diff --git a/README.md b/README.md index 4fe030d..5d071d4 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,13 @@

[![English badge](https://img.shields.io/badge/%E8%8B%B1%E6%96%87-English-blue)](./README.md) -[![简体中文 badge](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-Simplified%20Chinese-blue)](./README-ZH_CN.md)\ -![visitor](https://visitor-badge.glitch.me/badge?page_id=lencx.chatgpt) +[![简体中文 badge](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-Simplified%20Chinese-blue)](./README-ZH_CN.md) [![ChatGPT downloads](https://img.shields.io/github/downloads/lencx/ChatGPT/total.svg?style=flat-square)](https://github.com/lencx/ChatGPT/releases) [![chat](https://img.shields.io/badge/chat-discord-blue?style=flat&logo=discord)](https://discord.gg/aPhCRf4zZr) [![lencx](https://img.shields.io/badge/follow-lencx__-blue?style=flat&logo=Twitter)](https://twitter.com/lencx_) - - - - Buy Me A Coffee -**🛑 URGENT NOTICE: A hacker has been found to take advantage of the heat of `lencx/ChatGPT` to plant a Trojan horse after the fork project and rebuild the installer. If you have friends around you who are using this desktop application, please remind them not to download unknown links freely. Now the project will remove other installation ways and only provide this download link https://github.com/lencx/ChatGPT/releases** - -**🛑 紧急通知:目前发现有黑客利用 `lencx/ChatGPT` 的热度,在 fork 项目后植入木马,重新构建安装程序。如果你身边有朋友正在使用此桌面应用,请提醒 TA 们不要随意下载不明链接。现在项目将删除其他安装途径,仅提供此下载链接 https://github.com/lencx/ChatGPT/releases** - --- **It is an unofficial project intended for personal learning and research purposes only. During the time that the ChatGPT desktop application was open-sourced, it received a lot of attention, and I would like to thank everyone for their support. However, as things have developed, there are two issues that seriously affect the project's next development plan:** diff --git a/src-tauri/src/app/gpt.rs b/src-tauri/src/app/gpt.rs index 60c43dd..8c61e97 100644 --- a/src-tauri/src/app/gpt.rs +++ b/src-tauri/src/app/gpt.rs @@ -9,6 +9,8 @@ use std::{collections::HashMap, fs, path::PathBuf, vec}; use tauri::{api, command, AppHandle, Manager}; use walkdir::WalkDir; +use super::fs_extra::Error; + #[command] pub fn get_chat_prompt_cmd() -> serde_json::Value { let path = utils::app_root().join("chat.prompt.cmd.json"); @@ -24,23 +26,28 @@ pub struct PromptBaseRecord { } #[command] -pub fn parse_prompt(data: String) -> Vec { +pub fn parse_prompt(data: String) -> Option> { let mut rdr = csv::Reader::from_reader(data.as_bytes()); let mut list = vec![]; - for result in rdr.deserialize() { - let record: PromptBaseRecord = result.unwrap_or_else(|err| { - error!("parse_prompt: {}", err); - PromptBaseRecord { - cmd: None, - act: "".to_string(), - prompt: "".to_string(), + + for result in rdr.deserialize::() { + match result { + Ok(record) => { + if !record.act.is_empty() { + list.push(record); + } + } + Err(err) => { + error!("parse_prompt: {}", err); } - }); - if !record.act.is_empty() { - list.push(record); } } - list + + if list.is_empty() { + None + } else { + Some(list) + } } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] @@ -165,87 +172,89 @@ pub async fn sync_prompts(app: AppHandle, time: u64) -> Option .unwrap(); if let Some(v) = res { - let data = parse_prompt(v) - .iter() - .map(move |i| PromptRecord { - cmd: if i.cmd.is_some() { - i.cmd.clone().unwrap() - } else { - utils::gen_cmd(i.act.clone()) - }, - act: i.act.clone(), - prompt: i.prompt.clone(), - tags: vec!["chatgpt-prompts".to_string()], - enable: true, - }) - .collect::>(); - - let data2 = data.clone(); - - let prompts = utils::app_root().join("chat.prompt.json"); - let prompt_cmd = utils::app_root().join("chat.prompt.cmd.json"); - let chatgpt_prompts = utils::app_root() - .join("cache_prompts") - .join("chatgpt_prompts.json"); - - if !utils::exists(&prompts) { - fs::write( - &prompts, - serde_json::json!({ - "name": "ChatGPT Prompts", - "link": "https://github.com/lencx/ChatGPT" + if let Some(data) = parse_prompt(v) { + let transformed_data = data + .iter() + .map(|i| PromptRecord { + cmd: if let Some(cmd) = &i.cmd { + cmd.clone() + } else { + utils::gen_cmd(i.act.clone()) + }, + act: i.act.clone(), + prompt: i.prompt.clone(), + tags: vec!["chatgpt-prompts".to_string()], + enable: true, }) - .to_string(), + .collect::>(); + + let data2 = transformed_data; + + let prompts = utils::app_root().join("chat.prompt.json"); + let prompt_cmd = utils::app_root().join("chat.prompt.cmd.json"); + let chatgpt_prompts = utils::app_root() + .join("cache_prompts") + .join("chatgpt_prompts.json"); + + if !utils::exists(&prompts) { + fs::write( + &prompts, + serde_json::json!({ + "name": "ChatGPT Prompts", + "link": "https://github.com/lencx/ChatGPT" + }) + .to_string(), + ) + .unwrap(); + } + + // chatgpt_prompts.json + fs::write( + chatgpt_prompts, + serde_json::to_string_pretty(&data).unwrap(), ) .unwrap(); + let cmd_data = cmd_list(); + + // chat.prompt.cmd.json + fs::write( + prompt_cmd, + serde_json::to_string_pretty(&serde_json::json!({ + "name": "ChatGPT CMD", + "last_updated": time, + "data": cmd_data, + })) + .unwrap(), + ) + .unwrap(); + let mut kv = HashMap::new(); + kv.insert( + "sync_prompts".to_string(), + serde_json::json!({ "id": "chatgpt_prompts", "last_updated": time }), + ); + let prompts_data = utils::merge( + &serde_json::from_str(&fs::read_to_string(&prompts).unwrap()).unwrap(), + &kv, + ); + + // chat.prompt.json + fs::write( + prompts, + serde_json::to_string_pretty(&prompts_data).unwrap(), + ) + .unwrap(); + + // refresh window + api::dialog::message( + app.get_window("core").as_ref(), + "Sync Prompts", + "Prompts data has been synchronized!", + ); + window::cmd::window_reload(app.clone(), "core"); + window::cmd::window_reload(app, "tray"); + + return Some(data2); } - - // chatgpt_prompts.json - fs::write( - chatgpt_prompts, - serde_json::to_string_pretty(&data).unwrap(), - ) - .unwrap(); - let cmd_data = cmd_list(); - - // chat.prompt.cmd.json - fs::write( - prompt_cmd, - serde_json::to_string_pretty(&serde_json::json!({ - "name": "ChatGPT CMD", - "last_updated": time, - "data": cmd_data, - })) - .unwrap(), - ) - .unwrap(); - let mut kv = HashMap::new(); - kv.insert( - "sync_prompts".to_string(), - serde_json::json!({ "id": "chatgpt_prompts", "last_updated": time }), - ); - let prompts_data = utils::merge( - &serde_json::from_str(&fs::read_to_string(&prompts).unwrap()).unwrap(), - &kv, - ); - - // chat.prompt.json - fs::write( - prompts, - serde_json::to_string_pretty(&prompts_data).unwrap(), - ) - .unwrap(); - - // refresh window - api::dialog::message( - app.get_window("core").as_ref(), - "Sync Prompts", - "ChatGPT Prompts data has been synchronized!", - ); - window::cmd::window_reload(app.clone(), "core"); - window::cmd::window_reload(app, "tray"); - - return Some(data2); } None @@ -260,37 +269,40 @@ pub async fn sync_user_prompts(url: String, data_type: String) -> Option> = if data_type == "csv" { info!("chatgpt_http_csv_parse"); - data = parse_prompt(v); + parse_prompt(v) } else if data_type == "json" { info!("chatgpt_http_json_parse"); - data = serde_json::from_str(&v).unwrap_or_else(|err| { - error!("chatgpt_http_json_parse: {}", err); - vec![] - }); + match serde_json::from_str::>(&v) { + Ok(parsed) => Some(parsed), + Err(err) => { + error!("chatgpt_http_json_parse: {}", err); + None + } + } } else { error!("chatgpt_http_unknown_type"); - data = vec![]; + None + }; + + if let Some(base_records) = data { + let data = base_records + .iter() + .map(|i| PromptRecord { + cmd: i + .cmd + .clone() + .unwrap_or_else(|| utils::gen_cmd(i.act.clone())), + act: i.act.clone(), + prompt: i.prompt.clone(), + tags: vec!["user-sync".to_string()], + enable: true, + }) + .collect::>(); + + return Some(data); } - - let data = data - .iter() - .map(move |i| PromptRecord { - cmd: if i.cmd.is_some() { - i.cmd.clone().unwrap() - } else { - utils::gen_cmd(i.act.clone()) - }, - act: i.act.clone(), - prompt: i.prompt.clone(), - tags: vec!["user-sync".to_string()], - enable: true, - }) - .collect::>(); - - return Some(data); } None diff --git a/src/hooks/useChatPrompt.ts b/src/hooks/useChatPrompt.ts index b3aab33..5ba3bf8 100644 --- a/src/hooks/useChatPrompt.ts +++ b/src/hooks/useChatPrompt.ts @@ -18,11 +18,21 @@ export default function useChatPrompt(key: string, file = CHAT_PROMPT_JSON) { const promptSet = async (data: Record[] | Record) => { const oData = clone(promptJson); oData[key] = data; + await writeJSON(file, oData); setPromptJson(oData); }; - return { promptJson, promptSet, promptData: promptJson?.[key] || [] }; + const promptUpdate = async (id: string, field: string, value: any) => { + const oData = clone(promptJson); + const idx = oData[key].findIndex((v: any) => v.id === id); + oData[key][idx][field] = value; + + await writeJSON(file, oData); + setPromptJson(oData); + }; + + return { promptJson, promptSet, promptUpdate, promptData: promptJson?.[key] || [] }; } export function useCachePrompt(file = '') { diff --git a/src/view/prompts/SyncCustom/Form.tsx b/src/view/prompts/SyncCustom/Form.tsx index 263fbb5..711176c 100644 --- a/src/view/prompts/SyncCustom/Form.tsx +++ b/src/view/prompts/SyncCustom/Form.tsx @@ -5,17 +5,15 @@ import { useImperativeHandle, forwardRef, } from 'react'; -import { Form, Input, Radio, Upload, Tooltip, message } from 'antd'; +import { Form, Input, Radio, Upload, Tooltip, Button, message } from 'antd'; import { v4 } from 'uuid'; -import { InboxOutlined } from '@ant-design/icons'; +import { UploadOutlined } from '@ant-design/icons'; import type { FormProps, RadioChangeEvent, UploadProps, UploadFile } from 'antd'; import { DISABLE_AUTO_COMPLETE, chatRoot } from '@/utils'; -// import useInit from '@/hooks/useInit'; interface SyncFormProps { record?: Record | null; - type: string; } const initFormValue = { @@ -25,43 +23,18 @@ const initFormValue = { protocol: 'https', }; -const SyncForm: ForwardRefRenderFunction = ({ record, type }, ref) => { - // const isDisabled = type === 'edit'; +const SyncForm: ForwardRefRenderFunction = ({ record }, ref) => { const [form] = Form.useForm(); useImperativeHandle(ref, () => ({ form })); - // const [root, setRoot] = useState(''); const [protocol, setProtocol] = useState('https'); const [fileList, setFileList] = useState([]); - // useInit(async () => { - // setRoot(await chatRoot()); - // }); - useEffect(() => { if (record) { form.setFieldsValue(record); } }, [record]); - // const pathOptions = ( - // - // - // - // ); - - // const extOptions = ( - // - // - // - // ); - const jsonTip = ( = ({ record, local -
-

- .ext: The file supports only {csvTip} and {jsonTip} formats. -

-
{['http', 'https'].includes(protocol) && ( - ({ - validator(_, value) { - if (!value || /\.json$|\.csv$/.test(getFieldValue('url'))) { - return Promise.resolve(); - } - return Promise.reject(new Error('The file supports only .csv and .json formats')); - }, - }), - ]} - style={{ height: 200 }} - > - - +
+ ({ + validator(_, value) { + if (!value || /\.json$|\.csv$/.test(getFieldValue('url'))) { + return Promise.resolve(); + } + return Promise.reject( + new Error('The file supports only .csv and .json formats'), + ); + }, + }), + ]} + > + + +
+

+ .ext: only {csvTip} or {jsonTip} file formats are supported. +

+
+
)} {protocol === 'local' && ( -

- + +

+ Only {csvTip} or {jsonTip} file formats are supported.

-

Click or drag file to this area to upload

-

Only .json or .csv files are supported.

)} diff --git a/src/view/prompts/SyncCustom/config.tsx b/src/view/prompts/SyncCustom/config.tsx index bb3721c..d4c8424 100644 --- a/src/view/prompts/SyncCustom/config.tsx +++ b/src/view/prompts/SyncCustom/config.tsx @@ -4,6 +4,7 @@ import { HistoryOutlined } from '@ant-design/icons'; import { shell, path } from '@tauri-apps/api'; import { Link } from 'react-router-dom'; +import { EditRow } from '@/hooks/useColumns'; import useInit from '@/hooks/useInit'; import { chatRoot, fmtDate } from '@/utils'; @@ -13,6 +14,9 @@ export const syncColumns = () => [ dataIndex: 'name', key: 'name', width: 100, + render: (_: string, row: any, actions: any) => ( + + ), }, { title: 'Protocol', @@ -47,21 +51,22 @@ export const syncColumns = () => [ render: (_: any, row: any, actions: any) => { return ( - actions.setRecord(row, 'sync')} - okText="Yes" - cancelText="No" - > - Sync - + {row.protocol !== 'local' && ( + actions.setRecord(row, 'sync')} + okText="Yes" + cancelText="No" + > + Sync + + )} {row.last_updated && ( View )} - actions.setRecord(row, 'edit')}>Edit actions.setRecord(row, 'delete')} @@ -86,8 +91,8 @@ const RenderPath = ({ row }: any) => { export const getPath = async (row: any) => { if (!/^http/.test(row.protocol)) { - return (await path.join(await chatRoot(), row.path)) + `.${row.ext}`; + return await path.join(await chatRoot(), 'cache_prompts', `${row.id}.json`); } else { - return `${row.protocol}://${row.path}.${row.ext}`; + return `${row.protocol}://${row.url}`; } }; diff --git a/src/view/prompts/SyncCustom/index.tsx b/src/view/prompts/SyncCustom/index.tsx index d4b0dfc..46ff0fb 100644 --- a/src/view/prompts/SyncCustom/index.tsx +++ b/src/view/prompts/SyncCustom/index.tsx @@ -1,12 +1,13 @@ import { useState, useRef, useEffect } from 'react'; import { Table, Modal, Button, message } from 'antd'; -import { invoke, path, fs } from '@tauri-apps/api'; +import { invoke, path, fs, shell } from '@tauri-apps/api'; import useData from '@/hooks/useData'; +import useInit from '@/hooks/useInit'; import useColumns from '@/hooks/useColumns'; import { TABLE_PAGINATION } from '@/hooks/useTable'; import useChatPrompt, { useCachePrompt } from '@/hooks/useChatPrompt'; -import { CHAT_PROMPT_JSON, chatRoot, readJSON, genCmd } from '@/utils'; +import { CHAT_PROMPT_JSON, chatRoot, genCmd } from '@/utils'; import { syncColumns, getPath } from './config'; import SyncForm from './Form'; @@ -19,8 +20,9 @@ const fmtData = (data: Record[] = []) => })); export default function SyncCustom() { + const [logPath, setLogPath] = useState(''); const [isVisible, setVisible] = useState(false); - const { promptData, promptSet } = useChatPrompt('sync_custom', CHAT_PROMPT_JSON); + const { promptData, promptSet, promptUpdate } = useChatPrompt('sync_custom', CHAT_PROMPT_JSON); const { promptCacheCmd, promptCacheSet } = useCachePrompt(); const { opData, opInit, opAdd, opRemove, opReplace, opSafeKey } = useData([]); const { columns, ...opInfo } = useColumns(syncColumns()); @@ -31,6 +33,11 @@ export default function SyncCustom() { opInfo.resetRecord(); }; + useInit(async () => { + const filePath = await path.join(await chatRoot(), 'chatgpt.log'); + setLogPath(filePath); + }); + useEffect(() => { if (promptData.length <= 0) return; opInit(promptData); @@ -38,24 +45,19 @@ export default function SyncCustom() { useEffect(() => { if (!opInfo.opType) return; - if (opInfo.opType === 'sync') { - const filename = `${opInfo?.opRecord?.id}.json`; - handleSync(filename).then((isOk: boolean) => { + (async () => { + if (opInfo.opType === 'sync') { + handleSync(); + } + if (opInfo.opType === 'rowedit') { + await promptUpdate(opInfo?.opRecord?.id, 'name', opInfo?.opRecord?.name); + message.success('Name has been changed'); opInfo.resetRecord(); - if (!isOk) return; - const data = opReplace(opInfo?.opRecord?.[opSafeKey], { - ...opInfo?.opRecord, - last_updated: Date.now(), - }); - promptSet(data); - opInfo.resetRecord(); - }); - } - if (['edit', 'new'].includes(opInfo.opType)) { - setVisible(true); - } - if (['delete'].includes(opInfo.opType)) { - (async () => { + } + if (['edit', 'new'].includes(opInfo.opType)) { + setVisible(true); + } + if (['delete'].includes(opInfo.opType)) { try { const file = await path.join( await chatRoot(), @@ -68,63 +70,117 @@ export default function SyncCustom() { promptSet(data); opInfo.resetRecord(); promptCacheCmd(); - })(); - } + } + })(); }, [opInfo.opType, formRef]); - const handleSync = async (filename: string) => { + const handleSync = async () => { + const filename = `${opInfo?.opRecord?.id}.json`; const record = opInfo?.opRecord; - const isJson = /json$/.test(record?.ext); - const file = await path.join(await chatRoot(), 'cache_prompts', filename); - const filePath = await getPath(record); // https or http if (/^http/.test(record?.protocol)) { - const data = await invoke('sync_user_prompts', { url: filePath, dataType: record?.ext }); + const isJson = /json$/.test(record?.url); + const file = await path.join(await chatRoot(), 'cache_prompts', filename); + const filePath = await getPath(record); + + const data = await invoke('sync_user_prompts', { + url: filePath, + dataType: isJson ? 'json' : 'csv', + }); if (data) { await promptCacheSet(data as [], file); await promptCacheCmd(); - message.success('ChatGPT Prompts data has been synchronized!'); - return true; + message.success('Prompts successfully synchronized'); + const data2 = opReplace(opInfo?.opRecord?.[opSafeKey], { + ...opInfo?.opRecord, + last_updated: Date.now(), + }); + promptSet(data2); + opInfo.resetRecord(); } else { - message.error('ChatGPT Prompts data sync failed, please try again!'); - return false; + message.error( + 'Prompts synchronization failed, please try again (click to "View Log" for more details)', + ); } } - // local - if (isJson) { - // parse json - const data = await readJSON(filePath, { isRoot: true }); - await promptCacheSet(fmtData(data), file); - } else { - // parse csv - const data = await fs.readTextFile(filePath); - const list: Record[] = await invoke('parse_prompt', { data }); - await promptCacheSet(fmtData(list), file); + opInfo.resetRecord(); + }; + + const parseLocal = async (file: File): Promise<[boolean, any[] | null]> => { + if (file) { + const fileData = await readFile(file); + const isJSON = /json$/.test(file.name); + + if (isJSON) { + // parse json + try { + const jsonData = JSON.parse(fileData); + return [true, jsonData]; + } catch (e) { + message.error('JSON parse error, please check your file'); + return [false, null]; + } + } else { + // parse csv + const list: Record[] | null = await invoke('parse_prompt', { + data: fileData, + }); + if (!list) { + message.error('CSV parse error, please check your file'); + return [false, null]; + } else { + return [true, list]; + } + } } - await promptCacheCmd(); - return true; + + message.error('File parsing exception'); + return [false, null]; }; const handleOk = () => { formRef.current?.form?.validateFields().then(async (vals: Record) => { - const file = await readFile(vals?.file?.file?.originFileObj); - vals.file = file; - if (opInfo.opType === 'new') { - const data = opAdd(vals); - promptSet(data); - message.success('Data added successfully'); + if (vals.protocol !== 'local') { + // http or https + delete vals.file; + const data = opAdd(vals); + await promptSet(data); + hide(); + opInfo.setRecord(data[0], 'sync'); + message.success('Data added successfully'); + } else { + const file = vals?.file?.file?.originFileObj; + const data = opAdd(vals); + const parseData = await parseLocal(file); + + if (parseData[0]) { + const id = data[0].id; + const filePath = await path.join(await chatRoot(), 'cache_prompts', `${id}.json`); + data[0].last_updated = Date.now(); + await promptSet(data); + await promptCacheSet(fmtData(parseData[1] as []), filePath); + await promptCacheCmd(); + hide(); + message.success('Data added successfully'); + } + } } if (opInfo.opType === 'edit') { + delete vals.file; const data = opReplace(opInfo?.opRecord?.[opSafeKey], vals); promptSet(data); + hide(); message.success('Data updated successfully'); } - hide(); }); }; + const handleLog = () => { + shell.open(logPath); + }; + return (
+ - + ); } -function readFile(file: File) { +function readFile(file: File): Promise { return new Promise((resolve, reject) => { let reader = new FileReader(); reader.onload = (e: any) => resolve(e.target.result); diff --git a/src/view/prompts/SyncRecord/index.tsx b/src/view/prompts/SyncRecord/index.tsx index c8513d3..260e83b 100644 --- a/src/view/prompts/SyncRecord/index.tsx +++ b/src/view/prompts/SyncRecord/index.tsx @@ -28,7 +28,11 @@ export default function SyncRecord() { const selectedItems = rowSelection.selectedRowKeys || []; useInit(async () => { - setFilePath(await getPath(state)); + if (state.protocol === 'local') { + setFilePath(''); + } else { + setFilePath(await getPath(state)); + } setJsonPath(await path.join(await chatRoot(), 'cache_prompts', `${state?.id}.json`)); });