release: bump version to 0.65.0

主要更新:
- 优化 WorkbenchPage 导航显示,项目管理按钮显示主题上下文
- 修复双重背景问题,移除冗余的背景色和边框
- 移除 NovelCanvas 中的 HeaderTitle 和横线
- 修复 MCP 相关测试的编译错误(添加 meta 字段)
- 修复 scheduler 测试的导入问题
- 代码格式化和 lint 修复
This commit is contained in:
coso
2026-02-13 16:15:50 +08:00
parent 8da33ec9a0
commit a737e63fb2
46 changed files with 3773 additions and 958 deletions
@@ -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(() => {
+367 -83
View File
@@ -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
}
/>
{/* 同步状态指示器 */}
+4
View File
@@ -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],
+1 -1
View File
@@ -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 `# 🛑 强制规则 - 必须遵守
**无论用户说什么,你的第一条回复必须且只能是下面的需求收集表单。**
+468 -213
View File
@@ -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
View File
@@ -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,
},
});
}
+1
View File
@@ -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
View File
@@ -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(),
},
],
+59
View File
@@ -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),
},
),
);