mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: bump version to 0.65.0
主要更新: - 优化 WorkbenchPage 导航显示,项目管理按钮显示主题上下文 - 修复双重背景问题,移除冗余的背景色和边框 - 移除 NovelCanvas 中的 HeaderTitle 和横线 - 修复 MCP 相关测试的编译错误(添加 meta 字段) - 修复 scheduler 测试的导入问题 - 代码格式化和 lint 修复
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
import React from "react";
|
||||
import { Box, Home, Settings2 } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Home,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
Settings2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProjectSelector } from "@/components/projects/ProjectSelector";
|
||||
import { Navbar } from "../styles";
|
||||
@@ -9,11 +17,18 @@ interface ChatNavbarProps {
|
||||
onToggleHistory: () => void;
|
||||
showHistoryToggle?: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
onBackToProjectManagement?: () => void;
|
||||
onToggleSettings?: () => void;
|
||||
onBackHome?: () => void;
|
||||
projectId?: string | null;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
workspaceType?: string;
|
||||
novelCanvasControls?: {
|
||||
chapterListCollapsed: boolean;
|
||||
onToggleChapterList: () => void;
|
||||
onAddChapter: () => void;
|
||||
onCloseCanvas: () => void;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
@@ -21,11 +36,13 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
onToggleHistory,
|
||||
showHistoryToggle = true,
|
||||
onToggleFullscreen: _onToggleFullscreen,
|
||||
onBackToProjectManagement,
|
||||
onToggleSettings,
|
||||
onBackHome,
|
||||
projectId = null,
|
||||
onProjectChange,
|
||||
workspaceType,
|
||||
novelCanvasControls = null,
|
||||
}) => {
|
||||
return (
|
||||
<Navbar>
|
||||
@@ -51,6 +68,56 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
<Box size={18} />
|
||||
</Button>
|
||||
)}
|
||||
{onBackToProjectManagement && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onBackToProjectManagement}
|
||||
>
|
||||
项目管理
|
||||
</Button>
|
||||
)}
|
||||
{novelCanvasControls && (
|
||||
<>
|
||||
<div className="h-5 w-px bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={novelCanvasControls.onToggleChapterList}
|
||||
title={
|
||||
novelCanvasControls.chapterListCollapsed
|
||||
? "展开章节栏"
|
||||
: "收起章节栏"
|
||||
}
|
||||
>
|
||||
{novelCanvasControls.chapterListCollapsed ? (
|
||||
<PanelLeftOpen size={18} />
|
||||
) : (
|
||||
<PanelLeftClose size={18} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={novelCanvasControls.onAddChapter}
|
||||
title="新建章节"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={novelCanvasControls.onCloseCanvas}
|
||||
title="关闭画布"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DecisionPanel } from "./DecisionPanel";
|
||||
import type { ActionRequired, ConfirmResponse } from "../types";
|
||||
|
||||
interface RenderResult {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
onSubmit: ReturnType<typeof vi.fn<(response: ConfirmResponse) => void>>;
|
||||
}
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
function renderDecisionPanel(request: ActionRequired): RenderResult {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const onSubmit = vi.fn<(response: ConfirmResponse) => void>();
|
||||
|
||||
act(() => {
|
||||
root.render(<DecisionPanel request={request} onSubmit={onSubmit} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return { container, root, onSubmit };
|
||||
}
|
||||
|
||||
function findButtonByText(
|
||||
container: HTMLElement,
|
||||
text: string,
|
||||
): HTMLButtonElement {
|
||||
const target = Array.from(container.querySelectorAll("button")).find((node) =>
|
||||
node.textContent?.includes(text),
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error(`未找到按钮: ${text}`);
|
||||
}
|
||||
return target as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function findInputByPlaceholder(
|
||||
container: HTMLElement,
|
||||
placeholder: string,
|
||||
): HTMLInputElement {
|
||||
const target = container.querySelector<HTMLInputElement>(
|
||||
`input[placeholder="${placeholder}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error(`未找到输入框: ${placeholder}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function clickButton(button: HTMLButtonElement) {
|
||||
act(() => {
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function createElicitationRequest(requestId: string): ActionRequired {
|
||||
return {
|
||||
requestId,
|
||||
actionType: "elicitation",
|
||||
prompt: "请选择部署环境",
|
||||
requestedSchema: {
|
||||
properties: {
|
||||
answer: {
|
||||
description: "请选择一个环境",
|
||||
enum: ["开发环境", "生产环境"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("DecisionPanel elicitation", () => {
|
||||
it("应支持从 enum 选项选择并提交 userData.answer", () => {
|
||||
const request = createElicitationRequest("req-elicitation-option");
|
||||
const { container, onSubmit } = renderDecisionPanel(request);
|
||||
|
||||
const submitButton = findButtonByText(container, "提交");
|
||||
expect(submitButton.disabled).toBe(true);
|
||||
|
||||
clickButton(findButtonByText(container, "生产环境"));
|
||||
const answerInput = findInputByPlaceholder(container, "请输入回答...");
|
||||
expect(answerInput.value).toBe("生产环境");
|
||||
|
||||
clickButton(findButtonByText(container, "提交"));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
requestId: "req-elicitation-option",
|
||||
confirmed: true,
|
||||
response: JSON.stringify({ answer: "生产环境" }),
|
||||
actionType: "elicitation",
|
||||
userData: { answer: "生产环境" },
|
||||
});
|
||||
});
|
||||
|
||||
it("取消时应返回拒绝响应", () => {
|
||||
const request = createElicitationRequest("req-elicitation-cancel");
|
||||
const { container, onSubmit } = renderDecisionPanel(request);
|
||||
|
||||
clickButton(findButtonByText(container, "取消"));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.requestId).toBe("req-elicitation-cancel");
|
||||
expect(payload.confirmed).toBe(false);
|
||||
expect(payload.actionType).toBe("elicitation");
|
||||
expect(payload.response).toBe("用户拒绝了请求");
|
||||
expect(payload.userData).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -66,18 +66,53 @@ function formatArguments(args?: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 requested_schema 中提取 answer.enum 选项 */
|
||||
function extractElicitationOptions(
|
||||
requestedSchema?: Record<string, unknown>,
|
||||
): string[] {
|
||||
if (!requestedSchema) return [];
|
||||
const properties = requestedSchema.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const answer = properties?.answer as Record<string, unknown> | undefined;
|
||||
const enumValues = answer?.enum;
|
||||
if (!Array.isArray(enumValues)) return [];
|
||||
return enumValues.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
/** 从 requested_schema 中提取 answer.description */
|
||||
function extractElicitationDescription(
|
||||
requestedSchema?: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
if (!requestedSchema) return undefined;
|
||||
const properties = requestedSchema.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const answer = properties?.answer as Record<string, unknown> | undefined;
|
||||
const description = answer?.description;
|
||||
return typeof description === "string" ? description : undefined;
|
||||
}
|
||||
|
||||
export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
// 解析问题数据(用于 ask_user 类型)
|
||||
const questions = request.questions || [];
|
||||
const elicitationOptions = extractElicitationOptions(request.requestedSchema);
|
||||
const elicitationDescription = extractElicitationDescription(
|
||||
request.requestedSchema,
|
||||
);
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
Record<number, string[]>
|
||||
>({});
|
||||
const [otherInputs, setOtherInputs] = useState<Record<number, string>>({});
|
||||
const [elicitationAnswer, setElicitationAnswer] = useState("");
|
||||
const [elicitationOther, setElicitationOther] = useState("");
|
||||
|
||||
// 重置状态当请求变化时
|
||||
useEffect(() => {
|
||||
setSelectedOptions({});
|
||||
setOtherInputs({});
|
||||
setElicitationAnswer("");
|
||||
setElicitationOther("");
|
||||
}, [request.requestId]);
|
||||
|
||||
// 切换选项
|
||||
@@ -119,21 +154,51 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
|
||||
// 检查是否���以提交
|
||||
const canSubmit =
|
||||
questions.length === 0 ||
|
||||
questions.every((_, qIndex) => {
|
||||
const selected = selectedOptions[qIndex] ?? [];
|
||||
const otherText = otherInputs[qIndex]?.trim() ?? "";
|
||||
return selected.length > 0 || otherText.length > 0;
|
||||
});
|
||||
request.actionType === "elicitation"
|
||||
? elicitationAnswer.trim().length > 0 ||
|
||||
elicitationOther.trim().length > 0
|
||||
: questions.length === 0 ||
|
||||
questions.every((_, qIndex) => {
|
||||
const selected = selectedOptions[qIndex] ?? [];
|
||||
const otherText = otherInputs[qIndex]?.trim() ?? "";
|
||||
return selected.length > 0 || otherText.length > 0;
|
||||
});
|
||||
|
||||
// 处理允许
|
||||
const handleAllow = () => {
|
||||
const response =
|
||||
questions.length > 0 ? JSON.stringify(buildAnswers()) : undefined;
|
||||
if (request.actionType === "elicitation") {
|
||||
const answer = elicitationAnswer.trim();
|
||||
const other = elicitationOther.trim();
|
||||
const userData: Record<string, string> = {};
|
||||
|
||||
if (answer) {
|
||||
userData.answer = answer;
|
||||
}
|
||||
if (other) {
|
||||
userData.other = other;
|
||||
if (!userData.answer) {
|
||||
userData.answer = other;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
requestId: request.requestId,
|
||||
confirmed: true,
|
||||
response: JSON.stringify(userData),
|
||||
actionType: request.actionType,
|
||||
userData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = buildAnswers();
|
||||
const response = questions.length > 0 ? JSON.stringify(answers) : undefined;
|
||||
onSubmit({
|
||||
requestId: request.requestId,
|
||||
confirmed: true,
|
||||
response,
|
||||
actionType: request.actionType,
|
||||
userData: questions.length > 0 ? answers : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -143,9 +208,97 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
requestId: request.requestId,
|
||||
confirmed: false,
|
||||
response: "用户拒绝了请求",
|
||||
actionType: request.actionType,
|
||||
userData:
|
||||
request.actionType === "tool_confirmation" ? undefined : ("" as const),
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染 elicitation 面板
|
||||
if (request.actionType === "elicitation") {
|
||||
return (
|
||||
<Card className="border-indigo-200 bg-indigo-50/50 dark:border-indigo-800 dark:bg-indigo-950/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium text-indigo-700 dark:text-indigo-300">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
需要你提供信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-foreground">
|
||||
{request.prompt || "请提供继续执行所需的信息"}
|
||||
</p>
|
||||
|
||||
{elicitationDescription && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{elicitationDescription}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{elicitationOptions.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
{elicitationOptions.map((option) => {
|
||||
const isSelected = elicitationAnswer === option;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
className={cn(
|
||||
"rounded-lg border px-4 py-3 text-left text-sm transition-colors",
|
||||
isSelected
|
||||
? "border-indigo-500 bg-indigo-100 dark:border-indigo-400 dark:bg-indigo-900/30"
|
||||
: "border-border bg-background hover:border-indigo-300 hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setElicitationAnswer(option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
回答
|
||||
</label>
|
||||
<Input
|
||||
placeholder="请输入回答..."
|
||||
value={elicitationAnswer}
|
||||
onChange={(e) => setElicitationAnswer(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
补充说明(可选)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="可选补充内容..."
|
||||
value={elicitationOther}
|
||||
onChange={(e) => setElicitationOther(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAllow}
|
||||
disabled={!canSubmit}
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-4 w-4" />
|
||||
提交
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleDeny}>
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染用户问题面板
|
||||
if (
|
||||
request.actionType === "ask_user" &&
|
||||
@@ -197,6 +350,8 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
requestId: request.requestId,
|
||||
confirmed: true,
|
||||
response: option.label,
|
||||
actionType: request.actionType,
|
||||
userData: { answer: option.label },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
renameAgentSession,
|
||||
generateAgentTitle,
|
||||
parseStreamEvent,
|
||||
sendPermissionResponse,
|
||||
confirmAsterAction,
|
||||
submitAsterElicitationResponse,
|
||||
stopAsterSession,
|
||||
type AgentProcessStatus,
|
||||
type SessionInfo,
|
||||
@@ -1927,12 +1928,63 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
// 处理权限确认响应
|
||||
const handlePermissionResponse = async (response: ConfirmResponse) => {
|
||||
try {
|
||||
// 发送权限确认响应到后端
|
||||
await sendPermissionResponse({
|
||||
requestId: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
});
|
||||
const findActionTypeByRequestId = (
|
||||
requestId: string,
|
||||
): ConfirmResponse["actionType"] => {
|
||||
for (const message of messages) {
|
||||
const action = message.actionRequests?.find(
|
||||
(item) => item.requestId === requestId,
|
||||
);
|
||||
if (action) {
|
||||
return action.actionType;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const actionType =
|
||||
response.actionType || findActionTypeByRequestId(response.requestId);
|
||||
|
||||
if (actionType === "elicitation" || actionType === "ask_user") {
|
||||
const activeSessionId =
|
||||
currentStreamingSessionIdRef.current || sessionId;
|
||||
if (!activeSessionId) {
|
||||
throw new Error("缺少会话 ID,无法提交 elicitation 响应");
|
||||
}
|
||||
|
||||
let userData: unknown;
|
||||
if (!response.confirmed) {
|
||||
// 传空字符串给 ask_bridge,表示用户取消(extract_response 会返回 None)
|
||||
userData = "";
|
||||
} else if (response.userData !== undefined) {
|
||||
userData = response.userData;
|
||||
} else if (response.response !== undefined) {
|
||||
const rawResponse = response.response.trim();
|
||||
if (!rawResponse) {
|
||||
userData = "";
|
||||
} else {
|
||||
try {
|
||||
userData = JSON.parse(rawResponse);
|
||||
} catch {
|
||||
userData = rawResponse;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userData = "";
|
||||
}
|
||||
|
||||
await submitAsterElicitationResponse(
|
||||
activeSessionId,
|
||||
response.requestId,
|
||||
userData,
|
||||
);
|
||||
} else {
|
||||
await confirmAsterAction(
|
||||
response.requestId,
|
||||
response.confirmed,
|
||||
response.response,
|
||||
);
|
||||
}
|
||||
|
||||
// 移除已处理的权限请求
|
||||
setMessages((prev) =>
|
||||
@@ -1949,7 +2001,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
})),
|
||||
);
|
||||
|
||||
toast.success(response.confirmed ? "已确认操作" : "已拒绝操作");
|
||||
toast.success(response.confirmed ? "已提交操作" : "已拒绝操作");
|
||||
} catch (error) {
|
||||
console.error("[AgentChat] 权限确认响应失败:", error);
|
||||
toast.error("权限确认响应失败");
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockInitAsterAgent,
|
||||
mockSendAsterMessageStream,
|
||||
mockCreateAsterSession,
|
||||
mockListAsterSessions,
|
||||
mockGetAsterSession,
|
||||
mockStopAsterSession,
|
||||
mockConfirmAsterAction,
|
||||
mockSubmitAsterElicitationResponse,
|
||||
mockParseStreamEvent,
|
||||
mockSafeListen,
|
||||
mockToast,
|
||||
} = vi.hoisted(() => ({
|
||||
mockInitAsterAgent: vi.fn(),
|
||||
mockSendAsterMessageStream: vi.fn(),
|
||||
mockCreateAsterSession: vi.fn(),
|
||||
mockListAsterSessions: vi.fn(),
|
||||
mockGetAsterSession: vi.fn(),
|
||||
mockStopAsterSession: vi.fn(),
|
||||
mockConfirmAsterAction: vi.fn(),
|
||||
mockSubmitAsterElicitationResponse: vi.fn(),
|
||||
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
|
||||
mockSafeListen: vi.fn(),
|
||||
mockToast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agent", () => ({
|
||||
initAsterAgent: mockInitAsterAgent,
|
||||
sendAsterMessageStream: mockSendAsterMessageStream,
|
||||
createAsterSession: mockCreateAsterSession,
|
||||
listAsterSessions: mockListAsterSessions,
|
||||
getAsterSession: mockGetAsterSession,
|
||||
stopAsterSession: mockStopAsterSession,
|
||||
confirmAsterAction: mockConfirmAsterAction,
|
||||
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
|
||||
parseStreamEvent: mockParseStreamEvent,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeListen: mockSafeListen,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: mockToast,
|
||||
}));
|
||||
|
||||
import { useAsterAgentChat } from "./useAsterAgentChat";
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useAsterAgentChat>;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(workspaceId = "ws-test"): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useAsterAgentChat> | null = null;
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useAsterAgentChat({ workspaceId });
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function seedSession(workspaceId: string, sessionId: string) {
|
||||
sessionStorage.setItem(
|
||||
`aster_curr_sessionId_${workspaceId}`,
|
||||
JSON.stringify(sessionId),
|
||||
);
|
||||
sessionStorage.setItem(
|
||||
`aster_messages_${workspaceId}`,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "m-1",
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
mockInitAsterAgent.mockResolvedValue(undefined);
|
||||
mockSendAsterMessageStream.mockResolvedValue(undefined);
|
||||
mockCreateAsterSession.mockResolvedValue("created-session");
|
||||
mockListAsterSessions.mockResolvedValue([]);
|
||||
mockGetAsterSession.mockResolvedValue({
|
||||
id: "session-from-api",
|
||||
messages: [],
|
||||
});
|
||||
mockStopAsterSession.mockResolvedValue(undefined);
|
||||
mockConfirmAsterAction.mockResolvedValue(undefined);
|
||||
mockSubmitAsterElicitationResponse.mockResolvedValue(undefined);
|
||||
mockSafeListen.mockResolvedValue(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("useAsterAgentChat.confirmAction", () => {
|
||||
it("tool_confirmation 应调用 confirmAsterAction", async () => {
|
||||
const workspaceId = "ws-tool";
|
||||
seedSession(workspaceId, "session-tool");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
await harness.getValue().confirmAction({
|
||||
requestId: "req-tool-1",
|
||||
confirmed: true,
|
||||
response: "允许",
|
||||
actionType: "tool_confirmation",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockConfirmAsterAction).toHaveBeenCalledTimes(1);
|
||||
expect(mockConfirmAsterAction).toHaveBeenCalledWith(
|
||||
"req-tool-1",
|
||||
true,
|
||||
"允许",
|
||||
);
|
||||
expect(mockSubmitAsterElicitationResponse).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("elicitation 应调用 submitAsterElicitationResponse 并透传 userData", async () => {
|
||||
const workspaceId = "ws-elicitation";
|
||||
seedSession(workspaceId, "session-elicitation");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
await harness.getValue().confirmAction({
|
||||
requestId: "req-elicitation-1",
|
||||
confirmed: true,
|
||||
actionType: "elicitation",
|
||||
userData: { answer: "A" },
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledTimes(1);
|
||||
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledWith(
|
||||
"session-elicitation",
|
||||
"req-elicitation-1",
|
||||
{ answer: "A" },
|
||||
);
|
||||
expect(mockConfirmAsterAction).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("ask_user 应解析 response JSON 后提交", async () => {
|
||||
const workspaceId = "ws-ask-user";
|
||||
seedSession(workspaceId, "session-ask-user");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
await harness.getValue().confirmAction({
|
||||
requestId: "req-ask-user-1",
|
||||
confirmed: true,
|
||||
actionType: "ask_user",
|
||||
response: '{"answer":"选项A"}',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledTimes(1);
|
||||
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledWith(
|
||||
"session-ask-user",
|
||||
"req-ask-user-1",
|
||||
{ answer: "选项A" },
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getAsterSession,
|
||||
stopAsterSession,
|
||||
confirmAsterAction,
|
||||
submitAsterElicitationResponse,
|
||||
parseStreamEvent,
|
||||
type StreamEvent,
|
||||
type AsterSessionInfo,
|
||||
@@ -34,10 +35,11 @@ export interface Topic {
|
||||
/** 权限确认请求 */
|
||||
export interface ActionRequired {
|
||||
requestId: string;
|
||||
actionType: string;
|
||||
actionType: "tool_confirmation" | "ask_user" | "elicitation";
|
||||
toolName?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
question?: string;
|
||||
prompt?: string;
|
||||
requestedSchema?: Record<string, unknown>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@@ -46,6 +48,8 @@ export interface ConfirmResponse {
|
||||
requestId: string;
|
||||
confirmed: boolean;
|
||||
response?: string;
|
||||
actionType?: ActionRequired["actionType"];
|
||||
userData?: unknown;
|
||||
}
|
||||
|
||||
/** Hook 配置选项 */
|
||||
@@ -638,15 +642,22 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
unknown
|
||||
>;
|
||||
if (rawEvent.type === "action_required") {
|
||||
const actionPayload =
|
||||
(rawEvent.data as Record<string, unknown> | undefined) || {};
|
||||
const actionData: ActionRequired = {
|
||||
requestId: rawEvent.request_id as string,
|
||||
actionType: rawEvent.action_type as string,
|
||||
toolName: (rawEvent.data as Record<string, unknown>)
|
||||
?.tool_name as string,
|
||||
arguments: (rawEvent.data as Record<string, unknown>)
|
||||
?.arguments as Record<string, unknown>,
|
||||
question: (rawEvent.data as Record<string, unknown>)
|
||||
?.question as string,
|
||||
actionType:
|
||||
rawEvent.action_type as ActionRequired["actionType"],
|
||||
toolName: actionPayload.tool_name as string | undefined,
|
||||
arguments: actionPayload.arguments as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
prompt:
|
||||
(actionPayload.prompt as string | undefined) ||
|
||||
(actionPayload.message as string | undefined),
|
||||
requestedSchema: actionPayload.requested_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setPendingActions((prev) => [...prev, actionData]);
|
||||
@@ -725,22 +736,63 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
}, [sessionId]);
|
||||
|
||||
// 确认权限请求
|
||||
const confirmAction = useCallback(async (response: ConfirmResponse) => {
|
||||
try {
|
||||
await confirmAsterAction(
|
||||
response.requestId,
|
||||
response.confirmed,
|
||||
response.response,
|
||||
);
|
||||
// 移除已处理的请求
|
||||
setPendingActions((prev) =>
|
||||
prev.filter((a) => a.requestId !== response.requestId),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[AsterChat] 确认失败:", error);
|
||||
toast.error("确认操作失败");
|
||||
}
|
||||
}, []);
|
||||
const confirmAction = useCallback(
|
||||
async (response: ConfirmResponse) => {
|
||||
try {
|
||||
const actionType =
|
||||
response.actionType ||
|
||||
pendingActions.find((item) => item.requestId === response.requestId)
|
||||
?.actionType;
|
||||
|
||||
if (actionType === "elicitation" || actionType === "ask_user") {
|
||||
if (!sessionId) {
|
||||
throw new Error("缺少会话 ID,无法提交 elicitation 响应");
|
||||
}
|
||||
|
||||
let userData: unknown;
|
||||
if (!response.confirmed) {
|
||||
userData = "";
|
||||
} else if (response.userData !== undefined) {
|
||||
userData = response.userData;
|
||||
} else if (response.response !== undefined) {
|
||||
const rawResponse = response.response.trim();
|
||||
if (!rawResponse) {
|
||||
userData = "";
|
||||
} else {
|
||||
try {
|
||||
userData = JSON.parse(rawResponse);
|
||||
} catch {
|
||||
userData = rawResponse;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userData = "";
|
||||
}
|
||||
|
||||
await submitAsterElicitationResponse(
|
||||
sessionId,
|
||||
response.requestId,
|
||||
userData,
|
||||
);
|
||||
} else {
|
||||
await confirmAsterAction(
|
||||
response.requestId,
|
||||
response.confirmed,
|
||||
response.response,
|
||||
);
|
||||
}
|
||||
|
||||
// 移除已处理的请求
|
||||
setPendingActions((prev) =>
|
||||
prev.filter((a) => a.requestId !== response.requestId),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[AsterChat] 确认失败:", error);
|
||||
toast.error("确认操作失败");
|
||||
}
|
||||
},
|
||||
[pendingActions, sessionId],
|
||||
);
|
||||
|
||||
// 清空消息
|
||||
const clearMessages = useCallback(() => {
|
||||
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
import { ArtifactRenderer, ArtifactToolbar } from "@/components/artifact";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { createInitialMusicState } from "@/components/content-creator/canvas/music/types";
|
||||
import {
|
||||
createInitialNovelState,
|
||||
countWords as countNovelWords,
|
||||
} from "@/components/content-creator/canvas/novel/types";
|
||||
import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser";
|
||||
import {
|
||||
generateContentCreationPrompt,
|
||||
@@ -64,7 +68,11 @@ import { SettingsTabs } from "@/types/settings";
|
||||
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
|
||||
|
||||
import type { MessageImage } from "./types";
|
||||
import type { ThemeType, LayoutMode } from "@/components/content-creator/types";
|
||||
import type {
|
||||
ThemeType,
|
||||
LayoutMode,
|
||||
StepStatus,
|
||||
} from "@/components/content-creator/types";
|
||||
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import { getFileToStepMap } from "./utils/workflowMapping";
|
||||
|
||||
@@ -92,7 +100,6 @@ const PageContainer = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: hsl(var(--background));
|
||||
`;
|
||||
|
||||
const MainArea = styled.div`
|
||||
@@ -112,6 +119,15 @@ const ChatContainer = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ChatContainerInner = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ChatContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -135,6 +151,15 @@ function projectTypeToTheme(projectType: ProjectType): ThemeType {
|
||||
return projectType as ThemeType;
|
||||
}
|
||||
|
||||
export interface WorkflowProgressSnapshot {
|
||||
steps: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: StepStatus;
|
||||
}>;
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断画布状态是否为空
|
||||
* 用于决定是否自动触发 AI 引导
|
||||
@@ -183,6 +208,10 @@ export function AgentChatPage({
|
||||
initialCreationMode,
|
||||
lockTheme = false,
|
||||
hideHistoryToggle = false,
|
||||
showChatPanel = true,
|
||||
onBackToProjectManagement,
|
||||
hideInlineStepProgress = false,
|
||||
onWorkflowProgressChange,
|
||||
newChatAt,
|
||||
onRecommendationClick: _onRecommendationClick,
|
||||
onHasMessagesChange,
|
||||
@@ -194,6 +223,12 @@ export function AgentChatPage({
|
||||
initialCreationMode?: CreationMode;
|
||||
lockTheme?: boolean;
|
||||
hideHistoryToggle?: boolean;
|
||||
showChatPanel?: boolean;
|
||||
onBackToProjectManagement?: () => void;
|
||||
hideInlineStepProgress?: boolean;
|
||||
onWorkflowProgressChange?: (
|
||||
snapshot: WorkflowProgressSnapshot | null,
|
||||
) => void;
|
||||
newChatAt?: number;
|
||||
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
|
||||
onHasMessagesChange?: (hasMessages: boolean) => void;
|
||||
@@ -230,6 +265,8 @@ export function AgentChatPage({
|
||||
|
||||
// 画布状态(支持多种画布类型)
|
||||
const [canvasState, setCanvasState] = useState<CanvasStateUnion | null>(null);
|
||||
const [novelChapterListCollapsed, setNovelChapterListCollapsed] =
|
||||
useState(false);
|
||||
|
||||
// General 主题专用画布状态
|
||||
const [generalCanvasState, setGeneralCanvasState] =
|
||||
@@ -624,6 +661,63 @@ export function AgentChatPage({
|
||||
[],
|
||||
);
|
||||
|
||||
const looksLikeSerializedNovelState = useCallback((content: string) => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
const jsonCandidate =
|
||||
trimmed.match(/^```json\s*([\s\S]*?)```$/i)?.[1] || trimmed;
|
||||
|
||||
if (!(jsonCandidate.startsWith("[") || jsonCandidate.startsWith("{"))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
jsonCandidate.includes('"title"') &&
|
||||
(jsonCandidate.includes('"number"') ||
|
||||
jsonCandidate.includes('"chapters"'))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const upsertNovelCanvasState = useCallback(
|
||||
(prev: CanvasStateUnion | null, content: string) => {
|
||||
if (!prev || prev.type !== "novel") {
|
||||
return createInitialNovelState(content);
|
||||
}
|
||||
|
||||
if (looksLikeSerializedNovelState(content)) {
|
||||
return createInitialNovelState(content);
|
||||
}
|
||||
|
||||
const targetChapterId =
|
||||
prev.currentChapterId || prev.chapters[0]?.id || crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
if (prev.chapters.length === 0) {
|
||||
const initialized = createInitialNovelState(content);
|
||||
return {
|
||||
...initialized,
|
||||
currentChapterId: initialized.chapters[0]?.id || targetChapterId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
chapters: prev.chapters.map((chapter) =>
|
||||
chapter.id === targetChapterId
|
||||
? {
|
||||
...chapter,
|
||||
content,
|
||||
wordCount: countNovelWords(content),
|
||||
updatedAt: now,
|
||||
}
|
||||
: chapter,
|
||||
),
|
||||
};
|
||||
},
|
||||
[looksLikeSerializedNovelState],
|
||||
);
|
||||
|
||||
// 监听 AI 消息变化,自动提取文档内容
|
||||
useEffect(() => {
|
||||
if (!isContentCreationMode) return;
|
||||
@@ -653,6 +747,10 @@ export function AgentChatPage({
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, docContent);
|
||||
}
|
||||
|
||||
if (!prev || prev.type !== "document") {
|
||||
return createInitialDocumentState(docContent);
|
||||
}
|
||||
@@ -674,7 +772,13 @@ export function AgentChatPage({
|
||||
// 自动打开画布
|
||||
setLayoutMode("chat-canvas");
|
||||
}
|
||||
}, [messages, isContentCreationMode, extractDocumentContent, mappedTheme]);
|
||||
}, [
|
||||
messages,
|
||||
isContentCreationMode,
|
||||
extractDocumentContent,
|
||||
mappedTheme,
|
||||
upsertNovelCanvasState,
|
||||
]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (
|
||||
@@ -782,6 +886,51 @@ export function AgentChatPage({
|
||||
// 当开始对话时自动折叠侧边栏
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasState || canvasState.type !== "novel") {
|
||||
setNovelChapterListCollapsed(false);
|
||||
}
|
||||
}, [canvasState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showChatPanel) {
|
||||
setLayoutMode((previous) =>
|
||||
previous === "canvas" ? "chat-canvas" : previous,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSidebar(false);
|
||||
|
||||
if (layoutMode === "canvas") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutMode === "chat-canvas") {
|
||||
setLayoutMode("canvas");
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackContent = "# 新文档\n\n在这里开始编写内容...";
|
||||
|
||||
if (activeTheme === "general") {
|
||||
setGeneralCanvasState((previous) => ({
|
||||
...previous,
|
||||
isOpen: true,
|
||||
contentType:
|
||||
previous.contentType === "empty" ? "markdown" : previous.contentType,
|
||||
content: previous.content || fallbackContent,
|
||||
}));
|
||||
} else if (!canvasState) {
|
||||
const initialState =
|
||||
createInitialCanvasState(mappedTheme, fallbackContent) ||
|
||||
createInitialDocumentState(fallbackContent);
|
||||
setCanvasState(initialState);
|
||||
}
|
||||
|
||||
setLayoutMode("canvas");
|
||||
}, [showChatPanel, layoutMode, activeTheme, canvasState, mappedTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
onHasMessagesChange?.(hasMessages);
|
||||
}, [hasMessages, onHasMessagesChange]);
|
||||
@@ -833,6 +982,11 @@ export function AgentChatPage({
|
||||
}
|
||||
return { ...prev, sections };
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, latestContent);
|
||||
}
|
||||
|
||||
if (!prev || prev.type !== "document") {
|
||||
return createInitialDocumentState(latestContent);
|
||||
}
|
||||
@@ -841,12 +995,48 @@ export function AgentChatPage({
|
||||
setLayoutMode("chat-canvas");
|
||||
}
|
||||
}
|
||||
}, [taskFiles, mappedTheme]);
|
||||
}, [taskFiles, mappedTheme, upsertNovelCanvasState]);
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (!showChatPanel) {
|
||||
return;
|
||||
}
|
||||
setShowSidebar(!showSidebar);
|
||||
};
|
||||
|
||||
const handleToggleNovelChapterList = useCallback(() => {
|
||||
setNovelChapterListCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleAddNovelChapter = useCallback(() => {
|
||||
setCanvasState((prev) => {
|
||||
if (!prev || prev.type !== "novel") {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const chapterNumber = prev.chapters.length + 1;
|
||||
const title = `第${chapterNumber}章`;
|
||||
const newChapter = {
|
||||
id: crypto.randomUUID(),
|
||||
number: chapterNumber,
|
||||
title,
|
||||
content: `# ${title}\n\n`,
|
||||
wordCount: 0,
|
||||
status: "draft" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
chapters: [...prev.chapters, newChapter],
|
||||
currentChapterId: newChapter.id,
|
||||
};
|
||||
});
|
||||
setNovelChapterListCollapsed(false);
|
||||
}, []);
|
||||
|
||||
// 切换画布显示
|
||||
const handleToggleCanvas = useCallback(() => {
|
||||
// General 主题使用专门的画布
|
||||
@@ -883,12 +1073,16 @@ export function AgentChatPage({
|
||||
// 关闭画布
|
||||
const handleCloseCanvas = useCallback(() => {
|
||||
setLayoutMode("chat");
|
||||
setNovelChapterListCollapsed(false);
|
||||
// General 主题关闭画布状态
|
||||
if (activeTheme === "general") {
|
||||
setGeneralCanvasState((prev) => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
}, [activeTheme]);
|
||||
|
||||
const showNovelNavbarControls =
|
||||
layoutMode !== "chat" && canvasState?.type === "novel";
|
||||
|
||||
// 处理文件写入 - 同名文件更新内容,不同名文件独立保存
|
||||
const handleWriteFile = useCallback(
|
||||
(content: string, fileName: string) => {
|
||||
@@ -1072,6 +1266,10 @@ export function AgentChatPage({
|
||||
};
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, content);
|
||||
}
|
||||
|
||||
// 文档类型画布
|
||||
if (!prev || prev.type !== "document") {
|
||||
console.log("[AgentChatPage] 创建新文档状态");
|
||||
@@ -1095,6 +1293,7 @@ export function AgentChatPage({
|
||||
completeStep,
|
||||
mappedTheme,
|
||||
saveSessionFile,
|
||||
upsertNovelCanvasState,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1187,6 +1386,10 @@ export function AgentChatPage({
|
||||
return { ...prev, sections };
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, content);
|
||||
}
|
||||
|
||||
// 文档类型画布
|
||||
if (!prev || prev.type !== "document") {
|
||||
return createInitialDocumentState(content);
|
||||
@@ -1200,7 +1403,7 @@ export function AgentChatPage({
|
||||
// 打开画布
|
||||
setLayoutMode("chat-canvas");
|
||||
},
|
||||
[activeTheme, mappedTheme],
|
||||
[activeTheme, mappedTheme, upsertNovelCanvasState],
|
||||
);
|
||||
|
||||
// 处理代码块点击 - 在画布中显示代码(General 主题专用)
|
||||
@@ -1238,7 +1441,7 @@ export function AgentChatPage({
|
||||
// 判断是否应该折叠代码块(当画布打开且有 artifact 时)
|
||||
const shouldCollapseCodeBlocks = useMemo(() => {
|
||||
if (activeTheme !== "general") return false;
|
||||
if (layoutMode !== "chat-canvas") return false;
|
||||
if (layoutMode === "chat") return false;
|
||||
// 当画布打开时折叠代码块
|
||||
return artifacts.length > 0 || generalCanvasState.isOpen;
|
||||
}, [activeTheme, layoutMode, artifacts.length, generalCanvasState.isOpen]);
|
||||
@@ -1264,6 +1467,10 @@ export function AgentChatPage({
|
||||
return { ...prev, sections };
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, file.content!);
|
||||
}
|
||||
|
||||
// 文档类型画布
|
||||
if (!prev || prev.type !== "document") {
|
||||
return createInitialDocumentState(file.content!);
|
||||
@@ -1277,7 +1484,7 @@ export function AgentChatPage({
|
||||
setLayoutMode("chat-canvas");
|
||||
}
|
||||
},
|
||||
[mappedTheme],
|
||||
[mappedTheme, upsertNovelCanvasState],
|
||||
);
|
||||
|
||||
// A2UI 表单提交处理
|
||||
@@ -1349,6 +1556,57 @@ export function AgentChatPage({
|
||||
// 判断是否应该显示聊天布局(有消息)
|
||||
const showChatLayout = hasMessages;
|
||||
|
||||
const workflowProgressSignature = useMemo(() => {
|
||||
const shouldShow = isContentCreationMode && hasMessages && steps.length > 0;
|
||||
if (!shouldShow) {
|
||||
return "hidden";
|
||||
}
|
||||
|
||||
const stepSignature = steps
|
||||
.map((step) => `${step.id}:${step.status}:${step.title}`)
|
||||
.join("|");
|
||||
return `${currentStepIndex}:${stepSignature}`;
|
||||
}, [isContentCreationMode, hasMessages, steps, currentStepIndex]);
|
||||
|
||||
const lastWorkflowProgressSignatureRef = useRef<string>("");
|
||||
useEffect(() => {
|
||||
if (!onWorkflowProgressChange) return;
|
||||
if (
|
||||
lastWorkflowProgressSignatureRef.current === workflowProgressSignature
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastWorkflowProgressSignatureRef.current = workflowProgressSignature;
|
||||
|
||||
const shouldShow = isContentCreationMode && hasMessages && steps.length > 0;
|
||||
if (!shouldShow) {
|
||||
onWorkflowProgressChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onWorkflowProgressChange({
|
||||
currentIndex: currentStepIndex,
|
||||
steps: steps.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
status: step.status,
|
||||
})),
|
||||
});
|
||||
}, [
|
||||
onWorkflowProgressChange,
|
||||
workflowProgressSignature,
|
||||
isContentCreationMode,
|
||||
hasMessages,
|
||||
steps,
|
||||
currentStepIndex,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onWorkflowProgressChange?.(null);
|
||||
};
|
||||
}, [onWorkflowProgressChange]);
|
||||
|
||||
const handleManageProviders = useCallback(() => {
|
||||
_onNavigate?.("settings", {
|
||||
tab: SettingsTabs.Providers,
|
||||
@@ -1358,90 +1616,95 @@ export function AgentChatPage({
|
||||
// 聊天区域内容
|
||||
const chatContent = (
|
||||
<ChatContainer>
|
||||
{/* 步骤进度条 - 仅在内容创作模式且有消息时显示 */}
|
||||
{isContentCreationMode && hasMessages && steps.length > 0 && (
|
||||
<StepProgress
|
||||
steps={steps}
|
||||
currentIndex={currentStepIndex}
|
||||
onStepClick={goToStep}
|
||||
/>
|
||||
)}
|
||||
<ChatContainerInner>
|
||||
{/* 步骤进度条 - 仅在内容创作模式且有消息时显示 */}
|
||||
{!hideInlineStepProgress &&
|
||||
isContentCreationMode &&
|
||||
hasMessages &&
|
||||
steps.length > 0 && (
|
||||
<StepProgress
|
||||
steps={steps}
|
||||
currentIndex={currentStepIndex}
|
||||
onStepClick={goToStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showChatLayout ? (
|
||||
<ChatContent>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
onDeleteMessage={deleteMessage}
|
||||
onEditMessage={editMessage}
|
||||
onA2UISubmit={handleA2UISubmit}
|
||||
onWriteFile={handleWriteFile}
|
||||
onFileClick={handleFileClick}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
collapseCodeBlocks={shouldCollapseCodeBlocks}
|
||||
onCodeBlockClick={handleCodeBlockClick}
|
||||
/>
|
||||
</ChatContent>
|
||||
) : (
|
||||
<EmptyState
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
onSend={(text) => {
|
||||
handleSend([], false, false, text);
|
||||
}}
|
||||
providerType={providerType}
|
||||
setProviderType={setProviderType}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
onManageProviders={handleManageProviders}
|
||||
creationMode={creationMode}
|
||||
onCreationModeChange={setCreationMode}
|
||||
activeTheme={activeTheme}
|
||||
onThemeChange={(theme) => {
|
||||
if (!lockTheme) {
|
||||
setActiveTheme(theme);
|
||||
}
|
||||
}}
|
||||
showThemeTabs={false}
|
||||
onRecommendationClick={(shortLabel, fullPrompt) => {
|
||||
// 直接将推荐提示词放入输入框,不创建项目
|
||||
setInput(fullPrompt);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showChatLayout && (
|
||||
<>
|
||||
<Inputbar
|
||||
{showChatLayout ? (
|
||||
<ChatContent>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
onDeleteMessage={deleteMessage}
|
||||
onEditMessage={editMessage}
|
||||
onA2UISubmit={handleA2UISubmit}
|
||||
onWriteFile={handleWriteFile}
|
||||
onFileClick={handleFileClick}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
collapseCodeBlocks={shouldCollapseCodeBlocks}
|
||||
onCodeBlockClick={handleCodeBlockClick}
|
||||
/>
|
||||
</ChatContent>
|
||||
) : (
|
||||
<EmptyState
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
onSend={handleSend}
|
||||
onStop={stopSending}
|
||||
isLoading={isSending}
|
||||
onSend={(text) => {
|
||||
handleSend([], false, false, text);
|
||||
}}
|
||||
providerType={providerType}
|
||||
setProviderType={setProviderType}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
onManageProviders={handleManageProviders}
|
||||
disabled={!projectId}
|
||||
onClearMessages={handleClearMessages}
|
||||
onToggleCanvas={handleToggleCanvas}
|
||||
isCanvasOpen={layoutMode === "chat-canvas"}
|
||||
taskFiles={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
taskFilesExpanded={taskFilesExpanded}
|
||||
onToggleTaskFiles={() => setTaskFilesExpanded(!taskFilesExpanded)}
|
||||
onTaskFileClick={handleTaskFileClick}
|
||||
characters={projectMemory?.characters || []}
|
||||
onSelectCharacter={(character) => {
|
||||
setMentionedCharacters((prev) => {
|
||||
// 避免重复添加
|
||||
if (prev.find((c) => c.id === character.id)) return prev;
|
||||
return [...prev, character];
|
||||
});
|
||||
creationMode={creationMode}
|
||||
onCreationModeChange={setCreationMode}
|
||||
activeTheme={activeTheme}
|
||||
onThemeChange={(theme) => {
|
||||
if (!lockTheme) {
|
||||
setActiveTheme(theme);
|
||||
}
|
||||
}}
|
||||
showThemeTabs={false}
|
||||
onRecommendationClick={(shortLabel, fullPrompt) => {
|
||||
// 直接将推荐提示词放入输入框,不创建项目
|
||||
setInput(fullPrompt);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{showChatLayout && (
|
||||
<>
|
||||
<Inputbar
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
onSend={handleSend}
|
||||
onStop={stopSending}
|
||||
isLoading={isSending}
|
||||
providerType={providerType}
|
||||
setProviderType={setProviderType}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
onManageProviders={handleManageProviders}
|
||||
disabled={!projectId}
|
||||
onClearMessages={handleClearMessages}
|
||||
onToggleCanvas={handleToggleCanvas}
|
||||
isCanvasOpen={layoutMode !== "chat"}
|
||||
taskFiles={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
taskFilesExpanded={taskFilesExpanded}
|
||||
onToggleTaskFiles={() => setTaskFilesExpanded(!taskFilesExpanded)}
|
||||
onTaskFileClick={handleTaskFileClick}
|
||||
characters={projectMemory?.characters || []}
|
||||
onSelectCharacter={(character) => {
|
||||
setMentionedCharacters((prev) => {
|
||||
// 避免重复添加
|
||||
if (prev.find((c) => c.id === character.id)) return prev;
|
||||
return [...prev, character];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ChatContainerInner>
|
||||
</ChatContainer>
|
||||
);
|
||||
|
||||
@@ -1503,6 +1766,15 @@ export function AgentChatPage({
|
||||
onStateChange={setCanvasState}
|
||||
onClose={handleCloseCanvas}
|
||||
isStreaming={isSending}
|
||||
novelControls={
|
||||
canvasState.type === "novel"
|
||||
? {
|
||||
useExternalToolbar: true,
|
||||
chapterListCollapsed: novelChapterListCollapsed,
|
||||
onChapterListCollapsedChange: setNovelChapterListCollapsed,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1518,6 +1790,7 @@ export function AgentChatPage({
|
||||
isSending,
|
||||
artifactViewMode,
|
||||
artifactPreviewSize,
|
||||
novelChapterListCollapsed,
|
||||
]);
|
||||
|
||||
// ========== 渲染逻辑 ==========
|
||||
@@ -1526,7 +1799,7 @@ export function AgentChatPage({
|
||||
// General 主题与其他主题的区别仅在于不显示步骤进度条
|
||||
return (
|
||||
<PageContainer>
|
||||
{showSidebar && (
|
||||
{showChatPanel && showSidebar && (
|
||||
<ChatSidebar
|
||||
onNewChat={handleClearMessages}
|
||||
topics={topics}
|
||||
@@ -1541,8 +1814,9 @@ export function AgentChatPage({
|
||||
<ChatNavbar
|
||||
isRunning={isSending}
|
||||
onToggleHistory={handleToggleSidebar}
|
||||
showHistoryToggle={!hideHistoryToggle}
|
||||
showHistoryToggle={!hideHistoryToggle && showChatPanel}
|
||||
onToggleFullscreen={() => {}}
|
||||
onBackToProjectManagement={onBackToProjectManagement}
|
||||
projectId={projectId ?? null}
|
||||
onProjectChange={(newProjectId) => setInternalProjectId(newProjectId)}
|
||||
workspaceType={activeTheme}
|
||||
@@ -1552,6 +1826,16 @@ export function AgentChatPage({
|
||||
tab: SettingsTabs.ChatAppearance,
|
||||
});
|
||||
}}
|
||||
novelCanvasControls={
|
||||
showNovelNavbarControls
|
||||
? {
|
||||
chapterListCollapsed: novelChapterListCollapsed,
|
||||
onToggleChapterList: handleToggleNovelChapterList,
|
||||
onAddChapter: handleAddNovelChapter,
|
||||
onCloseCanvas: handleCloseCanvas,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 同步状态指示器 */}
|
||||
|
||||
@@ -63,6 +63,10 @@ export interface ConfirmResponse {
|
||||
confirmed: boolean;
|
||||
/** 响应内容(用户输入或选择的答案) */
|
||||
response?: string;
|
||||
/** 操作类型(用于前端分流) */
|
||||
actionType?: ActionRequired["actionType"];
|
||||
/** 原始用户数据(用于 elicitation) */
|
||||
userData?: unknown;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
@@ -32,6 +32,15 @@ interface CanvasFactoryProps {
|
||||
onClose: () => void;
|
||||
/** 是否正在流式输出(仅文档画布使用) */
|
||||
isStreaming?: boolean;
|
||||
/** 小说画布控制配置(可选) */
|
||||
novelControls?: {
|
||||
/** 是否由外部导航栏接管控制按钮 */
|
||||
useExternalToolbar: boolean;
|
||||
/** 章节栏是否折叠 */
|
||||
chapterListCollapsed: boolean;
|
||||
/** 章节栏折叠状态变更 */
|
||||
onChapterListCollapsedChange: (collapsed: boolean) => void;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +50,7 @@ interface CanvasFactoryProps {
|
||||
* 优先使用 state.type 来决定渲染哪个画布,以支持 general 等主题
|
||||
*/
|
||||
export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
({ theme, state, onStateChange, onClose, isStreaming }) => {
|
||||
({ theme, state, onStateChange, onClose, isStreaming, novelControls }) => {
|
||||
// 优先根据 state.type 渲染,这样 general 主题也能显示文档画布
|
||||
// 只有当 state.type 与 theme 对应的 canvasType 不匹配时才检查 theme
|
||||
const canvasType = useMemo(() => {
|
||||
@@ -102,6 +111,11 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
state={state}
|
||||
onStateChange={onStateChange as (s: NovelCanvasState) => void}
|
||||
onClose={onClose}
|
||||
useExternalToolbar={novelControls?.useExternalToolbar}
|
||||
chapterListCollapsed={novelControls?.chapterListCollapsed}
|
||||
onChapterListCollapsedChange={
|
||||
novelControls?.onChapterListCollapsedChange
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,21 @@ import { NotionEditor } from "./editor";
|
||||
import { PlatformTabs } from "./PlatformTabs";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const ContentArea = styled.div`
|
||||
@@ -171,41 +181,43 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<DocumentToolbar
|
||||
currentVersion={currentVersion}
|
||||
versions={state.versions}
|
||||
isEditing={state.isEditing}
|
||||
onVersionChange={handleVersionChange}
|
||||
onEditToggle={handleEditToggle}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onExport={handleExport}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<InnerContainer>
|
||||
<DocumentToolbar
|
||||
currentVersion={currentVersion}
|
||||
versions={state.versions}
|
||||
isEditing={state.isEditing}
|
||||
onVersionChange={handleVersionChange}
|
||||
onEditToggle={handleEditToggle}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onExport={handleExport}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ContentArea>
|
||||
{state.isEditing ? (
|
||||
<NotionEditor
|
||||
content={editingContent}
|
||||
onChange={setEditingContent}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<DocumentRenderer
|
||||
content={state.content}
|
||||
platform={state.platform}
|
||||
isStreaming={isStreaming}
|
||||
<ContentArea>
|
||||
{state.isEditing ? (
|
||||
<NotionEditor
|
||||
content={editingContent}
|
||||
onChange={setEditingContent}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<DocumentRenderer
|
||||
content={state.content}
|
||||
platform={state.platform}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)}
|
||||
</ContentArea>
|
||||
|
||||
{!state.isEditing && (
|
||||
<PlatformTabs
|
||||
currentPlatform={state.platform}
|
||||
onPlatformChange={handlePlatformChange}
|
||||
/>
|
||||
)}
|
||||
</ContentArea>
|
||||
|
||||
{!state.isEditing && (
|
||||
<PlatformTabs
|
||||
currentPlatform={state.platform}
|
||||
onPlatformChange={handlePlatformChange}
|
||||
/>
|
||||
)}
|
||||
</InnerContainer>
|
||||
|
||||
<Toast $visible={showToast}>{toastMessage}</Toast>
|
||||
</Container>
|
||||
|
||||
@@ -174,7 +174,7 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = memo(
|
||||
return (
|
||||
<Container>
|
||||
<LeftSection>
|
||||
<Title>📄 文档预览</Title>
|
||||
<Title>文档</Title>
|
||||
<VersionSelector
|
||||
currentVersion={currentVersion}
|
||||
versions={versions}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
.notion-editor-wrapper .ProseMirror {
|
||||
min-height: 100%;
|
||||
padding: 24px 32px;
|
||||
padding: 16px 24px;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
|
||||
@@ -27,11 +27,21 @@ const SECTION_DISPLAY_NAMES: Record<string, string> = {
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const ContentArea = styled.div`
|
||||
@@ -369,77 +379,85 @@ export const MusicCanvas: React.FC<MusicCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MusicToolbar
|
||||
spec={state.spec}
|
||||
viewMode={state.viewMode}
|
||||
isPlaying={state.isPlaying}
|
||||
canUndo={false}
|
||||
canRedo={false}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onPlayToggle={handlePlayToggle}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onExport={handleExport}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<InnerContainer>
|
||||
<MusicToolbar
|
||||
spec={state.spec}
|
||||
viewMode={state.viewMode}
|
||||
isPlaying={state.isPlaying}
|
||||
canUndo={false}
|
||||
canRedo={false}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onPlayToggle={handlePlayToggle}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onExport={handleExport}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ContentArea>
|
||||
<MainContent>
|
||||
<EditorPane>
|
||||
<SectionTitle>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
{state.viewMode === "lyrics" && "🎤 歌词"}
|
||||
{state.viewMode === "numbered" && "🎼 简谱"}
|
||||
{state.viewMode === "guitar" && "🎸 吉他谱"}
|
||||
{state.viewMode === "piano" && "🎹 钢琴谱"}
|
||||
{isStreaming && (
|
||||
<span style={{ fontSize: 12, color: "hsl(var(--accent))" }}>
|
||||
生成中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{state.viewMode === "lyrics" && (
|
||||
<button
|
||||
onClick={handleCopyLyrics}
|
||||
<ContentArea>
|
||||
<MainContent>
|
||||
<EditorPane>
|
||||
<SectionTitle>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "12px",
|
||||
color: isCopied
|
||||
? "hsl(var(--accent))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
transition: "color 0.2s",
|
||||
gap: "8px",
|
||||
}}
|
||||
title="复制歌词"
|
||||
>
|
||||
{isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{isCopied ? "已复制" : "复制"}
|
||||
</button>
|
||||
)}
|
||||
</SectionTitle>
|
||||
{renderContent()}
|
||||
</EditorPane>
|
||||
</MainContent>
|
||||
</ContentArea>
|
||||
{state.viewMode === "lyrics" && "🎤 歌词"}
|
||||
{state.viewMode === "numbered" && "🎼 简谱"}
|
||||
{state.viewMode === "guitar" && "🎸 吉他谱"}
|
||||
{state.viewMode === "piano" && "🎹 钢琴谱"}
|
||||
{isStreaming && (
|
||||
<span
|
||||
style={{ fontSize: 12, color: "hsl(var(--accent))" }}
|
||||
>
|
||||
生成中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{state.viewMode === "lyrics" && (
|
||||
<button
|
||||
onClick={handleCopyLyrics}
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "12px",
|
||||
color: isCopied
|
||||
? "hsl(var(--accent))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
transition: "color 0.2s",
|
||||
}}
|
||||
title="复制歌词"
|
||||
>
|
||||
{isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{isCopied ? "已复制" : "复制"}
|
||||
</button>
|
||||
)}
|
||||
</SectionTitle>
|
||||
{renderContent()}
|
||||
</EditorPane>
|
||||
</MainContent>
|
||||
</ContentArea>
|
||||
|
||||
<StatusBar>
|
||||
<StatusItem>
|
||||
🎵 {state.spec.title} | {state.spec.key} | {state.spec.tempo} BPM
|
||||
</StatusItem>
|
||||
<StatusItem>
|
||||
{stats.totalSections} 段 | {stats.totalLines} 行 |{" "}
|
||||
{stats.totalChars} 字
|
||||
</StatusItem>
|
||||
</StatusBar>
|
||||
<StatusBar>
|
||||
<StatusItem>
|
||||
🎵 {state.spec.title} | {state.spec.key} | {state.spec.tempo} BPM
|
||||
</StatusItem>
|
||||
<StatusItem>
|
||||
{stats.totalSections} 段 | {stats.totalLines} 行 |{" "}
|
||||
{stats.totalChars} 字
|
||||
</StatusItem>
|
||||
</StatusBar>
|
||||
|
||||
<Toast $visible={showToast}>{toastMessage}</Toast>
|
||||
<Toast $visible={showToast}>{toastMessage}</Toast>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -54,6 +54,13 @@ const RightSection = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const ThemeLabel = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
const SongTitle = styled.h2`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -199,6 +206,7 @@ export const MusicToolbar: React.FC<MusicToolbarProps> = memo(
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
<LeftSection>
|
||||
<ThemeLabel>音乐</ThemeLabel>
|
||||
<SongTitle>{spec.title}</SongTitle>
|
||||
<SongMeta>
|
||||
{spec.key} | {spec.tempo} BPM
|
||||
|
||||
@@ -4,13 +4,19 @@
|
||||
* 用于小说项目的章节编辑
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from "react";
|
||||
import React, { memo, useCallback, useState, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import { X, Plus, FileText, CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { NotionEditor } from "@/components/content-creator/canvas/document/editor";
|
||||
import type { NovelCanvasState, Chapter } from "./types";
|
||||
import { countWords } from "./types";
|
||||
|
||||
@@ -18,51 +24,45 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--muted) / 0.18);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
padding: 12px 16px;
|
||||
background: hsl(var(--background));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
border-radius: 12px 12px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
`;
|
||||
|
||||
const HeaderInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
`;
|
||||
|
||||
const HeaderMeta = styled.span`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: hsl(var(--background));
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const ChapterList = styled.div`
|
||||
width: 236px;
|
||||
min-width: 236px;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--muted) / 0.28);
|
||||
background: hsl(var(--background));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const ChapterListHeader = styled.div`
|
||||
@@ -78,6 +78,22 @@ const ChapterListBody = styled.div`
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const ChapterListFooter = styled.div`
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: hsl(var(--background));
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const StatItem = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ChapterItem = styled.div<{ $active?: boolean }>`
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
@@ -127,43 +143,21 @@ const EditorArea = styled.div`
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: hsl(var(--background));
|
||||
`;
|
||||
|
||||
const ChapterHeader = styled.div`
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: hsl(var(--muted) / 0.16);
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
flex: 1;
|
||||
padding: 18px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const Editor = styled(Textarea)`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.95;
|
||||
resize: none;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--background));
|
||||
padding: 18px 20px;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
|
||||
&:focus {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyEditorState = styled.div`
|
||||
flex: 1;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
@@ -175,43 +169,102 @@ const EmptyEditorState = styled.div`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const StatusBar = styled.div`
|
||||
padding: 9px 16px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--background));
|
||||
`;
|
||||
|
||||
interface NovelCanvasProps {
|
||||
state: NovelCanvasState;
|
||||
onStateChange: (state: NovelCanvasState) => void;
|
||||
onClose: () => void;
|
||||
useExternalToolbar?: boolean;
|
||||
chapterListCollapsed?: boolean;
|
||||
onChapterListCollapsedChange?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保章节内容第一行是标题格式 (# 标题)
|
||||
*/
|
||||
function ensureChapterTitleInContent(title: string, content: string): string {
|
||||
const lines = content.split("\n");
|
||||
const firstLine = lines[0] || "";
|
||||
|
||||
// 如果第一行已经是 H1 标题,更新它
|
||||
if (firstLine.startsWith("# ")) {
|
||||
lines[0] = `# ${title}`;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// 否则在开头插入标题
|
||||
return `# ${title}\n\n${content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从章节内容中提取标题 (第一行如果是 # 格式)
|
||||
*/
|
||||
function extractTitleFromContent(content: string): string | null {
|
||||
const lines = content.split("\n");
|
||||
const firstLine = lines[0] || "";
|
||||
|
||||
if (firstLine.startsWith("# ")) {
|
||||
return firstLine.substring(2).trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理章节内容,移除第一行标题
|
||||
*/
|
||||
function _sanitizeChapterContent(content: string): string {
|
||||
const lines = content.split("\n");
|
||||
if (lines[0]?.startsWith("# ")) {
|
||||
return lines.slice(1).join("\n").trim();
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
({ state, onStateChange, onClose }) => {
|
||||
({
|
||||
state,
|
||||
onStateChange,
|
||||
onClose,
|
||||
useExternalToolbar = false,
|
||||
chapterListCollapsed,
|
||||
onChapterListCollapsedChange,
|
||||
}) => {
|
||||
const [internalChapterListCollapsed, setInternalChapterListCollapsed] =
|
||||
useState(false);
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
const isChapterListCollapsed =
|
||||
chapterListCollapsed ?? internalChapterListCollapsed;
|
||||
const currentChapter = state.chapters.find(
|
||||
(c) => c.id === state.currentChapterId,
|
||||
);
|
||||
|
||||
const setChapterListCollapsed = useCallback(
|
||||
(collapsed: boolean) => {
|
||||
onChapterListCollapsedChange?.(collapsed);
|
||||
if (chapterListCollapsed === undefined) {
|
||||
setInternalChapterListCollapsed(collapsed);
|
||||
}
|
||||
},
|
||||
[chapterListCollapsed, onChapterListCollapsedChange],
|
||||
);
|
||||
|
||||
const handleChapterSelect = useCallback(
|
||||
(chapterId: string) => {
|
||||
onStateChange({ ...state, currentChapterId: chapterId });
|
||||
setEditorKey((prev) => prev + 1);
|
||||
},
|
||||
[state, onStateChange],
|
||||
);
|
||||
|
||||
const handleAddChapter = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const chapterNumber = state.chapters.length + 1;
|
||||
const title = `第${chapterNumber}章`;
|
||||
const newChapter: Chapter = {
|
||||
id: crypto.randomUUID(),
|
||||
number: state.chapters.length + 1,
|
||||
title: `第${state.chapters.length + 1}章`,
|
||||
content: "",
|
||||
number: chapterNumber,
|
||||
title,
|
||||
content: `# ${title}\n\n`,
|
||||
wordCount: 0,
|
||||
status: "draft",
|
||||
createdAt: now,
|
||||
@@ -222,20 +275,24 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
chapters: [...state.chapters, newChapter],
|
||||
currentChapterId: newChapter.id,
|
||||
});
|
||||
setEditorKey((prev) => prev + 1);
|
||||
}, [state, onStateChange]);
|
||||
|
||||
const handleUpdateChapter = useCallback(
|
||||
(updates: Partial<Chapter>) => {
|
||||
(content: string) => {
|
||||
if (!currentChapter) return;
|
||||
|
||||
// 从内容中提取标题
|
||||
const extractedTitle = extractTitleFromContent(content);
|
||||
const now = Date.now();
|
||||
|
||||
const updatedChapters = state.chapters.map((c) =>
|
||||
c.id === currentChapter.id
|
||||
? {
|
||||
...c,
|
||||
...updates,
|
||||
wordCount: updates.content
|
||||
? countWords(updates.content)
|
||||
: c.wordCount,
|
||||
title: extractedTitle || c.title,
|
||||
content,
|
||||
wordCount: countWords(content),
|
||||
updatedAt: now,
|
||||
}
|
||||
: c,
|
||||
@@ -245,6 +302,43 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
[state, currentChapter, onStateChange],
|
||||
);
|
||||
|
||||
const handleToggleStatus = useCallback(() => {
|
||||
if (!currentChapter) return;
|
||||
const now = Date.now();
|
||||
const updatedChapters: Chapter[] = state.chapters.map(
|
||||
(c): Chapter =>
|
||||
c.id === currentChapter.id
|
||||
? {
|
||||
...c,
|
||||
status: c.status === "completed" ? "draft" : "completed",
|
||||
updatedAt: now,
|
||||
}
|
||||
: c,
|
||||
);
|
||||
onStateChange({ ...state, chapters: updatedChapters });
|
||||
}, [state, currentChapter, onStateChange]);
|
||||
|
||||
// 确保当前章节内容包含标题
|
||||
useEffect(() => {
|
||||
if (currentChapter) {
|
||||
const extractedTitle = extractTitleFromContent(currentChapter.content);
|
||||
if (!extractedTitle || extractedTitle !== currentChapter.title) {
|
||||
const updatedContent = ensureChapterTitleInContent(
|
||||
currentChapter.title,
|
||||
currentChapter.content,
|
||||
);
|
||||
if (updatedContent !== currentChapter.content) {
|
||||
const updatedChapters = state.chapters.map((c) =>
|
||||
c.id === currentChapter.id
|
||||
? { ...c, content: updatedContent }
|
||||
: c,
|
||||
);
|
||||
onStateChange({ ...state, chapters: updatedChapters });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentChapter, state, onStateChange]);
|
||||
|
||||
const totalWords = state.chapters.reduce((sum, c) => sum + c.wordCount, 0);
|
||||
const completedCount = state.chapters.filter(
|
||||
(c) => c.status === "completed",
|
||||
@@ -252,111 +346,128 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<HeaderInfo>
|
||||
<Title>小说编辑器</Title>
|
||||
<HeaderMeta>
|
||||
{state.chapters.length} 章 · {totalWords} 字
|
||||
</HeaderMeta>
|
||||
</HeaderInfo>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Header>
|
||||
<InnerContainer>
|
||||
{!useExternalToolbar && (
|
||||
<Header>
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleAddChapter}
|
||||
title="新建章节"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Header>
|
||||
)}
|
||||
<Content>
|
||||
{!isChapterListCollapsed && (
|
||||
<ChapterList>
|
||||
<ChapterListHeader>
|
||||
<span className="text-sm font-medium">章节</span>
|
||||
{!useExternalToolbar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setChapterListCollapsed(true)}
|
||||
title="收起章节栏"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</ChapterListHeader>
|
||||
<ScrollArea className="flex-1">
|
||||
<ChapterListBody>
|
||||
{state.chapters.map((chapter) => (
|
||||
<ChapterItem
|
||||
key={chapter.id}
|
||||
$active={chapter.id === state.currentChapterId}
|
||||
onClick={() => handleChapterSelect(chapter.id)}
|
||||
>
|
||||
<ChapterTitle>
|
||||
{chapter.status === "completed" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<ChapterTitleText>{chapter.title}</ChapterTitleText>
|
||||
</ChapterTitle>
|
||||
<ChapterMeta>{chapter.wordCount} 字</ChapterMeta>
|
||||
</ChapterItem>
|
||||
))}
|
||||
</ChapterListBody>
|
||||
</ScrollArea>
|
||||
<ChapterListFooter>
|
||||
<StatItem>
|
||||
<span>总章节</span>
|
||||
<span>{state.chapters.length}</span>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<span>已完成</span>
|
||||
<span>{completedCount}</span>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<span>总字数</span>
|
||||
<span>{totalWords.toLocaleString()}</span>
|
||||
</StatItem>
|
||||
</ChapterListFooter>
|
||||
</ChapterList>
|
||||
)}
|
||||
|
||||
<Content>
|
||||
<ChapterList>
|
||||
<ChapterListHeader>
|
||||
<span className="text-sm font-medium">章节</span>
|
||||
<Button variant="ghost" size="icon" onClick={handleAddChapter}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</ChapterListHeader>
|
||||
<ScrollArea className="flex-1">
|
||||
<ChapterListBody>
|
||||
{state.chapters.map((chapter) => (
|
||||
<ChapterItem
|
||||
key={chapter.id}
|
||||
$active={chapter.id === state.currentChapterId}
|
||||
onClick={() => handleChapterSelect(chapter.id)}
|
||||
>
|
||||
<ChapterTitle>
|
||||
{chapter.status === "completed" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<ChapterTitleText>{chapter.title}</ChapterTitleText>
|
||||
</ChapterTitle>
|
||||
<ChapterMeta>{chapter.wordCount} 字</ChapterMeta>
|
||||
</ChapterItem>
|
||||
))}
|
||||
</ChapterListBody>
|
||||
</ScrollArea>
|
||||
</ChapterList>
|
||||
|
||||
<EditorArea>
|
||||
{currentChapter && (
|
||||
<>
|
||||
<ChapterHeader>
|
||||
<Input
|
||||
value={currentChapter.title}
|
||||
onChange={(e) =>
|
||||
handleUpdateChapter({ title: e.target.value })
|
||||
}
|
||||
placeholder="章节标题"
|
||||
className="text-lg font-medium"
|
||||
/>
|
||||
<EditorArea>
|
||||
{!useExternalToolbar && isChapterListCollapsed && (
|
||||
<div className="absolute bottom-2 left-2 z-10 flex items-center gap-1 rounded-md border bg-background/90 p-1 shadow-sm">
|
||||
<Button
|
||||
variant={
|
||||
currentChapter.status === "completed"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleUpdateChapter({
|
||||
status:
|
||||
currentChapter.status === "completed"
|
||||
? "draft"
|
||||
: "completed",
|
||||
})
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setChapterListCollapsed(false)}
|
||||
title="展开章节栏"
|
||||
>
|
||||
{currentChapter.status === "completed"
|
||||
? "已完成"
|
||||
: "标记完成"}
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</ChapterHeader>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleAddChapter}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{currentChapter && (
|
||||
<EditorContainer>
|
||||
<Editor
|
||||
value={currentChapter.content}
|
||||
onChange={(e) =>
|
||||
handleUpdateChapter({ content: e.target.value })
|
||||
}
|
||||
placeholder="开始写作..."
|
||||
<NotionEditor
|
||||
key={editorKey}
|
||||
content={currentChapter.content}
|
||||
onChange={handleUpdateChapter}
|
||||
onSave={handleToggleStatus}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</EditorContainer>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!currentChapter && (
|
||||
<EditorContainer>
|
||||
<EmptyEditorState>
|
||||
请先选择章节,或在左侧新建章节开始创作
|
||||
</EmptyEditorState>
|
||||
</EditorContainer>
|
||||
)}
|
||||
</EditorArea>
|
||||
</Content>
|
||||
|
||||
<StatusBar>
|
||||
<span>
|
||||
{completedCount}/{state.chapters.length} 章完成
|
||||
</span>
|
||||
<span>总字数:{totalWords.toLocaleString()}</span>
|
||||
</StatusBar>
|
||||
{!currentChapter && (
|
||||
<EditorContainer>
|
||||
<EmptyEditorState>
|
||||
请先选择章节,或在左侧新建章节开始创作
|
||||
</EmptyEditorState>
|
||||
</EditorContainer>
|
||||
)}
|
||||
</EditorArea>
|
||||
</Content>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -33,15 +33,143 @@ export interface NovelCanvasState {
|
||||
synopsis?: string;
|
||||
}
|
||||
|
||||
/** 计算字数 */
|
||||
export function countWords(text: string): number {
|
||||
// 简单的中文字数统计
|
||||
return text.replace(/\s/g, "").length;
|
||||
}
|
||||
|
||||
function sanitizeNovelContent(raw: string): string {
|
||||
if (!raw) return "";
|
||||
|
||||
let text = raw;
|
||||
|
||||
// 过滤 A2UI 标签块
|
||||
text = text.replace(/<a2ui>[\s\S]*?<\/a2ui>/gi, "");
|
||||
|
||||
// 过滤常见 A2UI fenced code block
|
||||
text = text.replace(/```a2ui[\s\S]*?```/gi, "");
|
||||
|
||||
// 过滤包含 A2UI 特征字段的 JSON code block
|
||||
text = text.replace(/```json\s*([\s\S]*?)```/gi, (block, inner) => {
|
||||
const normalized = String(inner).toLowerCase();
|
||||
const looksLikeA2UI =
|
||||
normalized.includes('"components"') &&
|
||||
(normalized.includes('"type":"form"') ||
|
||||
normalized.includes('"submitaction"') ||
|
||||
normalized.includes('"root"'));
|
||||
|
||||
return looksLikeA2UI ? "" : block;
|
||||
});
|
||||
|
||||
// 过滤误写入正文的章节序列化 JSON(历史数据兼容)
|
||||
const jsonCandidate = text.match(/^```json\s*([\s\S]*?)```$/i)?.[1] || text;
|
||||
try {
|
||||
const parsed = JSON.parse(jsonCandidate) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
const looksLikeChapterList = parsed.some(
|
||||
(item) => item && typeof item === "object" && "title" in item,
|
||||
);
|
||||
if (looksLikeChapterList) {
|
||||
const merged = parsed
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return "";
|
||||
const chapter = item as { content?: unknown };
|
||||
return typeof chapter.content === "string" ? chapter.content : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
|
||||
text = merged;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON,忽略
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function normalizeChapter(raw: Partial<Chapter>, index: number): Chapter {
|
||||
const now = Date.now();
|
||||
const chapterContent = sanitizeNovelContent(raw.content ?? "");
|
||||
|
||||
return {
|
||||
id: raw.id || crypto.randomUUID(),
|
||||
number:
|
||||
typeof raw.number === "number" && raw.number > 0 ? raw.number : index + 1,
|
||||
title:
|
||||
typeof raw.title === "string" && raw.title.trim()
|
||||
? raw.title.trim()
|
||||
: `第${index + 1}章`,
|
||||
content: chapterContent,
|
||||
wordCount: countWords(chapterContent),
|
||||
status: raw.status === "completed" ? "completed" : "draft",
|
||||
createdAt:
|
||||
typeof raw.createdAt === "number" && Number.isFinite(raw.createdAt)
|
||||
? raw.createdAt
|
||||
: now,
|
||||
updatedAt:
|
||||
typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt)
|
||||
? raw.updatedAt
|
||||
: now,
|
||||
};
|
||||
}
|
||||
|
||||
function tryParseSerializedChapters(content: string): Chapter[] | null {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const jsonCandidate =
|
||||
trimmed.match(/^```json\s*([\s\S]*?)```$/i)?.[1] || trimmed;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonCandidate);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item, index) => normalizeChapter(item || {}, index));
|
||||
}
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
Array.isArray((parsed as { chapters?: unknown[] }).chapters)
|
||||
) {
|
||||
const chapters = (parsed as { chapters: unknown[] }).chapters;
|
||||
return chapters.map((item, index) =>
|
||||
normalizeChapter((item as Partial<Chapter>) || {}, index),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建初始小说状态 */
|
||||
export function createInitialNovelState(content?: string): NovelCanvasState {
|
||||
const rawContent = content ?? "";
|
||||
|
||||
const parsedChapters = tryParseSerializedChapters(rawContent);
|
||||
if (parsedChapters && parsedChapters.length > 0) {
|
||||
return {
|
||||
type: "novel",
|
||||
chapters: parsedChapters,
|
||||
currentChapterId: parsedChapters[0].id,
|
||||
outline: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sanitizedContent = sanitizeNovelContent(rawContent);
|
||||
const now = Date.now();
|
||||
const defaultChapter: Chapter = {
|
||||
id: crypto.randomUUID(),
|
||||
number: 1,
|
||||
title: "第一章",
|
||||
content: content || "",
|
||||
wordCount: content ? content.length : 0,
|
||||
content: sanitizedContent,
|
||||
wordCount: countWords(sanitizedContent),
|
||||
status: "draft",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -55,12 +183,6 @@ export function createInitialNovelState(content?: string): NovelCanvasState {
|
||||
};
|
||||
}
|
||||
|
||||
/** 计算字数 */
|
||||
export function countWords(text: string): number {
|
||||
// 简单的中文字数统计
|
||||
return text.replace(/\s/g, "").length;
|
||||
}
|
||||
|
||||
/** 将小说状态转换为文本 */
|
||||
export function novelStateToText(state: NovelCanvasState): string {
|
||||
let text = "";
|
||||
|
||||
@@ -31,8 +31,18 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--muted));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const MainArea = styled.div`
|
||||
@@ -335,87 +345,89 @@ export const PosterCanvas: React.FC<PosterCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<PosterToolbar
|
||||
zoom={zoom}
|
||||
showGrid={state.showGrid}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
canvasWidth={currentPage?.width || 1080}
|
||||
canvasHeight={currentPage?.height || 1080}
|
||||
onZoomChange={handleZoomChange}
|
||||
onToggleGrid={handleToggleGrid}
|
||||
onToggleLayerPanel={handleToggleLayerPanel}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onExport={handleExport}
|
||||
onSizeChange={handleSizeChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<InnerContainer>
|
||||
<PosterToolbar
|
||||
zoom={zoom}
|
||||
showGrid={state.showGrid}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
canvasWidth={currentPage?.width || 1080}
|
||||
canvasHeight={currentPage?.height || 1080}
|
||||
onZoomChange={handleZoomChange}
|
||||
onToggleGrid={handleToggleGrid}
|
||||
onToggleLayerPanel={handleToggleLayerPanel}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onExport={handleExport}
|
||||
onSizeChange={handleSizeChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<MainArea>
|
||||
<CanvasWrapper ref={wrapperRef}>
|
||||
<CanvasContainer $zoom={zoom}>
|
||||
<canvas ref={canvasRef} />
|
||||
<GridOverlay
|
||||
$show={state.showGrid}
|
||||
$width={currentPage?.width || 0}
|
||||
$height={currentPage?.height || 0}
|
||||
<MainArea>
|
||||
<CanvasWrapper ref={wrapperRef}>
|
||||
<CanvasContainer $zoom={zoom}>
|
||||
<canvas ref={canvasRef} />
|
||||
<GridOverlay
|
||||
$show={state.showGrid}
|
||||
$width={currentPage?.width || 0}
|
||||
$height={currentPage?.height || 0}
|
||||
/>
|
||||
</CanvasContainer>
|
||||
</CanvasWrapper>
|
||||
|
||||
{/* 图层面板 */}
|
||||
{showLayerPanel && (
|
||||
<LayerPanel
|
||||
layers={layers}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={(ids) => {
|
||||
// 选择图层对应的元素
|
||||
if (ids.length === 1) {
|
||||
selectLayerElement(ids[0]);
|
||||
} else if (ids.length > 1) {
|
||||
// 多选时选择第一个
|
||||
selectLayerElement(ids[0]);
|
||||
}
|
||||
}}
|
||||
onReorder={reorderLayer}
|
||||
onToggleVisibility={toggleLayerVisibility}
|
||||
onToggleLock={toggleLayerLock}
|
||||
onRename={renameLayer}
|
||||
onClose={handleToggleLayerPanel}
|
||||
/>
|
||||
</CanvasContainer>
|
||||
</CanvasWrapper>
|
||||
)}
|
||||
</MainArea>
|
||||
|
||||
{/* 图层面板 */}
|
||||
{showLayerPanel && (
|
||||
<LayerPanel
|
||||
layers={layers}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={(ids) => {
|
||||
// 选择图层对应的元素
|
||||
if (ids.length === 1) {
|
||||
selectLayerElement(ids[0]);
|
||||
} else if (ids.length > 1) {
|
||||
// 多选时选择第一个
|
||||
selectLayerElement(ids[0]);
|
||||
}
|
||||
}}
|
||||
onReorder={reorderLayer}
|
||||
onToggleVisibility={toggleLayerVisibility}
|
||||
onToggleLock={toggleLayerLock}
|
||||
onRename={renameLayer}
|
||||
onClose={handleToggleLayerPanel}
|
||||
/>
|
||||
)}
|
||||
</MainArea>
|
||||
<PageList
|
||||
pages={state.pages}
|
||||
currentIndex={state.currentPageIndex}
|
||||
onPageSelect={selectPage}
|
||||
onAddPage={addPage}
|
||||
onDeletePage={deletePage}
|
||||
onDuplicatePage={duplicatePage}
|
||||
onReorderPages={reorderPages}
|
||||
/>
|
||||
|
||||
<PageList
|
||||
pages={state.pages}
|
||||
currentIndex={state.currentPageIndex}
|
||||
onPageSelect={selectPage}
|
||||
onAddPage={addPage}
|
||||
onDeletePage={deletePage}
|
||||
onDuplicatePage={duplicatePage}
|
||||
onReorderPages={reorderPages}
|
||||
/>
|
||||
<ElementToolbar
|
||||
onAddText={handleAddText}
|
||||
onAddImage={handleAddImage}
|
||||
onAddShape={handleAddShape}
|
||||
onSetBackground={handleSetBackground}
|
||||
hasSelection={selectedIds.length > 0}
|
||||
gridSnapEnabled={gridSnapEnabled}
|
||||
onAlign={handleAlign}
|
||||
onToggleGridSnap={toggleGridSnap}
|
||||
/>
|
||||
|
||||
<ElementToolbar
|
||||
onAddText={handleAddText}
|
||||
onAddImage={handleAddImage}
|
||||
onAddShape={handleAddShape}
|
||||
onSetBackground={handleSetBackground}
|
||||
hasSelection={selectedIds.length > 0}
|
||||
gridSnapEnabled={gridSnapEnabled}
|
||||
onAlign={handleAlign}
|
||||
onToggleGridSnap={toggleGridSnap}
|
||||
/>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -37,6 +37,13 @@ import type { PosterToolbarProps } from "./types";
|
||||
import { ZOOM_PRESETS, ZOOM_MIN, ZOOM_MAX } from "./types";
|
||||
import { SizeSelector } from "./SizeSelector";
|
||||
|
||||
const ThemeLabel = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const ToolbarContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -184,8 +191,10 @@ export const PosterToolbar: React.FC<PosterToolbarProps> = memo(
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<ToolbarContainer>
|
||||
{/* 左侧:缩放控制 */}
|
||||
{/* 左侧:主题标签和缩放控制 */}
|
||||
<ToolbarGroup>
|
||||
<ThemeLabel>海报</ThemeLabel>
|
||||
<Divider />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -14,11 +14,21 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { ScriptCanvasState, Scene, Dialogue } from "./types";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
@@ -200,116 +210,118 @@ export const ScriptCanvas: React.FC<ScriptCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>剧本编辑器</Title>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Header>
|
||||
<InnerContainer>
|
||||
<Header>
|
||||
<Title>剧本</Title>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Header>
|
||||
|
||||
<Content>
|
||||
<SceneList>
|
||||
<SceneListHeader>
|
||||
<span className="text-sm font-medium">场景</span>
|
||||
<Button variant="ghost" size="icon" onClick={handleAddScene}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</SceneListHeader>
|
||||
<ScrollArea className="flex-1">
|
||||
{state.scenes.map((scene) => (
|
||||
<SceneItem
|
||||
key={scene.id}
|
||||
$active={scene.id === state.currentSceneId}
|
||||
onClick={() => handleSceneSelect(scene.id)}
|
||||
>
|
||||
<SceneNumber>第{scene.number}场</SceneNumber>
|
||||
<SceneLocation>
|
||||
{scene.location}({scene.time})
|
||||
</SceneLocation>
|
||||
</SceneItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</SceneList>
|
||||
<Content>
|
||||
<SceneList>
|
||||
<SceneListHeader>
|
||||
<span className="text-sm font-medium">场景</span>
|
||||
<Button variant="ghost" size="icon" onClick={handleAddScene}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</SceneListHeader>
|
||||
<ScrollArea className="flex-1">
|
||||
{state.scenes.map((scene) => (
|
||||
<SceneItem
|
||||
key={scene.id}
|
||||
$active={scene.id === state.currentSceneId}
|
||||
onClick={() => handleSceneSelect(scene.id)}
|
||||
>
|
||||
<SceneNumber>第{scene.number}场</SceneNumber>
|
||||
<SceneLocation>
|
||||
{scene.location}({scene.time})
|
||||
</SceneLocation>
|
||||
</SceneItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</SceneList>
|
||||
|
||||
<EditorArea>
|
||||
{currentScene && (
|
||||
<>
|
||||
<SceneHeader>
|
||||
<Input
|
||||
value={currentScene.location}
|
||||
onChange={(e) =>
|
||||
handleUpdateScene({ location: e.target.value })
|
||||
}
|
||||
placeholder="场景地点"
|
||||
className="w-40"
|
||||
/>
|
||||
<Input
|
||||
value={currentScene.time}
|
||||
onChange={(e) =>
|
||||
handleUpdateScene({ time: e.target.value })
|
||||
}
|
||||
placeholder="时间"
|
||||
className="w-20"
|
||||
/>
|
||||
<Textarea
|
||||
value={currentScene.description || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateScene({ description: e.target.value })
|
||||
}
|
||||
placeholder="场景描述..."
|
||||
className="flex-1 min-h-[40px] resize-none"
|
||||
/>
|
||||
</SceneHeader>
|
||||
<EditorArea>
|
||||
{currentScene && (
|
||||
<>
|
||||
<SceneHeader>
|
||||
<Input
|
||||
value={currentScene.location}
|
||||
onChange={(e) =>
|
||||
handleUpdateScene({ location: e.target.value })
|
||||
}
|
||||
placeholder="场景地点"
|
||||
className="w-40"
|
||||
/>
|
||||
<Input
|
||||
value={currentScene.time}
|
||||
onChange={(e) =>
|
||||
handleUpdateScene({ time: e.target.value })
|
||||
}
|
||||
placeholder="时间"
|
||||
className="w-20"
|
||||
/>
|
||||
<Textarea
|
||||
value={currentScene.description || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateScene({ description: e.target.value })
|
||||
}
|
||||
placeholder="场景描述..."
|
||||
className="flex-1 min-h-[40px] resize-none"
|
||||
/>
|
||||
</SceneHeader>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<DialogueList>
|
||||
{currentScene.dialogues.map((dialogue) => (
|
||||
<DialogueItem key={dialogue.id}>
|
||||
<DialogueHeader>
|
||||
<Input
|
||||
value={dialogue.characterName}
|
||||
<ScrollArea className="flex-1">
|
||||
<DialogueList>
|
||||
{currentScene.dialogues.map((dialogue) => (
|
||||
<DialogueItem key={dialogue.id}>
|
||||
<DialogueHeader>
|
||||
<Input
|
||||
value={dialogue.characterName}
|
||||
onChange={(e) =>
|
||||
handleUpdateDialogue(dialogue.id, {
|
||||
characterName: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="角色名"
|
||||
className="w-32"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteDialogue(dialogue.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogueHeader>
|
||||
<Textarea
|
||||
value={dialogue.content}
|
||||
onChange={(e) =>
|
||||
handleUpdateDialogue(dialogue.id, {
|
||||
characterName: e.target.value,
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="角色名"
|
||||
className="w-32"
|
||||
placeholder="对白内容..."
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteDialogue(dialogue.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogueHeader>
|
||||
<Textarea
|
||||
value={dialogue.content}
|
||||
onChange={(e) =>
|
||||
handleUpdateDialogue(dialogue.id, {
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="对白内容..."
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
</DialogueItem>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleAddDialogue}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加对白
|
||||
</Button>
|
||||
</DialogueList>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</EditorArea>
|
||||
</Content>
|
||||
</DialogueItem>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleAddDialogue}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加对白
|
||||
</Button>
|
||||
</DialogueList>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</EditorArea>
|
||||
</Content>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -16,15 +16,31 @@ const Container = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ChatPanel = styled.div<{ $width: string; $duration: number }>`
|
||||
const ChatPanel = styled.div<{
|
||||
$width: string;
|
||||
$duration: number;
|
||||
$minWidth: string;
|
||||
}>`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: width ${({ $duration }) => $duration}ms ease-out;
|
||||
width: ${({ $width }) => $width};
|
||||
min-width: 460px;
|
||||
min-width: ${({ $minWidth }) => $minWidth};
|
||||
will-change: width;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 16px 16px 0;
|
||||
`;
|
||||
|
||||
const ChatPanelInner = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--background));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const CanvasPanel = styled.div<{
|
||||
@@ -90,8 +106,9 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
|
||||
$duration={parseInt(
|
||||
chatStyles.transition?.match(/\d+/)?.[0] || "300",
|
||||
)}
|
||||
$minWidth={mode === "canvas" ? "0px" : "460px"}
|
||||
>
|
||||
{chatContent}
|
||||
<ChatPanelInner>{chatContent}</ChatPanelInner>
|
||||
</ChatPanel>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -49,9 +49,7 @@ export function useLayoutTransition(
|
||||
);
|
||||
const [transitionState, setTransitionState] =
|
||||
useState<TransitionState>("idle");
|
||||
const [isCanvasVisible, setIsCanvasVisible] = useState(
|
||||
mode === "chat-canvas",
|
||||
);
|
||||
const [isCanvasVisible, setIsCanvasVisible] = useState(mode !== "chat");
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevModeRef = useRef<LayoutMode>(mode);
|
||||
|
||||
@@ -69,7 +67,7 @@ export function useLayoutTransition(
|
||||
|
||||
clearTimeouts();
|
||||
|
||||
if (mode === "chat-canvas") {
|
||||
if (mode !== "chat") {
|
||||
// 进入画布模式
|
||||
setIsCanvasVisible(true);
|
||||
setTransitionState("entering");
|
||||
@@ -122,7 +120,8 @@ export function useLayoutTransition(
|
||||
// chat 区域 - 画布打开时提升右侧聊天区宽度
|
||||
return {
|
||||
transition: `width ${duration}ms ease-out`,
|
||||
width: mode === "chat-canvas" ? "46%" : "100%",
|
||||
width:
|
||||
mode === "chat-canvas" ? "35%" : mode === "canvas" ? "0%" : "100%",
|
||||
};
|
||||
},
|
||||
[transitionState, mergedConfig, mode],
|
||||
|
||||
@@ -41,7 +41,7 @@ export type CreationMode = "guided" | "fast" | "hybrid" | "framework";
|
||||
/**
|
||||
* 布局模式
|
||||
*/
|
||||
export type LayoutMode = "chat" | "chat-canvas";
|
||||
export type LayoutMode = "chat" | "chat-canvas" | "canvas";
|
||||
|
||||
/**
|
||||
* 步骤类型
|
||||
|
||||
@@ -230,6 +230,407 @@ function getFormInstructions(): string {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成小说引导模式(教练模式)的系统提示词
|
||||
* 小说模式使用独立的章节创作引导,避免沿用社媒文章语义
|
||||
*/
|
||||
function generateNovelGuidedModePrompt(
|
||||
themeName: string,
|
||||
themeGuidance: string,
|
||||
): string {
|
||||
return `# 🛑 强制规则 - 必须遵守
|
||||
|
||||
**无论用户说什么,你的第一条回复必须且只能是下面的小说需求收集表单。**
|
||||
|
||||
不要:
|
||||
- ❌ 直接生成章节正文
|
||||
- ❌ 跳过世界观与角色设定
|
||||
- ❌ 使用“文章写作”话术(如段落、论点、读者收获)
|
||||
|
||||
必须:
|
||||
- ✅ 先收集小说创作要素
|
||||
- ✅ 每一步都等待用户确认
|
||||
- ✅ 全程使用“故事、角色、冲突、章节、场景”语义
|
||||
|
||||
---
|
||||
|
||||
你是一位专业的小说创作教练,当前帮助用户进行「${themeName}」创作。
|
||||
|
||||
## 你的角色:小说教练(章节共创)
|
||||
|
||||
你的职责是帮助用户搭建故事骨架并引导其完成章节,而不是代替用户一次性写完全部剧情。
|
||||
|
||||
### 可以做
|
||||
- ✅ 拆解章节目标、场景节拍和冲突推进
|
||||
- ✅ 引导用户补充角色动机、关系张力、细节动作
|
||||
- ✅ 对用户写出的片段给出节奏和叙事建议
|
||||
|
||||
### 绝对不能做
|
||||
- ❌ 把小说任务当成社媒文章任务
|
||||
- ❌ 输出“文章大纲/论点结构/平台适配”类建议
|
||||
- ❌ 在未确认设定前直接展开完整章节正文
|
||||
|
||||
${getFileWritingInstructions("novel")}
|
||||
|
||||
${getFormInstructions()}
|
||||
|
||||
## 🔄 工作流程(严格按顺序执行)
|
||||
|
||||
### 步骤 1️⃣:收集小说基础设定(必须首先执行)
|
||||
|
||||
**你的第一条回复必须是这个表单,无论用户说什么:**
|
||||
|
||||
\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "📚 小说创作引导 - 设定收集",
|
||||
"description": "先确定故事核心,再进入章节创作。",
|
||||
"fields": [
|
||||
{
|
||||
"id": "storyTheme",
|
||||
"type": "text",
|
||||
"label": "故事主题",
|
||||
"placeholder": "例如:记忆交易、末日求生、校园悬疑",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "genre",
|
||||
"type": "choice",
|
||||
"label": "题材类型",
|
||||
"options": [
|
||||
{"value": "fantasy", "label": "奇幻"},
|
||||
{"value": "scifi", "label": "科幻"},
|
||||
{"value": "suspense", "label": "悬疑"},
|
||||
{"value": "romance", "label": "言情"},
|
||||
{"value": "historical", "label": "历史"},
|
||||
{"value": "other", "label": "其他"}
|
||||
],
|
||||
"default": "fantasy"
|
||||
},
|
||||
{
|
||||
"id": "worldSetting",
|
||||
"type": "text",
|
||||
"label": "世界观/时代背景",
|
||||
"placeholder": "描述时代、地点、规则或特殊设定",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "protagonist",
|
||||
"type": "text",
|
||||
"label": "主角设定",
|
||||
"placeholder": "姓名、身份、核心动机、弱点",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "chapterGoal",
|
||||
"type": "text",
|
||||
"label": "本章目标",
|
||||
"placeholder": "这一章要推动什么关键变化?",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "tone",
|
||||
"type": "choice",
|
||||
"label": "叙事文风",
|
||||
"options": [
|
||||
{"value": "cinematic", "label": "电影感"},
|
||||
{"value": "lyrical", "label": "细腻抒情"},
|
||||
{"value": "compact", "label": "紧凑硬朗"},
|
||||
{"value": "light", "label": "轻松日常"}
|
||||
],
|
||||
"default": "compact"
|
||||
},
|
||||
{
|
||||
"id": "chapterLength",
|
||||
"type": "choice",
|
||||
"label": "本章字数",
|
||||
"options": [
|
||||
{"value": "1500", "label": "约1500字"},
|
||||
{"value": "2000", "label": "约2000字"},
|
||||
{"value": "3000", "label": "约3000字"},
|
||||
{"value": "4000", "label": "约4000字"}
|
||||
],
|
||||
"default": "2000"
|
||||
}
|
||||
],
|
||||
"submitLabel": "开始构建章节 →"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**🛑 停止并等待用户填写表单**
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2️⃣:收集冲突与场景节拍(表单提交后执行)
|
||||
|
||||
\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "🎭 情节推进信息",
|
||||
"description": "请补充本章冲突与关键场景。",
|
||||
"fields": [
|
||||
{
|
||||
"id": "coreConflict",
|
||||
"type": "text",
|
||||
"label": "核心冲突",
|
||||
"placeholder": "本章主角面临的冲突是什么?"
|
||||
},
|
||||
{
|
||||
"id": "keyScene",
|
||||
"type": "text",
|
||||
"label": "关键场景",
|
||||
"placeholder": "至少描述一个关键场景(时间/地点/人物/动作)"
|
||||
},
|
||||
{
|
||||
"id": "supportingRoles",
|
||||
"type": "text",
|
||||
"label": "配角与关系变化",
|
||||
"placeholder": "谁会出场?与主角关系如何变化?"
|
||||
},
|
||||
{
|
||||
"id": "turningPoint",
|
||||
"type": "text",
|
||||
"label": "转折点",
|
||||
"placeholder": "本章中段或后段要出现什么反转?"
|
||||
},
|
||||
{
|
||||
"id": "endingHook",
|
||||
"type": "text",
|
||||
"label": "章末钩子",
|
||||
"placeholder": "章节结尾留什么悬念,驱动下一章?"
|
||||
}
|
||||
],
|
||||
"submitLabel": "生成章节蓝图 →"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**🛑 停止并等待用户填写表单**
|
||||
|
||||
---
|
||||
|
||||
### 步骤 3️⃣:生成设定与蓝图文件(用户回答后执行)
|
||||
|
||||
先写入小说 brief:
|
||||
|
||||
<write_file path="brief.md">
|
||||
# 小说创作 Brief
|
||||
|
||||
## 项目元信息
|
||||
- **项目类型**: ${themeName}
|
||||
- **创作模式**: 引导模式
|
||||
|
||||
## 核心设定
|
||||
- **故事主题**: [用户填写]
|
||||
- **题材类型**: [用户填写]
|
||||
- **世界观/背景**: [用户填写]
|
||||
- **主角设定**: [用户填写]
|
||||
|
||||
## 本章目标
|
||||
- **章节目标**: [用户填写]
|
||||
- **核心冲突**: [用户填写]
|
||||
- **转折点**: [用户填写]
|
||||
- **章末钩子**: [用户填写]
|
||||
</write_file>
|
||||
|
||||
再写入章节蓝图:
|
||||
|
||||
<write_file path="outline.md">
|
||||
# 章节蓝图
|
||||
|
||||
## 场景节拍
|
||||
1. 开场场景:[场景1目标]
|
||||
2. 冲突升级:[场景2目标]
|
||||
3. 关键转折:[场景3目标]
|
||||
4. 收束与钩子:[场景4目标]
|
||||
|
||||
## 角色推进
|
||||
- 主角变化:[本章结束时主角状态变化]
|
||||
- 关系变化:[人物关系变化]
|
||||
</write_file>
|
||||
|
||||
---
|
||||
|
||||
### 步骤 4️⃣:逐场景引导创作
|
||||
|
||||
对每个场景使用引导表单,帮助用户写出正文片段,并逐段保存到 chapter.md。
|
||||
|
||||
每轮引导至少包含:
|
||||
1. 场景目标(发生什么)
|
||||
2. 人物动机(为什么做)
|
||||
3. 感官细节(看到/听到/触到)
|
||||
4. 对话张力(冲突如何体现)
|
||||
|
||||
---
|
||||
|
||||
### 如果用户要求“你直接写完整章”
|
||||
|
||||
请明确提示:
|
||||
> 当前是引导模式,我会优先用提问和场景拆解帮助你共创。
|
||||
> 如果你希望我直接生成完整章节,请切换到「快速模式」。
|
||||
|
||||
${themeGuidance}
|
||||
|
||||
---
|
||||
|
||||
## 🚀 立即开始
|
||||
|
||||
你现在进入了**小说引导创作模式**。
|
||||
|
||||
**请立即返回步骤 1 的小说需求收集表单。**
|
||||
|
||||
记住:第一条回复必须是小说设定表单。`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成小说快速模式的系统提示词
|
||||
* 小说快速模式收集设定后直接生成章节内容
|
||||
*/
|
||||
function generateNovelFastModePrompt(
|
||||
themeName: string,
|
||||
themeGuidance: string,
|
||||
): string {
|
||||
return `# 🛑 强制规则 - 必须遵守
|
||||
|
||||
**无论用户说什么,你的第一条回复必须且只能是下面的小说需求收集表单。**
|
||||
|
||||
不要:
|
||||
- ❌ 跳过设定收集直接写章节
|
||||
- ❌ 输出社媒文章式结构(导语/观点/结语)
|
||||
|
||||
必须:
|
||||
- ✅ 收集故事设定后再生成
|
||||
- ✅ 输出小说章节,而非文章
|
||||
- ✅ 使用 <write_file> 标签写入小说文件
|
||||
|
||||
---
|
||||
|
||||
你是一位专业的小说创作助手,当前帮助用户进行「${themeName}」创作。
|
||||
|
||||
快速模式下,你负责基于设定直接生成完整章节草稿。
|
||||
|
||||
${getFileWritingInstructions("novel")}
|
||||
|
||||
${getFormInstructions()}
|
||||
|
||||
## 🔄 工作流程(严格按顺序执行)
|
||||
|
||||
### 步骤 1️⃣:收集小说设定(必须首先执行)
|
||||
|
||||
**你的第一条回复必须是这个表单,无论用户说什么:**
|
||||
|
||||
\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "⚡ 小说快速创作 - 设定收集",
|
||||
"description": "填写以下信息,我将直接生成本章初稿。",
|
||||
"fields": [
|
||||
{
|
||||
"id": "storyTheme",
|
||||
"type": "text",
|
||||
"label": "故事主题",
|
||||
"placeholder": "例如:时间循环中的追凶",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "genre",
|
||||
"type": "choice",
|
||||
"label": "题材类型",
|
||||
"options": [
|
||||
{"value": "fantasy", "label": "奇幻"},
|
||||
{"value": "scifi", "label": "科幻"},
|
||||
{"value": "suspense", "label": "悬疑"},
|
||||
{"value": "romance", "label": "言情"},
|
||||
{"value": "historical", "label": "历史"},
|
||||
{"value": "other", "label": "其他"}
|
||||
],
|
||||
"default": "suspense"
|
||||
},
|
||||
{
|
||||
"id": "worldSetting",
|
||||
"type": "text",
|
||||
"label": "世界观/背景",
|
||||
"placeholder": "时间、地点、秩序规则、禁忌设定",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "protagonist",
|
||||
"type": "text",
|
||||
"label": "主角设定",
|
||||
"placeholder": "身份、欲望、恐惧、能力边界",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "conflict",
|
||||
"type": "text",
|
||||
"label": "本章冲突",
|
||||
"placeholder": "主角这章要解决或面对的冲突",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "endingHook",
|
||||
"type": "text",
|
||||
"label": "章末悬念(可选)",
|
||||
"placeholder": "希望在结尾留下什么钩子?"
|
||||
},
|
||||
{
|
||||
"id": "chapterLength",
|
||||
"type": "choice",
|
||||
"label": "本章字数",
|
||||
"options": [
|
||||
{"value": "1500", "label": "约1500字"},
|
||||
{"value": "2000", "label": "约2000字"},
|
||||
{"value": "3000", "label": "约3000字"},
|
||||
{"value": "4000", "label": "约4000字"}
|
||||
],
|
||||
"default": "2000"
|
||||
}
|
||||
],
|
||||
"submitLabel": "开始生成章节 →"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**🛑 停止并等待用户填写表单**
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2️⃣:写入章节草稿(表单提交后执行)
|
||||
|
||||
收到表单后,按“开场 → 冲突升级 → 转折 → 章末钩子”结构生成并写入:
|
||||
|
||||
<write_file path="chapter.md">
|
||||
# 第X章 [章节标题]
|
||||
|
||||
[章节正文……]
|
||||
</write_file>
|
||||
|
||||
如用户要求终稿,再生成:
|
||||
|
||||
<write_file path="chapter-final.md">
|
||||
[润色后的章节正文……]
|
||||
</write_file>
|
||||
|
||||
---
|
||||
|
||||
### 生成原则
|
||||
|
||||
1. 角色行为必须符合其动机与能力边界
|
||||
2. 对话必须推进冲突或揭示信息
|
||||
3. 场景描写服务剧情,不堆砌辞藻
|
||||
4. 结尾保留下一章驱动力
|
||||
|
||||
${themeGuidance}
|
||||
|
||||
---
|
||||
|
||||
## 🚀 立即开始
|
||||
|
||||
你现在进入了**小说快速创作模式**。
|
||||
|
||||
**请立即返回步骤 1 的小说需求收集表单。**
|
||||
|
||||
记住:第一条回复必须是小说设定表单。`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成引导模式(教练模式)的系统提示词
|
||||
* 借鉴 aster /plan 模式:先问问题收集信息,等待用户确认后再进入下一步
|
||||
@@ -239,6 +640,10 @@ function generateGuidedModePrompt(
|
||||
themeGuidance: string,
|
||||
theme?: ThemeType,
|
||||
): string {
|
||||
if (theme === "novel") {
|
||||
return generateNovelGuidedModePrompt(themeName, themeGuidance);
|
||||
}
|
||||
|
||||
return `# 🛑 强制规则 - 必须遵守
|
||||
|
||||
**无论用户说什么,你的第一条回复必须且只能是下面的需求收集表单。**
|
||||
@@ -589,6 +994,10 @@ function generateFastModePrompt(
|
||||
themeGuidance: string,
|
||||
theme?: ThemeType,
|
||||
): string {
|
||||
if (theme === "novel") {
|
||||
return generateNovelFastModePrompt(themeName, themeGuidance);
|
||||
}
|
||||
|
||||
return `# 🛑 强制规则 - 必须遵守
|
||||
|
||||
**无论用户说什么,你的第一条回复必须且只能是下面的需求收集表单。**
|
||||
|
||||
@@ -6,17 +6,18 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Home,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -29,6 +30,12 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type ContentListItem,
|
||||
@@ -58,6 +65,7 @@ import type {
|
||||
} from "@/types/page";
|
||||
import { toast } from "sonner";
|
||||
import { AgentChatPage } from "@/components/agent";
|
||||
import type { WorkflowProgressSnapshot } from "@/components/agent/chat";
|
||||
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
|
||||
import { ProjectDetailPage } from "@/components/projects/ProjectDetailPage";
|
||||
import type { CreationMode } from "@/components/content-creator/types";
|
||||
@@ -114,6 +122,23 @@ function parseCreationMode(value: unknown): CreationMode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getWorkflowStepStatusLabel(
|
||||
status: WorkflowProgressSnapshot["steps"][number]["status"],
|
||||
): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "进行中";
|
||||
case "completed":
|
||||
return "已完成";
|
||||
case "skipped":
|
||||
return "已跳过";
|
||||
case "error":
|
||||
return "异常";
|
||||
default:
|
||||
return "待开始";
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkbenchPage({
|
||||
onNavigate,
|
||||
projectId: initialProjectId,
|
||||
@@ -122,8 +147,15 @@ export function WorkbenchPage({
|
||||
viewMode: initialViewMode,
|
||||
resetAt,
|
||||
}: WorkbenchPageProps) {
|
||||
const [showLeftSidebar, setShowLeftSidebar] = useState(true);
|
||||
const [showRightSidebar, setShowRightSidebar] = useState(false);
|
||||
const { leftSidebarCollapsed, toggleLeftSidebar, setLeftSidebarCollapsed } =
|
||||
useWorkbenchStore();
|
||||
const [activeRightDrawer, setActiveRightDrawer] = useState<"tools" | null>(
|
||||
null,
|
||||
);
|
||||
const [showChatPanel, setShowChatPanel] = useState(true);
|
||||
const [workflowProgress, setWorkflowProgress] =
|
||||
useState<WorkflowProgressSnapshot | null>(null);
|
||||
const [showWorkflowRail, setShowWorkflowRail] = useState(false);
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(
|
||||
initialViewMode ?? (initialContentId ? "workspace" : "project-management"),
|
||||
);
|
||||
@@ -187,10 +219,16 @@ export function WorkbenchPage({
|
||||
);
|
||||
}, [contents, contentQuery]);
|
||||
|
||||
const handleEnterWorkspace = useCallback((contentId: string) => {
|
||||
setSelectedContentId(contentId);
|
||||
setWorkspaceMode("workspace");
|
||||
}, []);
|
||||
const handleEnterWorkspace = useCallback(
|
||||
(contentId: string) => {
|
||||
setSelectedContentId(contentId);
|
||||
setWorkspaceMode("workspace");
|
||||
setShowChatPanel(false);
|
||||
setActiveRightDrawer(null);
|
||||
setLeftSidebarCollapsed(true);
|
||||
},
|
||||
[setLeftSidebarCollapsed],
|
||||
);
|
||||
|
||||
const handleOpenProjectDetail = useCallback(() => {
|
||||
if (!selectedProjectId) {
|
||||
@@ -198,8 +236,7 @@ export function WorkbenchPage({
|
||||
}
|
||||
|
||||
setWorkspaceMode("project-detail");
|
||||
setShowLeftSidebar(true);
|
||||
setShowRightSidebar(false);
|
||||
setActiveRightDrawer(null);
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
@@ -388,8 +425,12 @@ export function WorkbenchPage({
|
||||
setSelectedProjectId(initialProjectId ?? null);
|
||||
setSelectedContentId(initialContentId ?? null);
|
||||
setWorkspaceMode(nextMode);
|
||||
setShowLeftSidebar(true);
|
||||
setShowRightSidebar(false);
|
||||
const isWorkspaceMode = nextMode === "workspace";
|
||||
setShowChatPanel(!isWorkspaceMode);
|
||||
if (isWorkspaceMode) {
|
||||
setLeftSidebarCollapsed(true);
|
||||
}
|
||||
setActiveRightDrawer(null);
|
||||
setContents([]);
|
||||
void loadProjects();
|
||||
}, [
|
||||
@@ -398,6 +439,7 @@ export function WorkbenchPage({
|
||||
initialViewMode,
|
||||
loadProjects,
|
||||
resetAt,
|
||||
setLeftSidebarCollapsed,
|
||||
theme,
|
||||
]);
|
||||
|
||||
@@ -512,7 +554,7 @@ export function WorkbenchPage({
|
||||
}
|
||||
|
||||
void loadContents(selectedProjectId);
|
||||
}, [loadContents, selectedProjectId]);
|
||||
}, [loadContents, selectedProjectId, projects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedContentId || contentCreationModes[selectedContentId]) {
|
||||
@@ -555,235 +597,311 @@ export function WorkbenchPage({
|
||||
|
||||
const handleBackToProjectManagement = useCallback(() => {
|
||||
setWorkspaceMode("project-management");
|
||||
setShowLeftSidebar(true);
|
||||
setShowRightSidebar(false);
|
||||
setShowChatPanel(true);
|
||||
setActiveRightDrawer(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceMode !== "workspace") {
|
||||
setWorkflowProgress(null);
|
||||
setShowWorkflowRail(false);
|
||||
}
|
||||
}, [workspaceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowProgress || workflowProgress.steps.length === 0) {
|
||||
setShowWorkflowRail(false);
|
||||
}
|
||||
}, [workflowProgress]);
|
||||
|
||||
// 键盘快捷键: Cmd/Ctrl + B 切换左侧栏
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
|
||||
event.preventDefault();
|
||||
toggleLeftSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [toggleLeftSidebar]);
|
||||
|
||||
const shouldRenderLeftSidebar =
|
||||
workspaceMode !== "workspace" || showLeftSidebar;
|
||||
workspaceMode !== "workspace" || !leftSidebarCollapsed;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<header className="h-12 border-b px-3 flex items-center gap-2 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleBackHome}
|
||||
title="回到首页"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{workspaceMode === "workspace" && (
|
||||
{workspaceMode !== "workspace" && (
|
||||
<header className="h-12 border-b px-3 flex items-center gap-2 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowLeftSidebar((visible) => !visible)}
|
||||
title={showLeftSidebar ? "隐藏左侧栏" : "显示左侧栏"}
|
||||
onClick={handleBackHome}
|
||||
title="回到首页"
|
||||
>
|
||||
{showLeftSidebar ? (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
)}
|
||||
<Home className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{workspaceMode !== "project-management" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleBackToProjectManagement}
|
||||
>
|
||||
项目管理
|
||||
</Button>
|
||||
)}
|
||||
{workspaceMode !== "project-management" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleBackToProjectManagement}
|
||||
>
|
||||
{getProjectTypeLabel(theme)}项目管理
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-sm font-medium ml-2">
|
||||
{getProjectTypeLabel(theme)}
|
||||
</div>
|
||||
{selectedProject && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{selectedProject.name}
|
||||
<div className="text-sm font-medium ml-2">
|
||||
{getProjectTypeLabel(theme)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceMode === "workspace" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 ml-auto"
|
||||
onClick={() => setShowRightSidebar((visible) => !visible)}
|
||||
title={showRightSidebar ? "隐藏右侧栏" : "显示右侧栏"}
|
||||
>
|
||||
{showRightSidebar ? (
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
{selectedProject && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{selectedProject.name}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{shouldRenderLeftSidebar && (
|
||||
<aside className="w-[260px] min-w-[240px] border-r bg-muted/20 flex flex-col">
|
||||
<div className="px-3 py-3 border-b space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{getProjectTypeLabel(theme)}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">主题项目管理</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
void loadProjects();
|
||||
}}
|
||||
disabled={projectsLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
projectsLoading && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleOpenCreateProjectDialog}
|
||||
title="新建项目"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<aside
|
||||
className={cn(
|
||||
"border-r bg-muted/20 flex flex-col transition-all duration-300 ease-out",
|
||||
leftSidebarCollapsed ? "w-16" : "w-[260px] min-w-[240px]",
|
||||
)}
|
||||
>
|
||||
{leftSidebarCollapsed ? (
|
||||
// 图标模式 (折叠状态)
|
||||
<div className="flex flex-col items-center py-3 gap-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={toggleLeftSidebar}
|
||||
>
|
||||
<PanelLeftOpen className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>展开侧边栏 (⌘B)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Input
|
||||
value={projectQuery}
|
||||
onChange={(event) => setProjectQuery(event.target.value)}
|
||||
placeholder="搜索项目..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full border-t" />
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="min-h-0 basis-1/2 border-b flex flex-col">
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
项目
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={toggleLeftSidebar}
|
||||
>
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>项目列表</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={toggleLeftSidebar}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>文稿列表</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
该主题下暂无项目
|
||||
) : (
|
||||
// 完整模式 (展开状态)
|
||||
<>
|
||||
<div className="px-3 py-3 border-b space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold truncate">
|
||||
{getProjectTypeLabel(theme)}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
主题项目管理
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2 py-2 transition-colors",
|
||||
"hover:bg-accent",
|
||||
selectedProjectId === project.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={toggleLeftSidebar}
|
||||
title="折叠侧边栏 (⌘B)"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
setSelectedProjectId(project.id);
|
||||
setContentQuery("");
|
||||
void loadProjects();
|
||||
}}
|
||||
disabled={projectsLoading}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground truncate">
|
||||
{getProjectTypeLabel(project.workspaceType)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 basis-1/2 flex flex-col">
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<div className="text-xs text-muted-foreground flex-1">
|
||||
文稿
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleOpenCreateContentDialog}
|
||||
disabled={!selectedProjectId}
|
||||
title="新建文稿"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-2">
|
||||
<Input
|
||||
value={contentQuery}
|
||||
onChange={(event) => setContentQuery(event.target.value)}
|
||||
placeholder="搜索文稿..."
|
||||
className="h-8 text-xs"
|
||||
disabled={!selectedProjectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{contentsLoading ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
文稿加载中...
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
projectsLoading && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleOpenCreateProjectDialog}
|
||||
title="新建项目"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredContents.length === 0 ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
还没有文稿
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={projectQuery}
|
||||
onChange={(event) => setProjectQuery(event.target.value)}
|
||||
placeholder="搜索项目..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="min-h-0 basis-1/2 border-b flex flex-col">
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
项目
|
||||
</div>
|
||||
) : (
|
||||
filteredContents.map((content) => (
|
||||
<button
|
||||
key={content.id}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2 py-2 transition-colors",
|
||||
"hover:bg-accent",
|
||||
selectedContentId === content.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
该主题下暂无项目
|
||||
</div>
|
||||
) : (
|
||||
filteredProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2 py-2 transition-colors",
|
||||
"hover:bg-accent",
|
||||
selectedProjectId === project.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedProjectId(project.id);
|
||||
setContentQuery("");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground truncate">
|
||||
{getProjectTypeLabel(project.workspaceType)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
onClick={() => handleEnterWorkspace(content.id)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 basis-1/2 flex flex-col">
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<div className="text-xs text-muted-foreground flex-1">
|
||||
文稿
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleOpenCreateContentDialog}
|
||||
disabled={!selectedProjectId}
|
||||
title="新建文稿"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{content.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground truncate">
|
||||
{formatRelativeTime(content.updated_at)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-2">
|
||||
<Input
|
||||
value={contentQuery}
|
||||
onChange={(event) =>
|
||||
setContentQuery(event.target.value)
|
||||
}
|
||||
placeholder="搜索文稿..."
|
||||
className="h-8 text-xs"
|
||||
disabled={!selectedProjectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{contentsLoading ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
文稿加载中...
|
||||
</div>
|
||||
) : filteredContents.length === 0 ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
还没有文稿
|
||||
</div>
|
||||
) : (
|
||||
filteredContents.map((content) => (
|
||||
<button
|
||||
key={content.id}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2 py-2 transition-colors",
|
||||
"hover:bg-accent",
|
||||
selectedContentId === content.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => handleEnterWorkspace(content.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{content.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground truncate">
|
||||
{formatRelativeTime(content.updated_at)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<main className="flex-1 min-w-0 min-h-0 flex flex-col">
|
||||
@@ -832,6 +950,9 @@ export function WorkbenchPage({
|
||||
onBack={handleBackToProjectManagement}
|
||||
onNavigateToChat={() => {
|
||||
setWorkspaceMode("workspace");
|
||||
setShowChatPanel(false);
|
||||
setActiveRightDrawer(null);
|
||||
setLeftSidebarCollapsed(true);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -872,12 +993,16 @@ export function WorkbenchPage({
|
||||
}
|
||||
lockTheme={true}
|
||||
hideHistoryToggle={true}
|
||||
showChatPanel={showChatPanel}
|
||||
onBackToProjectManagement={handleBackToProjectManagement}
|
||||
hideInlineStepProgress={true}
|
||||
onWorkflowProgressChange={setWorkflowProgress}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{workspaceMode === "workspace" && showRightSidebar && (
|
||||
{workspaceMode === "workspace" && activeRightDrawer === "tools" && (
|
||||
<aside className="w-[260px] min-w-[260px] border-l bg-muted/10 p-4 flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold">主题工具</h3>
|
||||
<Button
|
||||
@@ -902,6 +1027,136 @@ export function WorkbenchPage({
|
||||
</Button>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{workspaceMode === "workspace" && (
|
||||
<aside className="w-14 min-w-14 border-l bg-background/95 flex flex-col items-center py-3 gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
showChatPanel && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => setShowChatPanel((visible) => !visible)}
|
||||
title={showChatPanel ? "隐藏 AI 对话" : "显示 AI 对话"}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{showChatPanel ? "隐藏 AI 对话" : "显示 AI 对话"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
activeRightDrawer === "tools" &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
setActiveRightDrawer((previous) =>
|
||||
previous === "tools" ? null : "tools",
|
||||
)
|
||||
}
|
||||
title={
|
||||
activeRightDrawer === "tools"
|
||||
? "收起主题工具"
|
||||
: "展开主题工具"
|
||||
}
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>
|
||||
{activeRightDrawer === "tools"
|
||||
? "收起主题工具"
|
||||
: "展开主题工具"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{workflowProgress && workflowProgress.steps.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
showWorkflowRail && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
setShowWorkflowRail((previous) => !previous)
|
||||
}
|
||||
title={showWorkflowRail ? "收起流程步骤" : "展开流程步骤"}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{showWorkflowRail ? "收起流程步骤" : "展开流程步骤"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{workflowProgress &&
|
||||
workflowProgress.steps.length > 0 &&
|
||||
showWorkflowRail && (
|
||||
<div className="overflow-hidden max-h-80 opacity-100 pointer-events-auto transition-all duration-200">
|
||||
<div className="w-8 border-t my-1" />
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{workflowProgress.steps.map((step, index) => {
|
||||
const isCurrent =
|
||||
index === workflowProgress.currentIndex;
|
||||
const isCompleted =
|
||||
step.status === "completed" ||
|
||||
step.status === "skipped";
|
||||
return (
|
||||
<Tooltip key={step.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full border text-[11px] font-medium transition-colors",
|
||||
isCurrent &&
|
||||
"border-primary bg-primary/10 text-primary",
|
||||
!isCurrent &&
|
||||
isCompleted &&
|
||||
"border-primary/40 bg-primary/5 text-primary",
|
||||
!isCurrent &&
|
||||
!isCompleted &&
|
||||
"border-border bg-muted/40 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isCompleted ? "✓" : index + 1}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{step.title}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{isCurrent
|
||||
? "当前步骤"
|
||||
: getWorkflowStepStatusLabel(step.status)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+23
-4
@@ -823,6 +823,23 @@ export async function confirmAsterAction(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 Aster Agent elicitation 响应
|
||||
*/
|
||||
export async function submitAsterElicitationResponse(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
userData: unknown,
|
||||
): Promise<void> {
|
||||
return await safeInvoke("aster_agent_submit_elicitation_response", {
|
||||
sessionId,
|
||||
request: {
|
||||
request_id: requestId,
|
||||
user_data: userData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Terminal Tool API (终端命令执行)
|
||||
// ============================================================
|
||||
@@ -961,9 +978,11 @@ export interface PermissionResponse {
|
||||
export async function sendPermissionResponse(
|
||||
response: PermissionResponse,
|
||||
): Promise<void> {
|
||||
return await safeInvoke("agent_permission_response", {
|
||||
requestId: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
return await safeInvoke("aster_agent_confirm", {
|
||||
request: {
|
||||
request_id: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ const defaultMocks: Record<string, any> = {
|
||||
aster_session_list: () => [],
|
||||
aster_session_get: () => ({ id: "mock", messages: [] }),
|
||||
aster_agent_confirm: () => ({}),
|
||||
aster_agent_submit_elicitation_response: () => ({}),
|
||||
|
||||
// 终端相关
|
||||
create_terminal_session: () => ({ uuid: "mock-terminal-uuid" }),
|
||||
|
||||
+72
-10
@@ -74,10 +74,15 @@ export interface SessionInfo {
|
||||
/** 权限确认请求 */
|
||||
export interface ActionRequired {
|
||||
requestId: string;
|
||||
actionType: "tool_confirmation" | "ask_user" | "permission_request";
|
||||
actionType:
|
||||
| "tool_confirmation"
|
||||
| "ask_user"
|
||||
| "elicitation"
|
||||
| "permission_request";
|
||||
toolName?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
question?: string;
|
||||
prompt?: string;
|
||||
requestedSchema?: Record<string, unknown>;
|
||||
options?: Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -90,6 +95,8 @@ export interface ConfirmResponse {
|
||||
requestId: string;
|
||||
confirmed: boolean;
|
||||
response?: string;
|
||||
actionType?: ActionRequired["actionType"];
|
||||
userData?: unknown;
|
||||
}
|
||||
|
||||
// ============ Tauri 事件类型 ============
|
||||
@@ -302,13 +309,53 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
// 确认权限请求
|
||||
confirmAction: async (response: ConfirmResponse) => {
|
||||
try {
|
||||
await invoke("aster_agent_confirm", {
|
||||
request: {
|
||||
request_id: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
},
|
||||
});
|
||||
const state = get();
|
||||
const actionType =
|
||||
response.actionType ||
|
||||
state.pendingActions.find((a) => a.requestId === response.requestId)
|
||||
?.actionType;
|
||||
|
||||
if (actionType === "elicitation" || actionType === "ask_user") {
|
||||
if (!state.currentSessionId) {
|
||||
throw new Error("缺少会话 ID,无法提交 elicitation 响应");
|
||||
}
|
||||
|
||||
let userData: unknown;
|
||||
if (!response.confirmed) {
|
||||
userData = "";
|
||||
} else if (response.userData !== undefined) {
|
||||
userData = response.userData;
|
||||
} else if (response.response !== undefined) {
|
||||
const rawResponse = response.response.trim();
|
||||
if (!rawResponse) {
|
||||
userData = "";
|
||||
} else {
|
||||
try {
|
||||
userData = JSON.parse(rawResponse);
|
||||
} catch {
|
||||
userData = rawResponse;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userData = "";
|
||||
}
|
||||
|
||||
await invoke("aster_agent_submit_elicitation_response", {
|
||||
sessionId: state.currentSessionId,
|
||||
request: {
|
||||
request_id: response.requestId,
|
||||
user_data: userData,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await invoke("aster_agent_confirm", {
|
||||
request: {
|
||||
request_id: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 移除已处理的请求
|
||||
set((s) => ({
|
||||
@@ -580,7 +627,22 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
{
|
||||
requestId: event.request_id,
|
||||
actionType: event.action_type as ActionRequired["actionType"],
|
||||
...event.data,
|
||||
toolName: event.data.tool_name as string | undefined,
|
||||
arguments: event.data.arguments as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
prompt:
|
||||
(event.data.prompt as string | undefined) ||
|
||||
(event.data.message as string | undefined),
|
||||
requestedSchema: event.data.requested_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
options: event.data.options as
|
||||
| Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
}>
|
||||
| undefined,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file useWorkbenchStore.ts
|
||||
* @description Workbench 页面的 Zustand 状态管理 Store
|
||||
* @module stores/useWorkbenchStore
|
||||
*
|
||||
* 管理 Workbench 页面的 UI 状态,包括侧边栏折叠状态
|
||||
* 使用 persist 中间件持久化到 localStorage
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
|
||||
/**
|
||||
* Workbench Store 状态接口
|
||||
*/
|
||||
export interface WorkbenchState {
|
||||
/** 左侧栏是否折叠 */
|
||||
leftSidebarCollapsed: boolean;
|
||||
|
||||
/** 切换左侧栏折叠状态 */
|
||||
toggleLeftSidebar: () => void;
|
||||
|
||||
/** 设置左侧栏折叠状态 */
|
||||
setLeftSidebarCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始状态
|
||||
*/
|
||||
const initialState = {
|
||||
leftSidebarCollapsed: true, // 默认折叠,给画布更多空间
|
||||
};
|
||||
|
||||
/**
|
||||
* Workbench Zustand Store
|
||||
*
|
||||
* 使用 persist 中间件持久化 UI 状态到 localStorage
|
||||
*/
|
||||
export const useWorkbenchStore = create<WorkbenchState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
toggleLeftSidebar: () => {
|
||||
set((state) => ({
|
||||
leftSidebarCollapsed: !state.leftSidebarCollapsed,
|
||||
}));
|
||||
},
|
||||
|
||||
setLeftSidebarCollapsed: (collapsed: boolean) => {
|
||||
set({ leftSidebarCollapsed: collapsed });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "workbench-storage",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user