支持ChatGPT生成内容

This commit is contained in:
xiaoqi.cxq 2023-04-10 10:24:22 +08:00
parent 0e02822add
commit 599d71b597
18 changed files with 305 additions and 3 deletions

View File

@ -72,6 +72,7 @@ StackEdit中文版
- Gitlab的支持优化2023-02-23 - Gitlab的支持优化2023-02-23
- 导出HTML、PDF支持带预览主题导出2023-02-26 - 导出HTML、PDF支持带预览主题导出2023-02-26
- 支持分享文档2023-03-30 - 支持分享文档2023-03-30
- 支持ChatGPT生成内容2023-04-10
## 国外开源版本弊端: ## 国外开源版本弊端:
- 作者已经不维护了 - 作者已经不维护了

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "stackedit", "name": "stackedit",
"version": "5.15.19", "version": "5.15.20",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "stackedit", "name": "stackedit",
"version": "5.15.19", "version": "5.15.20",
"description": "免费, 开源, 功能齐全的 Markdown 编辑器", "description": "免费, 开源, 功能齐全的 Markdown 编辑器",
"author": "Benoit Schweblin, 豆萁", "author": "Benoit Schweblin, 豆萁",
"license": "Apache-2.0", "license": "Apache-2.0",

View File

@ -41,6 +41,8 @@ import BadgeManagementModal from './modals/BadgeManagementModal';
import SponsorModal from './modals/SponsorModal'; import SponsorModal from './modals/SponsorModal';
import CommitMessageModal from './modals/CommitMessageModal'; import CommitMessageModal from './modals/CommitMessageModal';
import WorkspaceImgPathModal from './modals/WorkspaceImgPathModal'; import WorkspaceImgPathModal from './modals/WorkspaceImgPathModal';
import ChatGptModal from './modals/ChatGptModal';
import ChatGptConfigModal from './modals/ChatGptConfigModal';
// Providers // Providers
import GooglePhotoModal from './modals/providers/GooglePhotoModal'; import GooglePhotoModal from './modals/providers/GooglePhotoModal';
@ -111,6 +113,8 @@ export default {
SponsorModal, SponsorModal,
CommitMessageModal, CommitMessageModal,
WorkspaceImgPathModal, WorkspaceImgPathModal,
ChatGptModal,
ChatGptConfigModal,
// Providers // Providers
GooglePhotoModal, GooglePhotoModal,
GoogleDriveAccountModal, GoogleDriveAccountModal,

View File

@ -0,0 +1,52 @@
<template>
<modal-inner aria-label="ChatGPT配置">
<div class="modal__content">
<div class="modal__image">
<icon-chat-gpt></icon-chat-gpt>
</div>
<p> <b>ChatGPT</b> 配置.</p>
<form-entry label="代理地址" error="proxyHost">
<input slot="field" class="textfield" type="text" v-model.trim="proxyHost" @keydown.enter="resolve()">
<div class="form-entry__info">
<b>非必填默认是官方接口地址(https://api.openai.com):</b> https://openai.geekr.cool
</div>
</form-entry>
<form-entry label="apiKey" error="apiKey">
<input slot="field" class="textfield" type="text" v-model.trim="apiKey" @keydown.enter="resolve()">
<div class="form-entry__info">
<b>apiKey</b> 请到<a href="https://platform.openai.com/account/api-keys" target="_blank">https://platform.openai.com/account/api-keys</a> <br>
</div>
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">取消</button>
<button class="button button--resolve" @click="resolve()">确认</button>
</div>
</modal-inner>
</template>
<script>
import modalTemplate from './common/modalTemplate';
export default modalTemplate({
data: () => ({
apiKey: this.config.apiKey,
proxyHost: this.config.proxyHost,
}),
computedLocalSettings: {
apiKey: 'chatgptApiKey',
proxyHost: 'chatgptProxyHost',
},
methods: {
resolve() {
if (!this.apiKey) {
this.setError('apiKey');
}
if (this.proxyHost && this.proxyHost.endsWith('/')) {
this.proxyHost = this.proxyHost.substring(0, this.proxyHost.length - 1);
}
this.config.resolve({ apiKey: this.apiKey, proxyHost: this.proxyHost });
},
},
});
</script>

View File

@ -0,0 +1,145 @@
<template>
<modal-inner class="modal__inner-1--chatgpt" aria-label="chatgpt">
<div class="modal__content">
<div class="modal__image">
<icon-chat-gpt></icon-chat-gpt>
</div>
<p><b>ChatGPT内容生成</b><br>生成时长受ChatGPT服务响应与网络响应时长影响时间可能较长</p>
<form-entry label="生成内容要求详细描述" error="content">
<textarea slot="field" class="text-input" type="text" placeholder="输入内容" v-model.trim="content" :disabled="generating || !chatGptConfig.apiKey"></textarea>
<div class="form-entry__info">
<span v-if="!chatGptConfig.apiKey" class="config-warning">
未配置apiKey请点击 <a href="javascript:void(0)" @click="openConfig">配置</a> apiKey
</span>
<span v-else>
<span v-if="chatGptConfig.proxyHost">
<b>当前使用的接口代理</b>{{ chatGptConfig.proxyHost }}
</span>
<a href="javascript:void(0)" @click="openConfig">修改apiKey配置</a>
</span>
</div>
</form-entry>
<div class="modal__result">
<span v-if="generating && !result">(等待生成中...)</span>
<pre class="result_pre" v-html="result"></pre>
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="reject()">{{ generating ? '停止' : '关闭' }}</button>
<button class="button button--resolve" @click="generate" v-if="!generating && !!content">{{ !!result ? '重新生成' : '开始生成' }}</button>
<button class="button button--resolve" @click="resolve" v-if="!generating && !!result">确认插入</button>
</div>
</modal-inner>
</template>
<script>
import { mapGetters } from 'vuex';
import modalTemplate from './common/modalTemplate';
import chatGptSvc from '../../services/chatGptSvc';
import store from '../../store';
export default modalTemplate({
data: () => ({
generating: false,
content: '',
result: '',
xhr: null,
}),
computed: {
...mapGetters('chatgpt', [
'chatGptConfig',
]),
},
methods: {
resolve(evt) {
evt.preventDefault(); // Fixes https://github.com/mafgwo/stackedit/issues/1503
const { callback } = this.config;
this.config.resolve();
callback(this.result);
},
process({ done, content, error }) {
if (done) {
this.generating = false;
//
} else if (content) {
this.result = this.result + content;
const container = document.querySelector('.result_pre');
container.scrollTo(0, container.scrollHeight); //
} else if (error) {
this.generating = false;
}
},
generate() {
this.generating = true;
this.result = '';
try {
this.xhr = chatGptSvc.chat(this.chatGptConfig.proxyHost, this.chatGptConfig.apiKey, `${this.content}\n(使用Markdown方式输出结果)`, this.process);
} catch (err) {
this.generating = false;
store.dispatch('notification/error', err);
}
},
async openConfig() {
try {
const config = await store.dispatch('modal/open', { type: 'chatGptConfig', apiKey: this.chatGptConfig.apiKey, proxyHost: this.chatGptConfig.proxyHost });
store.dispatch('chatgpt/setCurrConfig', config);
} catch (e) { /* Cancel */ }
},
reject() {
if (this.generating) {
if (this.xhr) {
this.xhr.abort();
this.generating = false;
}
return;
}
const { callback } = this.config;
this.config.reject();
callback(null);
},
},
async created() {
// store chatgpt
const config = localStorage.getItem('chatgpt/config');
store.dispatch('chatgpt/setCurrConfig', JSON.parse(config || '{}'));
},
});
</script>
<style lang="scss">
@import '../../styles/variables.scss';
.modal__inner-1.modal__inner-1--chatgpt {
max-width: 560px;
.result_pre {
font-size: 0.9em;
font-variant-ligatures: no-common-ligatures;
line-height: 1.25;
white-space: pre-wrap;
word-break: break-word;
word-wrap: break-word;
height: 300px;
border: 1px solid rgb(126, 126, 126);
border-radius: $border-radius-base;
padding: 10px;
overflow-y: scroll; /* 开启垂直滚动条 */
}
.result_pre::-webkit-scrollbar {
display: none; /* 隐藏滚动条 */
}
.result_pre.scroll-bottom {
scroll-behavior: smooth;
}
.config-warning {
color: #f00;
}
.text-input {
min-height: 50px;
}
}
</style>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="gitee"></icon-provider> <icon-provider provider-id="gitee"></icon-provider>
</div> </div>
<p>Save <b>{{currentFileName}}</b> to your <b>Gitee</b> repository and keep it synced.</p> <p>保存 <b>{{currentFileName}}</b> 并与您的 <b>Gitee</b> 仓库保持同步.</p>
<form-entry label="仓库URL" error="repoUrl"> <form-entry label="仓库URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -40,4 +40,6 @@ export default () => ({
zendescPublishSectionId: '', zendescPublishSectionId: '',
zendescPublishLocale: '', zendescPublishLocale: '',
zendeskPublishTemplate: 'plainHtml', zendeskPublishTemplate: 'plainHtml',
chatgptApiKey: '',
chatgptProxyHost: '',
}); });

View File

@ -43,6 +43,8 @@ editor:
link: true link: true
# 图片 # 图片
image: true image: true
# ChatGPT
chatgpt: true
# Keyboard shortcuts # Keyboard shortcuts
# See https://craig.is/killing/mice # See https://craig.is/killing/mice
@ -57,6 +59,7 @@ shortcuts:
mod+shift+h: heading mod+shift+h: heading
mod+shift+r: hr mod+shift+r: hr
mod+shift+g: image mod+shift+g: image
mod+shift+p: chatgpt
mod+shift+i: italic mod+shift+i: italic
mod+shift+l: link mod+shift+l: link
mod+shift+o: olist mod+shift+o: olist

View File

@ -49,4 +49,8 @@ export default [{
method: 'image', method: 'image',
title: '图片', title: '图片',
icon: 'file-image', icon: 'file-image',
}, {
method: 'chatgpt',
title: 'ChatGPT',
icon: 'chat-gpt',
}]; }];

3
src/icons/ChatGpt.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<svg class="icon" width="24px" height="24px" viewBox="140 140 520 520"><defs><linearGradient id="linear" x1="100%" y1="22%" x2="0%" y2="78%"><stop offset="0%" stop-color="rgb(131,211,231)"></stop><stop offset="2%" stop-color="rgb(127,203,229)"></stop><stop offset="25%" stop-color="rgb(86,115,217)"></stop><stop offset="49%" stop-color="rgb(105,80,190)"></stop><stop offset="98%" stop-color="rgb(197,59,119)"></stop><stop offset="100%" stop-color="rgb(197,59,119)"></stop></linearGradient></defs><path id="logo" d="m617.24 354a126.36 126.36 0 0 0 -10.86-103.79 127.8 127.8 0 0 0 -137.65-61.32 126.36 126.36 0 0 0 -95.31-42.49 127.81 127.81 0 0 0 -121.92 88.49 126.4 126.4 0 0 0 -84.5 61.3 127.82 127.82 0 0 0 15.72 149.86 126.36 126.36 0 0 0 10.86 103.79 127.81 127.81 0 0 0 137.65 61.32 126.36 126.36 0 0 0 95.31 42.49 127.81 127.81 0 0 0 121.96-88.54 126.4 126.4 0 0 0 84.5-61.3 127.82 127.82 0 0 0 -15.76-149.81zm-190.66 266.49a94.79 94.79 0 0 1 -60.85-22c.77-.42 2.12-1.16 3-1.7l101-58.34a16.42 16.42 0 0 0 8.3-14.37v-142.39l42.69 24.65a1.52 1.52 0 0 1 .83 1.17v117.92a95.18 95.18 0 0 1 -94.97 95.06zm-204.24-87.23a94.74 94.74 0 0 1 -11.34-63.7c.75.45 2.06 1.25 3 1.79l101 58.34a16.44 16.44 0 0 0 16.59 0l123.31-71.2v49.3a1.53 1.53 0 0 1 -.61 1.31l-102.1 58.95a95.16 95.16 0 0 1 -129.85-34.79zm-26.57-220.49a94.71 94.71 0 0 1 49.48-41.68c0 .87-.05 2.41-.05 3.48v116.68a16.41 16.41 0 0 0 8.29 14.36l123.31 71.19-42.69 24.65a1.53 1.53 0 0 1 -1.44.13l-102.11-59a95.16 95.16 0 0 1 -34.79-129.81zm350.74 81.62-123.31-71.2 42.69-24.64a1.53 1.53 0 0 1 1.44-.13l102.11 58.95a95.08 95.08 0 0 1 -14.69 171.55c0-.88 0-2.42 0-3.49v-116.68a16.4 16.4 0 0 0 -8.24-14.36zm42.49-63.95c-.75-.46-2.06-1.25-3-1.79l-101-58.34a16.46 16.46 0 0 0 -16.59 0l-123.31 71.2v-49.3a1.53 1.53 0 0 1 .61-1.31l102.1-58.9a95.07 95.07 0 0 1 141.19 98.44zm-267.11 87.87-42.7-24.65a1.52 1.52 0 0 1 -.83-1.17v-117.92a95.07 95.07 0 0 1 155.9-73c-.77.42-2.11 1.16-3 1.7l-101 58.34a16.41 16.41 0 0 0 -8.3 14.36zm23.19-50 54.92-31.72 54.92 31.7v63.42l-54.92 31.7-54.92-31.7z" fill="#202123"></path></svg>
</template>

View File

@ -65,6 +65,7 @@ import SelectTheme from './SelectTheme';
import Copy from './Copy'; import Copy from './Copy';
import Ellipsis from './Ellipsis'; import Ellipsis from './Ellipsis';
import Share from './Share'; import Share from './Share';
import ChatGpt from './ChatGpt';
Vue.component('iconProvider', Provider); Vue.component('iconProvider', Provider);
Vue.component('iconFormatBold', FormatBold); Vue.component('iconFormatBold', FormatBold);
@ -132,3 +133,4 @@ Vue.component('iconSelectTheme', SelectTheme);
Vue.component('iconCopy', Copy); Vue.component('iconCopy', Copy);
Vue.component('iconEllipsis', Ellipsis); Vue.component('iconEllipsis', Ellipsis);
Vue.component('iconShare', Share); Vue.component('iconShare', Share);
Vue.component('iconChatGpt', ChatGpt);

View File

@ -122,6 +122,7 @@ function Pagedown(options) {
hooks.addNoop("onPreviewRefresh"); // called with no arguments after the preview has been refreshed hooks.addNoop("onPreviewRefresh"); // called with no arguments after the preview has been refreshed
hooks.addNoop("postBlockquoteCreation"); // called with the user's selection *after* the blockquote was created; should return the actual to-be-inserted text hooks.addNoop("postBlockquoteCreation"); // called with the user's selection *after* the blockquote was created; should return the actual to-be-inserted text
hooks.addFalse("insertImageDialog"); hooks.addFalse("insertImageDialog");
hooks.addFalse("insertChatGptDialog");
/* called with one parameter: a callback to be called with the URL of the image. If the application creates /* called with one parameter: a callback to be called with the URL of the image. If the application creates
* its own image insertion dialog, this hook should return true, and the callback should be called with the chosen * its own image insertion dialog, this hook should return true, and the callback should be called with the chosen
* image url (or null if the user cancelled). If this hook returns false, the default dialog will be used. * image url (or null if the user cancelled). If this hook returns false, the default dialog will be used.
@ -477,6 +478,7 @@ function UIManager(input, commandManager) {
buttons.image = bindCommand(function (chunk, postProcessing) { buttons.image = bindCommand(function (chunk, postProcessing) {
return this.doLinkOrImage(chunk, postProcessing, true); return this.doLinkOrImage(chunk, postProcessing, true);
}); });
buttons.chatgpt = bindCommand("doChatGpt");
buttons.olist = bindCommand(function (chunk, postProcessing) { buttons.olist = bindCommand(function (chunk, postProcessing) {
this.doList(chunk, postProcessing, true); this.doList(chunk, postProcessing, true);
}); });
@ -846,6 +848,17 @@ commandProto.doLinkOrImage = function (chunk, postProcessing, isImage) {
} }
}; };
commandProto.doChatGpt = function (chunk, postProcessing) {
var enteredCallback = function (content) {
if (content !== null) {
chunk.before = `${chunk.before}${content}`;
chunk.selection = '';
}
postProcessing();
};
this.hooks.insertChatGptDialog(enteredCallback);
};
// When making a list, hitting shift-enter will put your cursor on the next line // When making a list, hitting shift-enter will put your cursor on the next line
// at the current indent level. // at the current indent level.
commandProto.doAutoindent = function (chunk) { commandProto.doAutoindent = function (chunk) {

View File

@ -0,0 +1,38 @@
import store from '../store';
export default {
chat(proxyHost, apiKey, content, callback) {
const xhr = new XMLHttpRequest();
const url = `${proxyHost || 'https://api.openai.com'}/v1/chat/completions`;
xhr.open('POST', url);
xhr.setRequestHeader('Authorization', `Bearer ${apiKey}`);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content }],
temperature: 1,
stream: true,
}));
let lastRespLen = 0;
xhr.onprogress = () => {
const responseText = xhr.response.substr(lastRespLen);
lastRespLen = xhr.response.length;
responseText.split('\n\n')
.filter(l => l.length > 0)
.forEach((text) => {
const item = text.substr(6);
if (item === '[DONE]') {
callback({ done: true });
} else {
const data = JSON.parse(item);
callback({ content: data.choices[0].delta.content });
}
});
};
xhr.onerror = () => {
store.dispatch('notification/error', 'ChatGPT接口请求异常');
callback({ error: 'ChatGPT接口请求异常' });
};
return xhr;
},
};

View File

@ -439,6 +439,13 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
}); });
return true; return true;
}); });
this.pagedownEditor.hooks.set('insertChatGptDialog', (callback) => {
store.dispatch('modal/open', {
type: 'chatGpt',
callback,
});
return true;
});
this.pagedownEditor.hooks.set('insertImageUploading', (callback) => { this.pagedownEditor.hooks.set('insertImageUploading', (callback) => {
callback(store.getters['img/currImgId']); callback(store.getters['img/currImgId']);
return true; return true;

View File

@ -28,6 +28,7 @@ const methods = {
quote: pagedownHandler('quote'), quote: pagedownHandler('quote'),
code: pagedownHandler('code'), code: pagedownHandler('code'),
image: pagedownHandler('image'), image: pagedownHandler('image'),
chatgpt: pagedownHandler('chatgpt'),
olist: pagedownHandler('olist'), olist: pagedownHandler('olist'),
ulist: pagedownHandler('ulist'), ulist: pagedownHandler('ulist'),
clist: pagedownHandler('clist'), clist: pagedownHandler('clist'),

25
src/store/chatgpt.js Normal file
View File

@ -0,0 +1,25 @@
const chatgptConfigKey = 'chatgpt/config';
export default {
namespaced: true,
state: {
config: {
apiKey: null,
proxyHost: null,
},
},
mutations: {
setCurrConfig: (state, value) => {
state.config = value;
},
},
getters: {
chatGptConfig: state => state.config,
},
actions: {
setCurrConfig({ commit }, value) {
commit('setCurrConfig', value);
localStorage.setItem(chatgptConfigKey, JSON.stringify(value));
},
},
};

View File

@ -20,6 +20,7 @@ import userInfo from './userInfo';
import workspace from './workspace'; import workspace from './workspace';
import img from './img'; import img from './img';
import theme from './theme'; import theme from './theme';
import chatgpt from './chatgpt';
import locationTemplate from './locationTemplate'; import locationTemplate from './locationTemplate';
import emptyPublishLocation from '../data/empties/emptyPublishLocation'; import emptyPublishLocation from '../data/empties/emptyPublishLocation';
import emptySyncLocation from '../data/empties/emptySyncLocation'; import emptySyncLocation from '../data/empties/emptySyncLocation';
@ -51,6 +52,7 @@ const store = new Vuex.Store({
workspace, workspace,
img, img,
theme, theme,
chatgpt,
}, },
state: { state: {
light: false, light: false,