From 83c36747dbfc2b09a6534076cfb1025106c8393a Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 11 Jan 2026 01:20:25 +0800 Subject: [PATCH] chore: bump version to 0.40.0 Co-Authored-By: Claude Opus 4.5 --- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 3 +- src/components/agent/chat/index.tsx | 514 ++++++++++++++++-- .../core/StepGuide/StepResult.tsx | 247 +++++++++ 5 files changed, 726 insertions(+), 42 deletions(-) create mode 100644 src/components/content-creator/core/StepGuide/StepResult.tsx diff --git a/package.json b/package.json index 81174b086..19dcce3e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.39.0", + "version": "0.40.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 77f49af87..711481865 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 89aea6377..b83d356b3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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 }, { diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 2cee1cb4d..7c59cb5a7 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -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 = { + "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 = { + 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("general"); + const [creationMode, setCreationMode] = useState("guided"); + const [layoutMode, setLayoutMode] = useState("chat"); + + // 文档画布状态 + const [documentState, setDocumentState] = + useState(null); + + // 任务文件状态 + const [taskFiles, setTaskFiles] = useState([]); + const [taskFilesExpanded, setTaskFilesExpanded] = useState(false); + const [selectedFileId, setSelectedFileId] = useState(); + + // 用于追踪已处理的消息 ID,避免重复处理 + const processedMessageIds = useRef>(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. ... 标签 + * 2. ```markdown ... ``` 代码块 + * 3. 以 # 开头的 Markdown 内容(整个响应) + */ + const extractDocumentContent = useCallback( + (content: string): string | null => { + // 1. 检查 标签 + const documentMatch = content.match(/([\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 = ( + + {/* 步骤进度条 - 仅在内容创作模式且有消息时显示 */} + {isContentCreationMode && hasMessages && steps.length > 0 && ( + + )} + + {hasMessages ? ( + + + + ) : ( + { + setInput(text); + setTimeout(() => handleSend([], false, false), 0); + }} + creationMode={creationMode} + onCreationModeChange={setCreationMode} + activeTheme={activeTheme} + onThemeChange={setActiveTheme} + /> + )} + + {hasMessages && ( + <> + {/* 任务文件列表 - 显示在输入框上方 */} + {taskFiles.length > 0 && ( + + )} + + + )} + + ); + + // 画布区域内容 + const canvasContent = useMemo(() => { + if (documentState) { + return ( + + ); + } + return null; + }, [documentState, handleCloseCanvas]); + return ( {showSidebar && ( @@ -120,38 +582,12 @@ export function AgentChatPage({ onToggleSettings={() => setShowSettings(!showSettings)} /> - - {hasMessages ? ( - - - - ) : ( - { - setInput(text); - // 使用 setTimeout 确保 state 更新后再发送 - setTimeout(() => handleSend([], false, false), 0); - }} - /> - )} - - {hasMessages && ( - - )} - + {/* 使用布局过渡组件 */} + {showSettings && setShowSettings(false)} />} diff --git a/src/components/content-creator/core/StepGuide/StepResult.tsx b/src/components/content-creator/core/StepGuide/StepResult.tsx new file mode 100644 index 000000000..46be1b64a --- /dev/null +++ b/src/components/content-creator/core/StepGuide/StepResult.tsx @@ -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 = 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 ( + + 你的输入 + + {entries.map(([key, val]) => ( + + {key}: + {formatValue(val)} + + ))} + + + ); + }; + + return ( + + + + + + + + {title} + + {onEdit && ( + + + 编辑 + + )} + + + {/* 用户输入 */} + {renderUserInput() as React.ReactNode} + + {/* AI 输出 */} + {aiOutput ? ( + + + + AI 生成 + + + {typeof aiOutput === "string" + ? aiOutput + : JSON.stringify(aiOutput, null, 2)} + + + ) : null} + + {/* 产物列表 */} + {artifacts && artifacts.length > 0 && ( + + 生成的文件 + {artifacts.map((file) => ( + onArtifactClick?.(file)} + > + + + + {file.name} + + ))} + + )} + + + ); + }, +); + +StepResult.displayName = "StepResult";