chore: bump version to 0.40.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2026-01-11 01:24:47 +08:00
co-authored by Claude Opus 4.5
parent c304512c57
commit 83c36747db
5 changed files with 726 additions and 42 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.39.0",
"version": "0.40.0",
"type": "module",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.39.0"
version = "0.40.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.39.0",
"version": "0.40.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -20,6 +20,7 @@
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"maximized": true,
"center": true
},
{
+475 -39
View File
@@ -2,9 +2,10 @@
* AI Agent 聊天页面
*
* 包含聊天区域和侧边栏(话题/技能列表)
* 支持内容创作模式下的布局过渡和步骤引导
*/
import React, { useState, useCallback } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import styled from "styled-components";
import { useAgentChat } from "./hooks/useAgentChat";
import { ChatNavbar } from "./components/ChatNavbar";
@@ -12,8 +13,33 @@ import { ChatSidebar } from "./components/ChatSidebar";
import { ChatSettings } from "./components/ChatSettings";
import { MessageList } from "./components/MessageList";
import { Inputbar } from "./components/Inputbar";
import { EmptyState } from "./components/EmptyState";
import { EmptyState, CreationMode } from "./components/EmptyState";
import { TaskFileList, type TaskFile } from "./components/TaskFiles";
import { LayoutTransition } from "@/components/content-creator/core/LayoutTransition/LayoutTransition";
import { StepProgress } from "@/components/content-creator/core/StepGuide/StepProgress";
import { useWorkflow } from "@/components/content-creator/hooks/useWorkflow";
import {
DocumentCanvas,
createInitialDocumentState,
} from "@/components/content-creator/canvas/document";
import {
generateContentCreationPrompt,
isContentCreationTheme,
} from "@/components/content-creator/utils/systemPrompt";
import type { DocumentCanvasState } from "@/components/content-creator/canvas/document/types";
import type { MessageImage } from "./types";
import type { ThemeType, LayoutMode } from "@/components/content-creator/types";
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
// 文件名到步骤索引的映射(静态常量,移到组件外部避免重复创建)
const FILE_TO_STEP_MAP: Record<string, number> = {
"brief.md": 0, // 明确需求
"specification.md": 1, // 调研收集
"research.md": 1, // 调研收集(备选文件名)
"outline.md": 2, // 生成大纲
"draft.md": 3, // 撰写内容
"article.md": 4, // 润色优化
};
const PageContainer = styled.div`
display: flex;
@@ -27,6 +53,8 @@ const MainArea = styled.div`
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
`;
const ChatContainer = styled.div`
@@ -34,6 +62,7 @@ const ChatContainer = styled.div`
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
`;
const ChatContent = styled.div`
@@ -42,13 +71,70 @@ const ChatContent = styled.div`
flex: 1;
min-height: 0;
padding: 0 16px;
overflow: hidden;
height: 100%;
`;
// 主题到 ThemeType 的映射
const THEME_MAP: Record<string, ThemeType> = {
general: "general",
knowledge: "knowledge",
planning: "planning",
social: "social-media",
image: "poster",
office: "document",
video: "video",
};
export function AgentChatPage({
onNavigate: _onNavigate,
}: {
onNavigate?: (page: string) => void;
}) {
const [showSidebar, setShowSidebar] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [input, setInput] = useState("");
// 内容创作相关状态
const [activeTheme, setActiveTheme] = useState<string>("general");
const [creationMode, setCreationMode] = useState<CreationMode>("guided");
const [layoutMode, setLayoutMode] = useState<LayoutMode>("chat");
// 文档画布状态
const [documentState, setDocumentState] =
useState<DocumentCanvasState | null>(null);
// 任务文件状态
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [taskFilesExpanded, setTaskFilesExpanded] = useState(false);
const [selectedFileId, setSelectedFileId] = useState<string | undefined>();
// 用于追踪已处理的消息 ID,避免重复处理
const processedMessageIds = useRef<Set<string>>(new Set());
// 文件写入回调 ref(用于传递给 useAgentChat)
const handleWriteFileRef =
useRef<(content: string, fileName: string) => void>();
// 工作流状态(仅在内容创作模式下使用)
const mappedTheme = THEME_MAP[activeTheme] || "general";
const { steps, currentStepIndex, goToStep, completeStep } = useWorkflow(
mappedTheme,
creationMode,
);
// 判断是否为内容创作模式
const isContentCreationMode = isContentCreationTheme(activeTheme);
// 生成系统提示词(仅在内容创作模式下)
const systemPrompt = useMemo(() => {
if (isContentCreationMode) {
return generateContentCreationPrompt(mappedTheme);
}
return undefined;
}, [isContentCreationMode, mappedTheme]);
// 使用 Agent Chat Hook(传递系统提示词)
const {
processStatus,
providerType,
@@ -58,18 +144,115 @@ export function AgentChatPage({
messages,
isSending,
sendMessage,
stopSending,
clearMessages,
deleteMessage,
editMessage,
topics,
sessionId,
switchTopic,
switchTopic: originalSwitchTopic,
deleteTopic,
} = useAgentChat();
} = useAgentChat({
systemPrompt,
onWriteFile: (content, fileName) => {
// 使用 ref 调用最新的 handleWriteFile
handleWriteFileRef.current?.(content, fileName);
},
});
const [showSidebar, setShowSidebar] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [input, setInput] = useState("");
// 包装 switchTopic,在切换话题时重置相关状态
const switchTopic = useCallback(
async (topicId: string) => {
// 先重置本地状态
setLayoutMode("chat");
setDocumentState(null);
setTaskFiles([]);
setSelectedFileId(undefined);
processedMessageIds.current.clear();
// 然后调用原始的 switchTopic
await originalSwitchTopic(topicId);
},
[originalSwitchTopic],
);
/**
* 从 AI 响应中提取文档内容
* 支持多种格式:
* 1. <document>...</document> 标签
* 2. ```markdown ... ``` 代码块
* 3. 以 # 开头的 Markdown 内容(整个响应)
*/
const extractDocumentContent = useCallback(
(content: string): string | null => {
// 1. 检查 <document> 标签
const documentMatch = content.match(/<document>([\s\S]*?)<\/document>/);
if (documentMatch) {
return documentMatch[1].trim();
}
// 2. 检查 markdown 代码块
const markdownMatch = content.match(/```(?:markdown|md)\n([\s\S]*?)```/);
if (markdownMatch) {
return markdownMatch[1].trim();
}
// 3. 如果整个内容以 # 开头且长度超过 200 字符,认为是文档
if (content.trim().startsWith("#") && content.length > 200) {
return content.trim();
}
return null;
},
[],
);
// 监听 AI 消息变化,自动提取文档内容
useEffect(() => {
if (!isContentCreationMode) return;
// 找到最新的 assistant 消息
const lastAssistantMsg = [...messages]
.reverse()
.find(
(msg) => msg.role === "assistant" && !msg.isThinking && msg.content,
);
if (!lastAssistantMsg) return;
// 检查是否已处理过
if (processedMessageIds.current.has(lastAssistantMsg.id)) return;
// 提取文档内容
const docContent = extractDocumentContent(lastAssistantMsg.content);
if (docContent) {
// 标记为已处理
processedMessageIds.current.add(lastAssistantMsg.id);
// 更新画布内容
setDocumentState((prev) => {
if (!prev) {
return createInitialDocumentState(docContent);
}
// 添加新版本
const newVersion = {
id: crypto.randomUUID(),
content: docContent,
createdAt: Date.now(),
description: `AI 生成 - 版本 ${prev.versions.length + 1}`,
};
return {
...prev,
content: docContent,
versions: [...prev.versions, newVersion],
currentVersionId: newVersion.id,
};
});
// 自动打开画布
setLayoutMode("chat-canvas");
}
}, [messages, isContentCreationMode, extractDocumentContent]);
const handleSend = useCallback(
async (
@@ -88,14 +271,293 @@ export function AgentChatPage({
const handleClearMessages = useCallback(() => {
clearMessages();
setInput("");
// 重置布局模式
setLayoutMode("chat");
}, [clearMessages]);
const handleToggleSidebar = () => {
setShowSidebar(!showSidebar);
};
// 切换画布显示
const handleToggleCanvas = useCallback(() => {
setLayoutMode((prev) => {
if (prev === "chat") {
// 打开画布时,如果没有文档状态则创建初始状态
if (!documentState) {
setDocumentState(
createInitialDocumentState("# 新文档\n\n在这里开始编写内容..."),
);
}
return "chat-canvas";
}
return "chat";
});
}, [documentState]);
// 关闭画布
const handleCloseCanvas = useCallback(() => {
setLayoutMode("chat");
}, []);
// 处理文件写入 - 同名文件更新内容,不同名文件独立保存
const handleWriteFile = useCallback(
(content: string, fileName: string) => {
console.log(
"[AgentChatPage] 收到文件写入:",
fileName,
content.length,
"字符",
);
const now = Date.now();
// 根据文件名推进工作流步骤
const stepIndex = FILE_TO_STEP_MAP[fileName];
if (
stepIndex !== undefined &&
stepIndex === currentStepIndex &&
isContentCreationMode
) {
console.log(
"[AgentChatPage] 推进工作流步骤:",
stepIndex,
"->",
stepIndex + 1,
);
completeStep({
aiOutput: { fileName, preview: content.slice(0, 100) },
});
}
// 更新或创建文件
setTaskFiles((prev) => {
// 查找同名文件
const existingIndex = prev.findIndex((f) => f.name === fileName);
if (existingIndex >= 0) {
// 同名文件存在 - 直接更新内容(不创建新版本)
const existing = prev[existingIndex];
// 如果内容完全相同,跳过
if (existing.content === content) {
console.log("[AgentChatPage] 文件内容相同,跳过:", fileName);
setSelectedFileId(existing.id);
return prev;
}
// 更新文件内容
console.log("[AgentChatPage] 更新文件:", fileName);
const updated = [...prev];
updated[existingIndex] = {
...existing,
content,
updatedAt: now,
};
setSelectedFileId(existing.id);
return updated;
}
// 新文件 - 添加到列表
console.log("[AgentChatPage] 创建新文件:", fileName);
const newFile: TaskFile = {
id: crypto.randomUUID(),
name: fileName,
type: "document",
content,
version: 1,
createdAt: now,
updatedAt: now,
};
setSelectedFileId(newFile.id);
return [...prev, newFile];
});
// 更新画布内容
setDocumentState((prev) => {
if (!prev) {
return createInitialDocumentState(content);
}
return {
...prev,
content,
};
});
// 自动打开画布显示流式内容
setLayoutMode("chat-canvas");
},
[currentStepIndex, isContentCreationMode, completeStep],
);
// 更新 ref,供 useAgentChat 使用
useEffect(() => {
handleWriteFileRef.current = handleWriteFile;
}, [handleWriteFile]);
// 处理文件点击 - 在画布中显示文件内容
const handleFileClick = useCallback((fileName: string, content: string) => {
console.log("[AgentChatPage] 文件点击:", fileName);
// 查找或创建任务文件
setTaskFiles((prev) => {
const existingFile = prev.find((f) => f.name === fileName);
if (existingFile) {
setSelectedFileId(existingFile.id);
return prev;
}
// 如果文件不存在,添加到列表
const newFile: TaskFile = {
id: crypto.randomUUID(),
name: fileName,
type: "document",
content,
version: 1,
createdAt: Date.now(),
updatedAt: Date.now(),
};
setSelectedFileId(newFile.id);
return [...prev, newFile];
});
// 更新画布内容
setDocumentState((prev) => {
if (!prev) {
return createInitialDocumentState(content);
}
return {
...prev,
content,
};
});
// 打开画布
setLayoutMode("chat-canvas");
}, []);
// 处理任务文件点击 - 在画布中显示文件内容
const handleTaskFileClick = useCallback((file: TaskFile) => {
if (file.type === "document" && file.content) {
setSelectedFileId(file.id);
setDocumentState((prev) => {
if (!prev) {
return createInitialDocumentState(file.content!);
}
return {
...prev,
content: file.content!,
};
});
// 只打开画布,不关闭文件列表(让用户自己关闭)
setLayoutMode("chat-canvas");
}
}, []);
const hasMessages = messages.length > 0;
// A2UI 表单提交处理
const handleA2UISubmit = useCallback(
async (formData: A2UIFormData, _messageId: string) => {
console.log("[AgentChatPage] A2UI 表单提交:", formData);
// 将表单数据格式化为用户消息
const formattedData = Object.entries(formData)
.map(([key, value]) => {
if (Array.isArray(value)) {
return `- ${key}: ${value.join(", ")}`;
}
return `- ${key}: ${value}`;
})
.join("\n");
const userMessage = `我的选择:\n${formattedData}`;
// 发送用户消息
await sendMessage(userMessage, [], false, false);
},
[sendMessage],
);
// 聊天区域内容
const chatContent = (
<ChatContainer>
{/* 步骤进度条 - 仅在内容创作模式且有消息时显示 */}
{isContentCreationMode && hasMessages && steps.length > 0 && (
<StepProgress
steps={steps}
currentIndex={currentStepIndex}
onStepClick={goToStep}
/>
)}
{hasMessages ? (
<ChatContent>
<MessageList
messages={messages}
onDeleteMessage={deleteMessage}
onEditMessage={editMessage}
onA2UISubmit={handleA2UISubmit}
onWriteFile={handleWriteFile}
onFileClick={handleFileClick}
/>
</ChatContent>
) : (
<EmptyState
input={input}
setInput={setInput}
onSend={(text) => {
setInput(text);
setTimeout(() => handleSend([], false, false), 0);
}}
creationMode={creationMode}
onCreationModeChange={setCreationMode}
activeTheme={activeTheme}
onThemeChange={setActiveTheme}
/>
)}
{hasMessages && (
<>
{/* 任务文件列表 - 显示在输入框上方 */}
{taskFiles.length > 0 && (
<TaskFileList
files={taskFiles}
selectedFileId={selectedFileId}
onFileClick={handleTaskFileClick}
expanded={taskFilesExpanded}
onExpandedChange={setTaskFilesExpanded}
/>
)}
<Inputbar
input={input}
setInput={setInput}
onSend={handleSend}
onStop={stopSending}
isLoading={isSending}
disabled={!processStatus.running && false}
onClearMessages={handleClearMessages}
onToggleCanvas={handleToggleCanvas}
isCanvasOpen={layoutMode === "chat-canvas"}
/>
</>
)}
</ChatContainer>
);
// 画布区域内容
const canvasContent = useMemo(() => {
if (documentState) {
return (
<DocumentCanvas
state={documentState}
onStateChange={setDocumentState}
onClose={handleCloseCanvas}
/>
);
}
return null;
}, [documentState, handleCloseCanvas]);
return (
<PageContainer>
{showSidebar && (
@@ -120,38 +582,12 @@ export function AgentChatPage({
onToggleSettings={() => setShowSettings(!showSettings)}
/>
<ChatContainer>
{hasMessages ? (
<ChatContent>
<MessageList
messages={messages}
onDeleteMessage={deleteMessage}
onEditMessage={editMessage}
/>
</ChatContent>
) : (
<EmptyState
input={input}
setInput={setInput}
onSend={(text) => {
setInput(text);
// 使用 setTimeout 确保 state 更新后再发送
setTimeout(() => handleSend([], false, false), 0);
}}
/>
)}
{hasMessages && (
<Inputbar
input={input}
setInput={setInput}
onSend={handleSend}
isLoading={isSending}
disabled={!processStatus.running && false}
onClearMessages={handleClearMessages}
/>
)}
</ChatContainer>
{/* 使用布局过渡组件 */}
<LayoutTransition
mode={layoutMode}
chatContent={chatContent}
canvasContent={canvasContent}
/>
</MainArea>
{showSettings && <ChatSettings onClose={() => setShowSettings(false)} />}
@@ -0,0 +1,247 @@
/**
* @file 步骤结果展示组件
* @description 展示步骤完成后的结果
* @module components/content-creator/core/StepGuide/StepResult
*/
import React, { memo } from "react";
import styled from "styled-components";
import { Check, Edit2, FileText, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StepResult as StepResultType, ContentFile } from "../../types";
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const ResultCard = styled.div`
padding: 16px;
background: hsl(var(--muted) / 0.3);
border-radius: 12px;
border: 1px solid hsl(var(--border));
`;
const ResultHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
`;
const ResultTitle = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: hsl(var(--foreground));
`;
const SuccessIcon = styled.div`
width: 24px;
height: 24px;
border-radius: 50%;
background: hsl(142 76% 36% / 0.1);
color: hsl(142 76% 36%);
display: flex;
align-items: center;
justify-content: center;
`;
const EditButton = styled(Button)`
gap: 4px;
`;
const UserInputSection = styled.div`
margin-bottom: 12px;
`;
const SectionLabel = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-bottom: 6px;
`;
const InputList = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const InputItem = styled.div`
display: flex;
gap: 8px;
font-size: 13px;
`;
const InputLabel = styled.span`
color: hsl(var(--muted-foreground));
min-width: 80px;
`;
const InputValue = styled.span`
color: hsl(var(--foreground));
`;
const AIOutputSection = styled.div`
padding-top: 12px;
border-top: 1px solid hsl(var(--border));
`;
const AIOutputContent = styled.div`
font-size: 14px;
color: hsl(var(--foreground));
line-height: 1.6;
white-space: pre-wrap;
`;
const ArtifactsSection = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 12px;
border-top: 1px solid hsl(var(--border));
`;
const ArtifactCard = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: hsl(var(--primary));
}
`;
const ArtifactIcon = styled.div`
color: hsl(var(--primary));
`;
const ArtifactName = styled.span`
font-size: 13px;
color: hsl(var(--foreground));
`;
interface StepResultProps {
/** 步骤结果 */
result: StepResultType;
/** 步骤标题 */
title?: string;
/** 编辑回调 */
onEdit?: () => void;
/** 点击产物回调 */
onArtifactClick?: (file: ContentFile) => void;
}
/**
* 步骤结果展示组件
*
* 展示用户输入、AI 输出和生成的产物
*/
export const StepResult: React.FC<StepResultProps> = memo(
({ result, title = "已完成", onEdit, onArtifactClick }) => {
const { userInput, aiOutput, artifacts } = result;
// 格式化用户输入值
const formatValue = (value: unknown): string => {
if (Array.isArray(value)) {
return value.join("、");
}
if (typeof value === "object" && value !== null) {
return JSON.stringify(value);
}
return String(value);
};
// 渲染用户输入部分
const renderUserInput = (): React.JSX.Element | null => {
if (!userInput || Object.keys(userInput).length === 0) {
return null;
}
const entries = Object.entries(userInput) as [string, unknown][];
return (
<UserInputSection>
<SectionLabel>你的输入</SectionLabel>
<InputList>
{entries.map(([key, val]) => (
<InputItem key={key}>
<InputLabel>{key}:</InputLabel>
<InputValue>{formatValue(val)}</InputValue>
</InputItem>
))}
</InputList>
</UserInputSection>
);
};
return (
<Container>
<ResultCard>
<ResultHeader>
<ResultTitle>
<SuccessIcon>
<Check size={14} />
</SuccessIcon>
{title}
</ResultTitle>
{onEdit && (
<EditButton variant="ghost" size="sm" onClick={onEdit}>
<Edit2 size={14} />
编辑
</EditButton>
)}
</ResultHeader>
{/* 用户输入 */}
{renderUserInput() as React.ReactNode}
{/* AI 输出 */}
{aiOutput ? (
<AIOutputSection>
<SectionLabel>
<Sparkles
size={12}
style={{ display: "inline", marginRight: 4 }}
/>
AI 生成
</SectionLabel>
<AIOutputContent>
{typeof aiOutput === "string"
? aiOutput
: JSON.stringify(aiOutput, null, 2)}
</AIOutputContent>
</AIOutputSection>
) : null}
{/* 产物列表 */}
{artifacts && artifacts.length > 0 && (
<ArtifactsSection>
<SectionLabel style={{ width: "100%" }}>生成的文件</SectionLabel>
{artifacts.map((file) => (
<ArtifactCard
key={file.id}
onClick={() => onArtifactClick?.(file)}
>
<ArtifactIcon>
<FileText size={16} />
</ArtifactIcon>
<ArtifactName>{file.name}</ArtifactName>
</ArtifactCard>
))}
</ArtifactsSection>
)}
</ResultCard>
</Container>
);
},
);
StepResult.displayName = "StepResult";