index.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. /**
  2. * ChatGPT工具
  3. * @author zhaoxianlie
  4. */
  5. import AI from './fh.ai.js';
  6. new Vue({
  7. el: '#pageContainer',
  8. data: {
  9. prompt: '',
  10. demos: [
  11. '用Js写一个冒泡排序的Demo',
  12. 'Js里的Fetch API是怎么用的',
  13. '帮我写一个单网页版的俄罗斯方块游戏',
  14. '我开发了一个浏览器插件,是专门为HR自动找简历的,现在请你帮我用SVG绘制一个插件的ICON,不需要问我细节,直接生成'
  15. ],
  16. initMessage: {
  17. id:'id-test123',
  18. sendTime:'2022/12/20 12:12:12',
  19. message: '你好,可以告诉我你是谁吗?我该怎么和你沟通?',
  20. respTime: '2022/12/20 12:12:13',
  21. respContent: '你好,我是FeHelper智能助理,由OpenAI提供技术支持;你可以在下面的输入框向我提问,我会尽可能回答你~~~'
  22. },
  23. respResult:{
  24. id: '',
  25. sendTime:'',
  26. message:'',
  27. respTime:'',
  28. respContent:''
  29. },
  30. currentSession: [],
  31. history:[],
  32. tempId:'',
  33. hideDemo: false,
  34. undergoing: false,
  35. messages: [],
  36. showHistoryPanel: false
  37. },
  38. computed: {
  39. groupedHistory() {
  40. // 按日期分组,主题为message前20字
  41. const groups = {};
  42. this.history.forEach(item => {
  43. const date = item.sendTime ? item.sendTime.split(' ')[0] : '未知日期';
  44. if (!groups[date]) groups[date] = [];
  45. groups[date].push({
  46. ...item,
  47. theme: item.message ? item.message.slice(0, 20) : ''
  48. });
  49. });
  50. return groups;
  51. }
  52. },
  53. watch: {
  54. history: {
  55. handler(val) {
  56. localStorage.setItem('fh-aiagent-history', JSON.stringify(val));
  57. },
  58. deep: true
  59. }
  60. },
  61. mounted: function () {
  62. this.$refs.prompt.focus();
  63. this.hideDemo = !!(new URL(location.href)).searchParams.get('hideDemo');
  64. // 加载本地历史
  65. const local = localStorage.getItem('fh-aiagent-history');
  66. if(local){
  67. try {
  68. this.history = JSON.parse(local);
  69. } catch(e) {}
  70. }
  71. },
  72. methods: {
  73. // 这个代码,主要用来判断大模型返回的内容是不是包含完整的代码块
  74. validateCodeBlocks(content) {
  75. let backticksCount = 0;
  76. let inCodeBlock = false;
  77. let codeBlockStartIndex = -1;
  78. for (let i = 0; i < content.length; i++) {
  79. // 检查当前位置是否是三个连续的反引号
  80. if (content.startsWith('```', i)) {
  81. backticksCount++;
  82. i += 2; // 跳过接下来的两个字符,因为它们也是反引号的一部分
  83. // 如果我们遇到了奇数个反引号序列,那么我们进入了代码块
  84. if (backticksCount % 2 !== 0) {
  85. inCodeBlock = true;
  86. codeBlockStartIndex = i - 2;
  87. } else { // 否则,我们离开了代码块
  88. inCodeBlock = false;
  89. if (codeBlockStartIndex === -1 || codeBlockStartIndex > i) {
  90. return false; // 这意味着有不匹配的反引号
  91. }
  92. codeBlockStartIndex = -1;
  93. }
  94. }
  95. }
  96. // 如果最终 backticksCount 是偶数,则所有代码块都正确关闭
  97. return backticksCount % 2 === 0 && !inCodeBlock;
  98. },
  99. sendMessage(prompt){
  100. if(this.undergoing) return;
  101. if(this.respResult.id){
  102. // 先存储上一轮对话到历史
  103. this.history.push({
  104. id: this.respResult.id,
  105. sendTime: this.respResult.sendTime,
  106. message: this.respResult.message,
  107. respTime: this.respResult.respTime,
  108. respContent: this.respResult.respContent
  109. });
  110. }
  111. this.undergoing = true;
  112. let sendTime = (new Date()).format('yyyy/MM/dd HH:mm:ss');
  113. this.$nextTick(() => {
  114. this.scrollToBottom();
  115. });
  116. this.tempId = '';
  117. let respContent = '';
  118. // 1. 先把用户输入 push 到 messages
  119. this.messages.push({ role: 'user', content: prompt });
  120. // 新增:用户消息push到currentSession
  121. this.currentSession.push({
  122. role: 'user',
  123. id: 'user-' + Date.now(),
  124. time: sendTime,
  125. content: prompt
  126. });
  127. AI.askCoderLLM(this.messages, (respJson, done) => {
  128. if(done){
  129. this.undergoing = false;
  130. if(this.respResult.id && this.respResult.respContent){
  131. const tempDiv = document.createElement('div');
  132. tempDiv.innerHTML = this.respResult.respContent;
  133. const plainText = tempDiv.textContent || tempDiv.innerText || '';
  134. this.messages.push({ role: 'assistant', content: plainText });
  135. this.history.push({
  136. id: this.respResult.id,
  137. sendTime: this.respResult.sendTime,
  138. message: this.respResult.message,
  139. respTime: this.respResult.respTime,
  140. respContent: this.respResult.respContent
  141. });
  142. }
  143. this.$nextTick(() => {
  144. document.querySelectorAll('.x-xcontent pre code').forEach((block) => {
  145. hljs.highlightBlock(block);
  146. insertCodeToolbar(block);
  147. });
  148. this.scrollToBottom();
  149. });
  150. return;
  151. }
  152. let id = respJson.id;
  153. let rawContent = respJson.content || '';
  154. // 检查多轮代码补全场景
  155. const lastAssistantMsg = this.currentSession.slice().reverse().find(m => m.role === 'assistant');
  156. const lastIsCodeBlock = lastAssistantMsg && /```\s*$/.test(lastAssistantMsg.content.trim());
  157. const thisIsCodeBlock = /^```/.test(rawContent.trim());
  158. if (lastIsCodeBlock && !thisIsCodeBlock) {
  159. rawContent = '```js\n' + rawContent.trim() + '\n```';
  160. }
  161. respContent = rawContent;
  162. if(!this.validateCodeBlocks(respContent)) {
  163. respContent += '\n```';
  164. }
  165. respContent = marked(respContent);
  166. if(this.tempId !== id) {
  167. this.tempId = id;
  168. let dateTime = new Date(respJson.created * 1000);
  169. let respTime = dateTime.format('yyyy/MM/dd HH:mm:ss');
  170. this.respResult = { id,sendTime,message:prompt,respTime,respContent };
  171. // 新增:助手回复push到currentSession
  172. this.currentSession.push({
  173. role: 'assistant',
  174. id,
  175. time: respTime,
  176. content: respContent
  177. });
  178. }else{
  179. this.respResult.respContent = respContent;
  180. // 更新最后一条助手消息内容
  181. if(this.currentSession.length && this.currentSession[this.currentSession.length-1].role==='assistant'){
  182. this.currentSession[this.currentSession.length-1].content = respContent;
  183. }
  184. }
  185. this.$nextTick(() => this.scrollToBottom());
  186. });
  187. },
  188. scrollToBottom(){
  189. this.$refs.boxResult.scrollTop = this.$refs.boxResult.scrollHeight;
  190. },
  191. goChat(){
  192. this.sendMessage(this.prompt);
  193. this.$nextTick(() => this.prompt='');
  194. },
  195. openOptionsPage: function(event) {
  196. event.preventDefault();
  197. event.stopPropagation();
  198. chrome.runtime.openOptionsPage();
  199. },
  200. openDonateModal: function(event ){
  201. event.preventDefault();
  202. event.stopPropagation();
  203. chrome.runtime.sendMessage({
  204. type: 'fh-dynamic-any-thing',
  205. thing: 'open-donate-modal',
  206. params: { toolName: 'aiagent' }
  207. });
  208. },
  209. loadHistory(item) {
  210. // 渲染到主面板
  211. this.respResult = {
  212. id: item.id,
  213. sendTime: item.sendTime,
  214. message: item.message,
  215. respTime: item.respTime,
  216. respContent: item.respContent
  217. };
  218. this.showHistoryPanel = false;
  219. this.$nextTick(() => this.scrollToBottom());
  220. },
  221. startNewChat(event) {
  222. event && event.preventDefault();
  223. this.messages = [];
  224. this.respResult = {
  225. id: '',
  226. sendTime: '',
  227. message: '',
  228. respTime: '',
  229. respContent: ''
  230. };
  231. this.currentSession = [];
  232. this.showHistoryPanel = false;
  233. this.$nextTick(() => {
  234. this.$forceUpdate();
  235. this.scrollToBottom();
  236. });
  237. },
  238. onHistoryClick(event) {
  239. event.preventDefault();
  240. event.stopPropagation();
  241. this.showHistoryPanel = !this.showHistoryPanel;
  242. },
  243. onPromptKeydown(e) {
  244. if (e.key === 'Enter') {
  245. if (e.shiftKey) {
  246. // 允许换行
  247. return;
  248. } else {
  249. // 阻止默认换行,发送消息
  250. e.preventDefault();
  251. this.goChat();
  252. }
  253. }
  254. }
  255. }
  256. });
  257. // 工具函数:复制和运行
  258. function copyCode(code) {
  259. if (navigator.clipboard) {
  260. navigator.clipboard.writeText(code);
  261. } else {
  262. const textarea = document.createElement('textarea');
  263. textarea.value = code;
  264. document.body.appendChild(textarea);
  265. textarea.select();
  266. document.execCommand('copy');
  267. document.body.removeChild(textarea);
  268. }
  269. }
  270. // 新增:为代码块插入工具栏
  271. function insertCodeToolbar(block) {
  272. // 检查是否已插入按钮,避免重复
  273. if (block.parentNode.querySelector('.fh-code-toolbar')) return;
  274. // 创建工具栏
  275. const toolbar = document.createElement('div');
  276. toolbar.className = 'fh-code-toolbar';
  277. toolbar.style.cssText = 'position:absolute;bottom:6px;right:12px;z-index:10;display:flex;gap:8px;';
  278. // 复制按钮
  279. const btnCopy = document.createElement('button');
  280. btnCopy.innerText = '复制';
  281. btnCopy.className = 'fh-btn-copy';
  282. btnCopy.style.cssText = 'padding:2px 8px;font-size:12px;cursor:pointer;';
  283. btnCopy.onclick = (e) => {
  284. e.stopPropagation();
  285. copyCode(block.innerText);
  286. // 复制成功反馈
  287. const oldText = btnCopy.innerText;
  288. btnCopy.innerText = '已复制';
  289. btnCopy.disabled = true;
  290. setTimeout(() => {
  291. btnCopy.innerText = oldText;
  292. btnCopy.disabled = false;
  293. }, 1000);
  294. };
  295. // 运行按钮
  296. const lang = (block.className || '').toLowerCase();
  297. let btnRun = document.createElement('button');
  298. btnRun.className = 'fh-btn-run';
  299. btnRun.style.cssText = 'padding:2px 8px;font-size:12px;cursor:pointer;';
  300. let shouldAppendBtnRun = true;
  301. if (lang.includes('lang-javascript') || lang.includes('lang-js')) {
  302. btnRun.innerText = 'Console运行';
  303. btnRun.onclick = (e) => {
  304. e.stopPropagation();
  305. copyCode(block.innerText);
  306. btnRun.innerText = '已复制到剪贴板';
  307. btnRun.disabled = true;
  308. setTimeout(() => {
  309. btnRun.innerText = 'Console运行';
  310. btnRun.disabled = false;
  311. }, 1200);
  312. showToast('代码已复制到剪贴板,请按F12打开开发者工具,切换到Console粘贴回车即可运行!');
  313. };
  314. } else if (lang.includes('lang-html')) {
  315. btnRun.innerText = '下载并运行';
  316. btnRun.onclick = (e) => {
  317. e.stopPropagation();
  318. const blob = new Blob([block.innerText], {type: 'text/html'});
  319. const url = URL.createObjectURL(blob);
  320. const a = document.createElement('a');
  321. a.href = url;
  322. a.download = 'fehelper-demo.html';
  323. document.body.appendChild(a);
  324. a.click();
  325. document.body.removeChild(a);
  326. URL.revokeObjectURL(url);
  327. btnRun.innerText = '已下载';
  328. btnRun.disabled = true;
  329. setTimeout(() => {
  330. btnRun.innerText = '下载并运行';
  331. btnRun.disabled = false;
  332. }, 1200);
  333. showToast('HTML文件已下载,请双击打开即可运行!');
  334. };
  335. } else if (lang.includes('lang-xml') || lang.includes('lang-svg')) {
  336. // 检查内容是否为svg
  337. const codeText = block.innerText.trim();
  338. if (/^<svg[\s\S]*<\/svg>$/.test(codeText)) {
  339. btnRun.innerText = '点击预览';
  340. btnRun.onclick = (e) => {
  341. e.stopPropagation();
  342. // 弹窗预览svg
  343. const modal = document.createElement('div');
  344. modal.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;background:rgba(0,0,0,0.5);z-index:999999;display:flex;align-items:center;justify-content:center;';
  345. const inner = document.createElement('div');
  346. inner.style.cssText = 'background:#fff;width:400px;height:400px;border-radius:10px;box-shadow:0 2px 16px rgba(0,0,0,0.18);position:relative;display:flex;align-items:center;justify-content:center;';
  347. const closeBtn = document.createElement('button');
  348. closeBtn.innerText = '×';
  349. closeBtn.style.cssText = 'position:absolute;top:8px;right:12px;width:32px;height:32px;font-size:22px;line-height:28px;background:transparent;border:none;cursor:pointer;color:#888;z-index:2;';
  350. closeBtn.onclick = () => document.body.removeChild(modal);
  351. const img = document.createElement('img');
  352. img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(codeText)));
  353. img.alt = 'SVG预览';
  354. img.style.cssText = 'max-width:90%;max-height:90%;display:block;border:1px solid #eee;background:#fafbfc;';
  355. inner.appendChild(closeBtn);
  356. inner.appendChild(img);
  357. modal.appendChild(inner);
  358. document.body.appendChild(modal);
  359. };
  360. } else {
  361. btnRun.remove();
  362. shouldAppendBtnRun = false;
  363. }
  364. } else {
  365. btnRun.remove();
  366. shouldAppendBtnRun = false;
  367. }
  368. toolbar.appendChild(btnCopy);
  369. if (shouldAppendBtnRun) {
  370. toolbar.appendChild(btnRun);
  371. }
  372. // 让pre相对定位,插入工具栏到底部
  373. const pre = block.parentNode;
  374. pre.style.position = 'relative';
  375. pre.appendChild(toolbar);
  376. }
  377. // 页面内Toast提示
  378. function showToast(msg) {
  379. let toast = document.createElement('div');
  380. toast.className = 'fh-toast';
  381. toast.innerText = msg;
  382. toast.style.cssText = `
  383. position:fixed;left:50%;top:80px;transform:translateX(-50%);
  384. background:rgba(0,0,0,0.85);color:#fff;padding:10px 24px;
  385. border-radius:6px;font-size:16px;z-index:99999;box-shadow:0 2px 8px rgba(0,0,0,0.2);
  386. transition:opacity 0.3s;opacity:1;
  387. `;
  388. document.body.appendChild(toast);
  389. setTimeout(() => {
  390. toast.style.opacity = '0';
  391. setTimeout(() => document.body.removeChild(toast), 300);
  392. }, 1800);
  393. }