diff --git a/package-lock.json b/package-lock.json index 68377d6..f4c16fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@agney/react-loading": "^0.1.2", "gpt-tokenizer": "^2.1.1", "luxon": "^3.4.1", - "openai": "^3.2.1", + "openai": "^4.11.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^8.0.7", @@ -27,6 +27,7 @@ "@commitlint/prompt-cli": "^17.4.3", "@semantic-release/changelog": "^6.0.2", "@semantic-release/git": "^10.0.1", + "@types/axios": "^0.14.0", "@types/luxon": "^3.3.1", "@types/node": "^16.11.6", "@types/react": "^18.0.27", @@ -1438,6 +1439,16 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "node_modules/@types/axios": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.0.tgz", + "integrity": "sha512-KqQnQbdYE54D7oa/UmYVMZKq7CO4l8DEENzOKc4aBRwxCXSlJXGz83flFx5L7AWrOQnmuN3kVsRdt+GZPPjiVQ==", + "deprecated": "This is a stub types definition for axios (https://github.com/mzabriskie/axios). axios provides its own type definitions, so you don't need @types/axios installed!", + "dev": true, + "dependencies": { + "axios": "*" + } + }, "node_modules/@types/codemirror": { "version": "0.0.108", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.108.tgz", @@ -1509,8 +1520,16 @@ "node_modules/@types/node": { "version": "16.18.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.12.tgz", - "integrity": "sha512-vzLe5NaNMjIE3mcddFVGlAXN1LEWueUsMsOJWaT6wWMJGyljHAWHznqfnKUQWGzu7TLPrGvWdNAsvQYW+C0xtw==", - "dev": true + "integrity": "sha512-vzLe5NaNMjIE3mcddFVGlAXN1LEWueUsMsOJWaT6wWMJGyljHAWHznqfnKUQWGzu7TLPrGvWdNAsvQYW+C0xtw==" + }, + "node_modules/@types/node-fetch": { + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.6.tgz", + "integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", @@ -1951,6 +1970,17 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", @@ -1993,6 +2023,17 @@ "node": ">= 6.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -2190,6 +2231,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", + "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2205,6 +2257,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -2352,6 +2409,14 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/ci-info": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", @@ -2777,6 +2842,14 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, "node_modules/crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -2979,6 +3052,15 @@ "node": ">=0.3.1" } }, + "node_modules/digest-fetch": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", + "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==", + "dependencies": { + "base-64": "^0.1.0", + "md5": "^2.3.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3785,6 +3867,14 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3993,6 +4083,7 @@ "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "dev": true, "funding": [ { "type": "individual", @@ -4030,6 +4121,11 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -4038,6 +4134,18 @@ "node": ">=0.4.x" } }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -4546,6 +4654,14 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/husky": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", @@ -6023,6 +6139,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/md5/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/mdast-util-definitions": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", @@ -6813,6 +6944,24 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -6826,7 +6975,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", - "dev": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -9763,21 +9911,27 @@ } }, "node_modules/openai": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/openai/-/openai-3.2.1.tgz", - "integrity": "sha512-762C9BNlJPbjjlWZi4WYK9iM2tAVAv0uUp1UmI34vb0CN5T2mjB/qM6RYBmNKMh/dN9fC+bxqPwWJZUTWW052A==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.11.1.tgz", + "integrity": "sha512-GU0HQWbejXuVAQlDjxIE8pohqnjptFDIm32aPlNT1H9ucMz1VJJD0DaTJRQsagNaJ97awWjjVLEG7zCM6sm4SA==", "dependencies": { - "axios": "^0.26.0", - "form-data": "^4.0.0" + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "digest-fetch": "^1.3.0", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" } }, - "node_modules/openai/node_modules/axios": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", - "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", - "dependencies": { - "follow-redirects": "^1.14.8" - } + "node_modules/openai/node_modules/@types/node": { + "version": "18.18.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.4.tgz", + "integrity": "sha512-t3rNFBgJRugIhackit2mVcLfF6IRc0JE4oeizPQL8Zrm8n2WY/0wOdpOPhdtG0V9Q2TlW/axbF1MJ6z+Yj/kKQ==" }, "node_modules/optionator": { "version": "0.9.1", @@ -10148,6 +10302,12 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -11863,8 +12023,7 @@ "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/traverse": { "version": "0.6.7", @@ -12320,17 +12479,23 @@ "dev": true, "peer": true }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" diff --git a/package.json b/package.json index 705b3c7..d169573 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@commitlint/prompt-cli": "^17.4.3", "@semantic-release/changelog": "^6.0.2", "@semantic-release/git": "^10.0.1", + "@types/axios": "^0.14.0", "@types/luxon": "^3.3.1", "@types/node": "^16.11.6", "@types/react": "^18.0.27", @@ -87,7 +88,7 @@ "@agney/react-loading": "^0.1.2", "gpt-tokenizer": "^2.1.1", "luxon": "^3.4.1", - "openai": "^3.2.1", + "openai": "^4.11.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^8.0.7", diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx index 5b730d0..be4ed7f 100644 --- a/src/components/ChatBubble.tsx +++ b/src/components/ChatBubble.tsx @@ -1,10 +1,10 @@ import React from 'react' -import type { CreateChatCompletionResponse } from 'openai' - import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' +import type { OpenAI } from 'openai' + import MemoryManager from './MemoryManager' import { useApp } from '../hooks/useApp' @@ -46,7 +46,7 @@ const ChatBubble = ({ } else if (isBotMessage) { if (conversation?.model.adapter.engine === 'chat') { messageContent = - (message.message as CreateChatCompletionResponse).choices[0].message + (message.message as OpenAI.Chat.ChatCompletion).choices[0].message ?.content ?? '' } else { messageContent = (message.message as OpenAICompletion).choices[0].text @@ -80,7 +80,7 @@ const ChatBubble = ({ {isUserMessage ? settings.userHandle : `${settings.botHandle} (${ - (message.message as CreateChatCompletionResponse).model + (message.message as OpenAI.Chat.ChatCompletion).model })`} ) : ( diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index c20c4dc..9043a2c 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -12,6 +12,7 @@ export interface ChatInputProps { prompt: string onPromptChange: React.Dispatch> onPromptSubmit: (event: React.FormEvent) => void + cancelPromptSubmit: (event: React.FormEvent) => void conversation: Conversation | null preamble?: string onPreambleChange?: React.Dispatch> @@ -24,6 +25,7 @@ const ChatInput = ({ onPromptChange, onPreambleChange, onPromptSubmit, + cancelPromptSubmit, prompt = '', preamble = '', busy = false, @@ -56,12 +58,12 @@ const ChatInput = ({ countAlign="right" /> {}} className="ai-research-assistant__chat__input__send" - disabled={busy} {...containerProps}> {indicatorEl} diff --git a/src/components/ChatWindow.tsx b/src/components/ChatWindow.tsx index f36bfda..8baab9a 100644 --- a/src/components/ChatWindow.tsx +++ b/src/components/ChatWindow.tsx @@ -7,12 +7,18 @@ import { useApp } from '../hooks/useApp' import { useChatScroll } from '../hooks/useChatScroll' import type { Conversation } from '../services/conversation' +import getUnixTimestamp from 'src/utils/getUnixTimestamp' +import { OPEN_AI_CHAT_COMPLETION_OBJECT_TYPE } from '../services/openai/constants' export interface ChatWindowProps { conversation: Conversation + latestMessageContent: string | null } -const ChatWindow = ({ conversation }: ChatWindowProps): React.ReactElement => { +const ChatWindow = ({ + conversation, + latestMessageContent +}: ChatWindowProps): React.ReactElement => { const { plugin } = useApp() const [autoSaving, setAutoSaving] = useState(false) @@ -25,16 +31,19 @@ const ChatWindow = ({ conversation }: ChatWindowProps): React.ReactElement => { // TODO: include toggleScrolling state change const [scrollRef] = useChatScroll(conversation.messages?.length) - const renderConversation = (): React.ReactElement[] => - conversation.messages.length > 0 - ? conversation.messages.map((message, index) => ( - - )) + const renderConversation = (): React.ReactElement[] => { + return conversation.messages.length > 0 + ? conversation.messages.map((message, index) => { + return ( + + ) + }) : [] + } useEffect(() => { setAutoSaving(plugin.autoSaving) @@ -52,6 +61,31 @@ const ChatWindow = ({ conversation }: ChatWindowProps): React.ReactElement => { {renderConversation()} + {latestMessageContent !== null && ( + + )} ) } diff --git a/src/components/SidebarView.tsx b/src/components/SidebarView.tsx index b0192ec..4ecb4ea 100644 --- a/src/components/SidebarView.tsx +++ b/src/components/SidebarView.tsx @@ -1,4 +1,6 @@ import React, { useState, useEffect } from 'react' +import type { OpenAI } from 'openai' +import type { Stream } from 'openai/streaming' import ChatTitle from './ChatTitle' import ChatWindow from './ChatWindow' @@ -8,6 +10,8 @@ import { useApp } from '../hooks/useApp' import type { Conversation } from '../services/conversation' +import { OPEN_AI_CHAT_COMPLETION_OBJECT_TYPE } from '../services/openai/constants' + export interface ChatFormProps { onChatUpdate?: () => Promise } @@ -23,6 +27,12 @@ const SidebarView = ({ onChatUpdate }: ChatFormProps): React.ReactElement => { const [loading, setLoading] = useState(false) const [conversation, setConversation] = useState(null) + const cancelPromptController = new AbortController() + + const [latestMessageContent, setLatestMessageContent] = useState< + string | null + >(null) + const handleSubmit = (event: React.FormEvent): void => { event.preventDefault() @@ -31,7 +41,61 @@ const SidebarView = ({ onChatUpdate }: ChatFormProps): React.ReactElement => { setPrompt('') chat - ?.send(prompt) + ?.send(prompt, { signal: cancelPromptController.signal }) + .then(async (responseStream: Stream) => { + let accumulatedMessage = '' + + try { + for await (const chunk of responseStream) { + const { created, id, model, choices } = chunk + + const delta = choices?.[0]?.delta + + const content = delta?.content + + // Update the UI with the new content + if (typeof content !== 'undefined') { + accumulatedMessage += `${content as string}` + + setLatestMessageContent( + (oldContent) => + `${ + oldContent !== null + ? `${oldContent}${content as string}` + : (content as string) + }` + ) + } + + if (choices?.[0]?.finish_reason === 'stop') { + const message = { + id, + model, + created, + object: OPEN_AI_CHAT_COMPLETION_OBJECT_TYPE, + choices: [ + { + message: { + role: 'assistant', + content: accumulatedMessage + } + } + ] + } + + if (conversation !== null) { + const newMessage = conversation.addMessage(message) + + if (typeof newMessage !== 'undefined') { + setLatestMessageContent((oldContent) => null) + } + } + } + } + } catch (error) { + logger.error(error) + } + }) .catch((error) => { logger.error(error) }) @@ -46,6 +110,12 @@ const SidebarView = ({ onChatUpdate }: ChatFormProps): React.ReactElement => { }) } + const cancelPromptSubmit = (event: React.FormEvent): void => { + logger.debug(`Cancelling streaming response from ${conversation?.model?.adapter?.name as string}...`) + + cancelPromptController.abort() + } + useEffect(() => { if ( typeof conversation !== 'undefined' && @@ -83,11 +153,20 @@ const SidebarView = ({ onChatUpdate }: ChatFormProps): React.ReactElement => { } }, [conversation?.id]) + useEffect(() => { + return () => { + cancelPromptController.abort() + } + }, []) + return (
{conversation !== null ? ( - + ) : ( <> )} @@ -95,6 +174,7 @@ const SidebarView = ({ onChatUpdate }: ChatFormProps): React.ReactElement => { prompt={prompt} onPromptChange={setPrompt} onPromptSubmit={handleSubmit} + cancelPromptSubmit={cancelPromptSubmit} preamble={preamble} onPreambleChange={setPreamble} conversation={conversation} diff --git a/src/services/chat.ts b/src/services/chat.ts index cc005dd..08caee6 100644 --- a/src/services/chat.ts +++ b/src/services/chat.ts @@ -1,6 +1,7 @@ // combine the conversation service and openai service into an integrated chat service // make sure to udpate the conversation context when user sends a message - +import type { OpenAI } from 'openai' +import type { Stream } from 'openai/streaming' import Conversations, { type Conversation } from './conversation' import { openAICompletion } from './openai' @@ -39,7 +40,10 @@ class Chat { return this.conversations.getConversation(this.currentConversationId) } - async send(prompt: string): Promise { + async send( + prompt: string, + { signal }: Partial<{ signal?: AbortSignal }> + ): Promise | unknown> { const conversation = this.currentConversation() if ( @@ -56,7 +60,7 @@ class Chat { switch (this.model.adapter.name) { case 'openai': try { - const response = await openAICompletion( + const responseStream = await openAICompletion( { input: this.model.adapter.engine === 'chat' @@ -69,15 +73,15 @@ class Chat { presencePenalty: conversation.settings.presencePenalty, frequencyPenalty: conversation.settings.frequencyPenalty }, + { signal }, this.currentConversation()?.settings, this.logger ) - conversation.addMessage(response) + return responseStream } catch (error) { - conversation.addMessage(error.message) - console.error(error) + conversation.addMessage(error.message) } break diff --git a/src/services/conversation.ts b/src/services/conversation.ts index 286fe8b..ca6f7ec 100644 --- a/src/services/conversation.ts +++ b/src/services/conversation.ts @@ -1,9 +1,10 @@ import { v4 as uuidv4 } from 'uuid' -import type { CreateChatCompletionResponse } from 'openai' +import type OpenAI from 'openai' import formatInput from '../utils/formatInput' -import getUnixTimestamp from 'src/utils/getUnixTimestamp' +import getUnixTimestamp from '../utils/getUnixTimestamp' +import formatChat from './openai/utils/formatChat' import { PLUGIN_SETTINGS, @@ -27,7 +28,6 @@ import type { PluginSettings, MemoryState } from '../types' -import formatChat from './openai/utils/formatChat' export interface ConversationSettings extends PluginSettings { temperature?: number @@ -163,7 +163,7 @@ export class Conversation { ) { return this.formatMessagePart( `${this.settings.botHandle}\n${ - ((message.message as CreateChatCompletionResponse).choices[0] + ((message.message as OpenAI.Chat.ChatCompletion).choices[0] .message?.content as string) ?? '' }`, true, @@ -233,7 +233,7 @@ export class Conversation { } else if (currentMessage.object === OPEN_AI_CHAT_COMPLETION_OBJECT_TYPE) { return formatInput( `${this.settings.botHandle}\n${ - ((currentMessage as CreateChatCompletionResponse).choices[0].message + ((currentMessage as OpenAI.Chat.ChatCompletion).choices[0].message ?.content as string) ?? '' }` ) @@ -248,6 +248,10 @@ export class Conversation { } } + getMessages(): ConversationMessage[] { + return this.messages + } + getConversationMessages(): ConversationMessage[] { // TODO: summarize the prompt (or maybe the response from OpenAI) and add it to the context // instead of just appending the message @@ -324,7 +328,6 @@ export class Conversation { ;(message as UserPrompt).fullText = fullText } } else if ( - conversationMessage.message.object !== OPEN_AI_COMPLETION_OBJECT_TYPE && conversationMessage.message.object !== OPEN_AI_CHAT_COMPLETION_OBJECT_TYPE ) { conversationMessage.memoryState = 'forgotten' as MemoryState diff --git a/src/services/openai/index.ts b/src/services/openai/index.ts index 771b91b..eb8a13b 100644 --- a/src/services/openai/index.ts +++ b/src/services/openai/index.ts @@ -1,25 +1,17 @@ -import { requestUrl as obsidianRequest, type RequestUrlParam } from 'obsidian' - -import { - Configuration, - OpenAIApi, - type CreateChatCompletionResponse -} from 'openai' +import OpenAI from 'openai' +import type { Stream } from 'openai/streaming' import formatChat from './utils/formatChat' -import formatInput from '../../utils/formatInput' - import { OPEN_AI_DEFAULT_MODEL, OPEN_AI_RESPONSE_TOKENS, - OPEN_AI_BASE_URL, OPEN_AI_DEFAULT_TEMPERATURE } from './constants' import { PLUGIN_SETTINGS } from '../../constants' -import type { OpenAICompletionRequest, OpenAICompletion } from './types' +import type { OpenAICompletionRequest } from './types' import type { Conversation } from '../conversation' import type { PluginSettings } from '../../types' import type Logger from '../logger' @@ -39,106 +31,50 @@ export const openAICompletion = async ( maxTokens = OPEN_AI_RESPONSE_TOKENS, topP = 1, frequencyPenalty = 0, - presencePenalty = 0, - stream = false + presencePenalty = 0 }: OpenAICompletionRequest, + { signal }: { signal?: AbortSignal }, settings: PluginSettings = PLUGIN_SETTINGS, logger: Logger -): Promise => { - const { userHandle, botHandle, openAiApiKey } = settings - - let apiKey = openAiApiKey +): Promise> => { + let { openAiApiKey: apiKey } = settings if (safeStorage.isEncryptionAvailable() === true) { apiKey = await safeStorage.decryptString(Buffer.from(apiKey)) } - // using the openai JavaScript library since the release of the new ChatGPT model - if (model.adapter?.engine === 'chat') { - try { - const config = new Configuration({ - apiKey - }) + try { + const openai = new OpenAI({ + apiKey, + dangerouslyAllowBrowser: true + }) - const openai = new OpenAIApi(config) + const messages = formatChat(input as Conversation) - const messages = formatChat(input as Conversation) - - const completion = await openai.createChatCompletion({ + const stream = await openai.chat.completions.create( + { model: model.model, - messages - }) - - return completion.data - } catch (error) { - if (typeof error?.response !== 'undefined') { - logger.error(error.response.status, error.response.data) - } else { - logger.error(error.message) + messages, + stream: true, + temperature, + max_tokens: maxTokens, + top_p: topP, + frequency_penalty: frequencyPenalty, + presence_penalty: presencePenalty + }, + { + signal } - - throw error - } - } else { - // TODO: remove this now that non-chat models are being deprecated - const requestUrl = new URL('/v1/completions', OPEN_AI_BASE_URL) - - const requestHeaders = { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - Accept: 'application/json' - } - - const prompt = formatInput(input as string) - - const stopWords: string[] = [] - - if (typeof model.stopWord !== 'undefined' && model.stopWord !== '') { - stopWords.push(model.stopWord) + ) + + return stream + } catch (error) { + if (typeof error?.response !== 'undefined') { + logger.error(error.response.status, error.response.data) + } else { + logger.error(error.message) } - if (typeof userHandle !== 'undefined' && userHandle !== '') { - stopWords.push(userHandle) - } - - if (typeof botHandle !== 'undefined' && botHandle !== '') { - stopWords.push(botHandle) - } - - const requestBody = { - prompt, - model: model.model, - stream, - temperature, - max_tokens: maxTokens, - stop: stopWords, - top_p: topP, - frequency_penalty: frequencyPenalty, - presence_penalty: presencePenalty - } - - const request: RequestUrlParam = { - url: requestUrl.toString(), - headers: requestHeaders, - method: 'POST', - body: JSON.stringify(requestBody), - throw: false - } - - try { - const response = await obsidianRequest(request) - - if (response.status < 400) { - return response.json - } else { - logger.error(response) - - throw new Error(response.text) - } - } catch (error) { - console.error(error) - - throw error - } + throw error } } diff --git a/src/types.ts b/src/types.ts index bba098a..e75c96c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,4 @@ -import type { - CreateChatCompletionResponse, - ChatCompletionRequestMessage -} from 'openai' +import type OpenAI from 'openai' import type { ModelDefinition, @@ -55,7 +52,7 @@ export interface UserPrompt { context?: string fullText?: string model?: ModelDefinition - messages?: ChatCompletionRequestMessage[] + messages?: Array> } export interface SystemMessage { @@ -68,8 +65,7 @@ export type ConversationMessageType = | UserPrompt | OpenAICompletion | SystemMessage - | CreateChatCompletionResponse - + | OpenAI.Chat.ChatCompletion export interface ConversationMessage { id: string memoryState: MemoryState