mirror of
https://gitee.com/mafgwo/stackedit
synced 2024-11-15 19:22:27 +08:00
支持ChatGPT生成内容
This commit is contained in:
parent
0e02822add
commit
599d71b597
|
@ -72,6 +72,7 @@ StackEdit中文版
|
|||
- Gitlab的支持优化(2023-02-23)
|
||||
- 导出HTML、PDF支持带预览主题导出(2023-02-26)
|
||||
- 支持分享文档(2023-03-30)
|
||||
- 支持ChatGPT生成内容(2023-04-10)
|
||||
|
||||
## 国外开源版本弊端:
|
||||
- 作者已经不维护了
|
||||
|
|
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "stackedit",
|
||||
"version": "5.15.19",
|
||||
"version": "5.15.20",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "stackedit",
|
||||
"version": "5.15.19",
|
||||
"version": "5.15.20",
|
||||
"description": "免费, 开源, 功能齐全的 Markdown 编辑器",
|
||||
"author": "Benoit Schweblin, 豆萁",
|
||||
"license": "Apache-2.0",
|
||||
|
|
|
@ -41,6 +41,8 @@ import BadgeManagementModal from './modals/BadgeManagementModal';
|
|||
import SponsorModal from './modals/SponsorModal';
|
||||
import CommitMessageModal from './modals/CommitMessageModal';
|
||||
import WorkspaceImgPathModal from './modals/WorkspaceImgPathModal';
|
||||
import ChatGptModal from './modals/ChatGptModal';
|
||||
import ChatGptConfigModal from './modals/ChatGptConfigModal';
|
||||
|
||||
// Providers
|
||||
import GooglePhotoModal from './modals/providers/GooglePhotoModal';
|
||||
|
@ -111,6 +113,8 @@ export default {
|
|||
SponsorModal,
|
||||
CommitMessageModal,
|
||||
WorkspaceImgPathModal,
|
||||
ChatGptModal,
|
||||
ChatGptConfigModal,
|
||||
// Providers
|
||||
GooglePhotoModal,
|
||||
GoogleDriveAccountModal,
|
||||
|
|
52
src/components/modals/ChatGptConfigModal.vue
Normal file
52
src/components/modals/ChatGptConfigModal.vue
Normal 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>
|
145
src/components/modals/ChatGptModal.vue
Normal file
145
src/components/modals/ChatGptModal.vue
Normal 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>
|
|
@ -4,7 +4,7 @@
|
|||
<div class="modal__image">
|
||||
<icon-provider provider-id="gitee"></icon-provider>
|
||||
</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">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
|
|
|
@ -40,4 +40,6 @@ export default () => ({
|
|||
zendescPublishSectionId: '',
|
||||
zendescPublishLocale: '',
|
||||
zendeskPublishTemplate: 'plainHtml',
|
||||
chatgptApiKey: '',
|
||||
chatgptProxyHost: '',
|
||||
});
|
||||
|
|
|
@ -43,6 +43,8 @@ editor:
|
|||
link: true
|
||||
# 图片
|
||||
image: true
|
||||
# ChatGPT
|
||||
chatgpt: true
|
||||
|
||||
# Keyboard shortcuts
|
||||
# See https://craig.is/killing/mice
|
||||
|
@ -57,6 +59,7 @@ shortcuts:
|
|||
mod+shift+h: heading
|
||||
mod+shift+r: hr
|
||||
mod+shift+g: image
|
||||
mod+shift+p: chatgpt
|
||||
mod+shift+i: italic
|
||||
mod+shift+l: link
|
||||
mod+shift+o: olist
|
||||
|
|
|
@ -49,4 +49,8 @@ export default [{
|
|||
method: 'image',
|
||||
title: '图片',
|
||||
icon: 'file-image',
|
||||
}, {
|
||||
method: 'chatgpt',
|
||||
title: 'ChatGPT',
|
||||
icon: 'chat-gpt',
|
||||
}];
|
||||
|
|
3
src/icons/ChatGpt.vue
Normal file
3
src/icons/ChatGpt.vue
Normal 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>
|
|
@ -65,6 +65,7 @@ import SelectTheme from './SelectTheme';
|
|||
import Copy from './Copy';
|
||||
import Ellipsis from './Ellipsis';
|
||||
import Share from './Share';
|
||||
import ChatGpt from './ChatGpt';
|
||||
|
||||
Vue.component('iconProvider', Provider);
|
||||
Vue.component('iconFormatBold', FormatBold);
|
||||
|
@ -132,3 +133,4 @@ Vue.component('iconSelectTheme', SelectTheme);
|
|||
Vue.component('iconCopy', Copy);
|
||||
Vue.component('iconEllipsis', Ellipsis);
|
||||
Vue.component('iconShare', Share);
|
||||
Vue.component('iconChatGpt', ChatGpt);
|
||||
|
|
|
@ -122,6 +122,7 @@ function Pagedown(options) {
|
|||
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.addFalse("insertImageDialog");
|
||||
hooks.addFalse("insertChatGptDialog");
|
||||
/* 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
|
||||
* 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) {
|
||||
return this.doLinkOrImage(chunk, postProcessing, true);
|
||||
});
|
||||
buttons.chatgpt = bindCommand("doChatGpt");
|
||||
buttons.olist = bindCommand(function (chunk, postProcessing) {
|
||||
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
|
||||
// at the current indent level.
|
||||
commandProto.doAutoindent = function (chunk) {
|
||||
|
|
38
src/services/chatGptSvc.js
Normal file
38
src/services/chatGptSvc.js
Normal 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;
|
||||
},
|
||||
};
|
|
@ -439,6 +439,13 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
|
|||
});
|
||||
return true;
|
||||
});
|
||||
this.pagedownEditor.hooks.set('insertChatGptDialog', (callback) => {
|
||||
store.dispatch('modal/open', {
|
||||
type: 'chatGpt',
|
||||
callback,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
this.pagedownEditor.hooks.set('insertImageUploading', (callback) => {
|
||||
callback(store.getters['img/currImgId']);
|
||||
return true;
|
||||
|
|
|
@ -28,6 +28,7 @@ const methods = {
|
|||
quote: pagedownHandler('quote'),
|
||||
code: pagedownHandler('code'),
|
||||
image: pagedownHandler('image'),
|
||||
chatgpt: pagedownHandler('chatgpt'),
|
||||
olist: pagedownHandler('olist'),
|
||||
ulist: pagedownHandler('ulist'),
|
||||
clist: pagedownHandler('clist'),
|
||||
|
|
25
src/store/chatgpt.js
Normal file
25
src/store/chatgpt.js
Normal 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));
|
||||
},
|
||||
},
|
||||
};
|
|
@ -20,6 +20,7 @@ import userInfo from './userInfo';
|
|||
import workspace from './workspace';
|
||||
import img from './img';
|
||||
import theme from './theme';
|
||||
import chatgpt from './chatgpt';
|
||||
import locationTemplate from './locationTemplate';
|
||||
import emptyPublishLocation from '../data/empties/emptyPublishLocation';
|
||||
import emptySyncLocation from '../data/empties/emptySyncLocation';
|
||||
|
@ -51,6 +52,7 @@ const store = new Vuex.Store({
|
|||
workspace,
|
||||
img,
|
||||
theme,
|
||||
chatgpt,
|
||||
},
|
||||
state: {
|
||||
light: false,
|
||||
|
|
Loading…
Reference in New Issue
Block a user