writing.tsx 13.4 KB
import chatStyles from "@/app/components/chat.module.scss";
import homeStyles from "@/app/components/home.module.scss";
import styles from "@/app/components/writing/writing.module.scss";
import clsx from "clsx";

import { WriteSiderBar } from "./write-siderBar";
import { WindowContent } from "@/app/components/home";
import { useMobileScreen } from "@/app/utils";
import { IconButton } from "../button";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
import { useLocation, useNavigate } from "react-router-dom";
import { getClientConfig } from "@/app/config/client";
import React, {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { useAppConfig, useChatStore, useMindMapStore } from "@/app/store";
import { ChatAction } from "../chat";
import { useWindowSize } from "@/app/utils";
import { exportHtmlToWord } from "@/app/utils/fileExport/word";

import ReturnIcon from "@/app/icons/return.svg";
import MinIcon from "@/app/icons/min.svg";
import MaxIcon from "@/app/icons/max.svg";
import SDIcon from "@/app/icons/sd.svg";
import LoadingIcon from "@/app/icons/three-dots.svg";
import BotIcon from "@/app/icons/bot.svg";
import EditIcon from "@/app/icons/rename.svg";
import ReloadIcon from "@/app/icons/reload.svg";
import CopyIcon from "@/app/icons/copy.svg";
import ExcelIcon from "@/app/icons/excel.svg";
import WordIcon from "@/app/icons/word.svg";
import MindIcon from "@/app/icons/mind.svg";
import PptIcon from "@/app/icons/ppt.svg";
import PdfIcon from "@/app/icons/pdf.svg";
import HtmlIcon from "@/app/icons/HTML.svg";

import { message } from "antd";
import { HTMLPreview } from "../artifacts";
import { getMindPrompt, getWrtingPrompt } from "@/app/utils/prompt";
import { htmlToPdf2 } from "@/app/utils/fileExport/toPdf";
import { hasTable, htmlToExcel } from "@/app/utils/fileExport/export2Excel";
import { writePromptParam } from "@/app/types/prompt";
import { mergedData } from "./writie-panel";
import dynamic from "next/dynamic";

const EditorComponent = dynamic(
  async () => (await import("./editor")).EditorComponent,
  {
    loading: () => null,
  },
);

export function WritingPage() {
  const chatStore = useChatStore();
  const isMobileScreen = useMobileScreen();
  const navigate = useNavigate();
  const clientConfig = useMemo(() => getClientConfig(), []);
  const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
  const config = useAppConfig();
  const scrollRef = useRef<HTMLDivElement>(null);
  const isWriting = location.pathname === Path.Writing;
  const { height } = useWindowSize();
  const [width, setWidth] = useState("100%");
  const [isEdit, setIsEdit] = useState(false);
  const [loading, setLoading] = useState(false);
  const [htmlHeader, setHtmlheader] = useState("");
  const [htmlCode, setHtmlCode] = useState<string>(
    localStorage.getItem("htmlCode") || "",
  );
  const query = useLocation(); //获取路由参数
  let { msg, writeMessage } = query.state || {}; //获取路由参数

  useEffect(() => {
    if (!msg) {
      return;
    }
    if (!writeMessage) {
      return;
    }
    const navigateGetData = async () => {
      try {
        const param: writePromptParam = {
          writingPurposeName: mergedData[0].default,
          writingStyleName: mergedData[2].default,
          writingLanguageName: mergedData[3].default,
          prompt: writeMessage,
          writingTypeName: mergedData[4].default,
          isImgName: mergedData[5].default,
          writingCount: "200",
        };
        const input = getWrtingPrompt(param);
        setLoading(true);
        console.log("------------------------" + input);
        const response = await chatStore.directLlmInvoke(input, "gpt-4o-mini");
        let cleanedContent = response.startsWith("```html")
          ? response.substring(8)
          : response;
        if (cleanedContent.endsWith("```")) {
          cleanedContent = cleanedContent.substring(
            0,
            cleanedContent.length - 4,
          );
        }
        //保存html头部
        const bodyTagRegex = /<body[^>]*>/i;
        const bodyTagMatch = cleanedContent.match(bodyTagRegex);
        if (bodyTagMatch && bodyTagMatch.index !== undefined) {
          // 截取从文档开头到 <body> 标签的起始位置
          const contentUpToBody = cleanedContent.slice(
            0,
            bodyTagMatch.index + bodyTagMatch[0].length,
          );
          setHtmlheader(contentUpToBody); //保存html头部
        }
        localStorage.setItem("htmlCode", cleanedContent);
        setHtmlCode(cleanedContent);
      } catch (error) {
        message.error("生成失败,请重试");
      } finally {
        setLoading(false);
      }
    };
    navigateGetData();
  }, []);

  function wrapContentInDivWithWidth(html: string): string {
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, "text/html");
    const body = doc.body;
    const centerStyle =
      "display: flex;flex-direction: column;justify-content: center;align-items: center;margin:0";
    body.style.cssText += centerStyle;
    if (!body) {
      return `<html><head><meta charset="UTF-8"></head>
              <body style="${centerStyle}">
              <div style="width: ${width}">${html}</div></body></html>`;
    }
    // 创建一个新的<div>,并设置宽度
    const wrapperDiv = doc.createElement("div");
    wrapperDiv.style.width = width;
    // 将<body>中的所有子节点移到<div>
    while (body.firstChild) {
      wrapperDiv.appendChild(body.firstChild);
    }
    // 将<div>添加到<body>
    body.appendChild(wrapperDiv);
    // 将修改后的DOM转换回HTML字符串
    return doc.documentElement.outerHTML;
  }

  const handleCopy = async () => {
    try {
      const blob = new Blob([htmlCode], { type: "text/html" });
      const clipboardItem = new ClipboardItem({ "text/html": blob });
      await navigator.clipboard.write([clipboardItem]);
      message.success("复制成功!");
    } catch (error) {
      message.error("复制失败");
    }
  };
  //跳转到ppt页面
  function toPowerpoint(pptMessage: string) {
    navigate(Path.Powerpoint, { state: { msg: true, pptMessage: pptMessage } });
  }
  // 转至思维导图页面
  const toMind = useCallback(
    (content: string) => {
      const { setMindMapData } = useMindMapStore.getState();
      setMindMapData(
        [
          {
            role: "user",
            content: getMindPrompt(content, true),
          },
        ],
        content,
      );
      navigate(Path.Mind, { state: { msg: true } });
    },
    [navigate],
  );

  //导出html文件
  const exportHtml = useCallback(() => {
    try {
      const htmlContent = wrapContentInDivWithWidth(htmlCode);
      const parser = new DOMParser();
      const doc = parser.parseFromString(htmlContent, "text/html");
      // 提取<h1>标签的内容作为文件名
      let fileName = "output.html"; // 默认文件名
      const h1Element = doc.querySelector("h1");
      if (h1Element && h1Element.textContent) {
        // 使用<h1>的内容作为文件名,并清理非法字符
        fileName =
          h1Element.textContent
            .trim()
            .replace(/[\u0000-\u001f\\?*:"<>|]/g, "") + ".html";
      }
      const blob = new Blob([htmlContent], { type: "text/html" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = fileName;
      a.click();
      URL.revokeObjectURL(url);
      message.success("导出成功");
    } catch (error) {
      message.error("导出失败");
    }
  }, [wrapContentInDivWithWidth]);

  useEffect(() => {
    localStorage.setItem("htmlCode", htmlCode);
  }, [htmlCode]);
  return (
    <>
      <WriteSiderBar
        className={clsx({ [homeStyles["sidebar-show"]]: isWriting })}
        htmlCode={htmlCode}
        setHtmlCode={setHtmlCode}
        loading={loading}
        setLoading={setLoading}
        setWidth={setWidth}
        setHtmlheader={setHtmlheader}
      />
      <WindowContent>
        <div className={chatStyles.chat} key={"1"}>
          <div className="window-header" data-tauri-drag-region>
            {isMobileScreen && (
              <div className="window-actions">
                <div className={"window-action-button"}>
                  <IconButton
                    icon={<ReturnIcon />}
                    bordered
                    title={Locale.Chat.Actions.ChatList}
                    onClick={() => navigate(Path.BgRemoval)}
                  />
                </div>
              </div>
            )}
            <div
              className={clsx(
                "window-header-title",
                chatStyles["chat-body-title"],
              )}
            >
              <div className={`window-header-main-title`}>AI-Writing</div>
            </div>
            <div className={chatStyles["chat-message-actions"]}>
              {htmlCode && (
                <div className={chatStyles["chat-input-actions"]}>
                  <ChatAction
                    text={Locale.Chat.Actions.ReWrite}
                    icon={<ReloadIcon />}
                    onClick={() => {}}
                    disabled={isEdit}
                  />
                  <ChatAction
                    text={Locale.Chat.Actions.Copy}
                    icon={<CopyIcon />}
                    onClick={handleCopy}
                    disabled={isEdit}
                  />
                  {!isEdit ? (
                    <ChatAction
                      text={Locale.Chat.Actions.Edit}
                      icon={<EditIcon />}
                      onClick={() => {
                        setIsEdit(true);
                      }}
                    />
                  ) : (
                    <ChatAction
                      text={Locale.Chat.Actions.CancelEdit}
                      icon={<EditIcon />}
                      onClick={() => {
                        setIsEdit(false);
                      }}
                    />
                  )}
                  <ChatAction
                    text={Locale.Export.Pdf}
                    icon={<PdfIcon />}
                    onClick={async () => {
                      setLoading(true);
                      const html = wrapContentInDivWithWidth(htmlCode);
                      await htmlToPdf2(html);
                      setLoading(false);
                    }}
                    disabled={isEdit}
                  />
                  <ChatAction
                    text={Locale.Export.Word}
                    icon={<WordIcon />}
                    onClick={() => {
                      const html = wrapContentInDivWithWidth(htmlCode);
                      exportHtmlToWord(html);
                    }}
                    disabled={isEdit}
                  />
                  {hasTable(htmlCode) && (
                    <ChatAction
                      text={Locale.Export.Excel}
                      icon={<ExcelIcon />}
                      onClick={() => {
                        htmlToExcel(htmlCode);
                      }}
                      disabled={isEdit}
                    />
                  )}

                  <ChatAction
                    text={Locale.Export.Ppt}
                    icon={<PptIcon />}
                    onClick={() => {
                      toPowerpoint(htmlCode);
                    }}
                    disabled={isEdit}
                  />
                  <ChatAction
                    text={Locale.Export.Mind.title}
                    icon={<MindIcon />}
                    onClick={() => {
                      toMind(htmlCode);
                    }}
                    disabled={isEdit}
                  />
                  <ChatAction
                    text={Locale.Export.Html}
                    icon={<HtmlIcon />}
                    onClick={() => {
                      exportHtml();
                    }}
                    disabled={isEdit}
                  />
                </div>
              )}
            </div>
            <div className="window-actions">
              {showMaxIcon && (
                <div className="window-action-button">
                  <IconButton
                    aria={Locale.Chat.Actions.FullScreen}
                    icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
                    bordered
                    onClick={() => {
                      config.update(
                        (config) => (config.tightBorder = !config.tightBorder),
                      );
                    }}
                  />
                </div>
              )}
              {isMobileScreen && <SDIcon width={50} height={50} />}
            </div>
          </div>
          <div
            className={`${chatStyles["chat-body"]} ${styles["write-body"]}`}
            ref={scrollRef}
          >
            {loading ? (
              <div className={clsx("no-dark", styles["loading-content"])}>
                <BotIcon />
                <LoadingIcon />
              </div>
            ) : (
              htmlCode &&
              (isEdit ? (
                <EditorComponent
                  htmlCode={htmlCode}
                  setHtmlCode={setHtmlCode}
                />
              ) : (
                <HTMLPreview
                  code={htmlCode}
                  autoHeight={!document.fullscreenElement}
                  height={!document.fullscreenElement ? "100%" : height}
                  width={width}
                />
              ))
            )}
          </div>
        </div>
      </WindowContent>
    </>
  );
}