forked from React-Group/interstellar_ai
		
	Backend changes #4
					 16 changed files with 173 additions and 125 deletions
				
			
		
							
								
								
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -41,3 +41,4 @@ venv/ | ||||||
| 
 | 
 | ||||||
| key.pem | key.pem | ||||||
| cert.pem | cert.pem | ||||||
|  | api_key.txt | ||||||
|  |  | ||||||
|  | @ -27,30 +27,30 @@ const handleCopyClick = () => { | ||||||
| const InputOutputBackend: React.FC = () => { | const InputOutputBackend: React.FC = () => { | ||||||
|     const [accessToken, setAccessToken] = useState("") |     const [accessToken, setAccessToken] = useState("") | ||||||
|     const workerRef = useRef<Worker | null>(null) |     const workerRef = useRef<Worker | null>(null) | ||||||
|  |     type Message = { | ||||||
|  |       role: string | ||||||
|  |       content: string | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     const handleSendClick = (message:string) => { |     const handleSendClick = (message:string) => { | ||||||
|         var system = "You give really short answers (maximum of 30 sentences). The following is the chat history." |       var system:Message = {role:"system" ,content:"You are a helpful assistant."} | ||||||
|         for (let index = 0; index < messages.length; index++) { |  | ||||||
|             system += messages[index] + " "; |  | ||||||
|         }; |  | ||||||
| 
 | 
 | ||||||
|         HandlePostRequest(message, "phi3.5", system) |       addMessage("user", message); | ||||||
|  |       console.log("added User Message") | ||||||
|        |        | ||||||
|         addMessage('User: ' + message); |       HandlePostRequest([...messages, { role: "user", content: message }], "phi3.5", system); | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const [messages, setMessages] = useState([ |     const [messages, setMessages] = useState([ | ||||||
|         'User: Hello!', |       { role:"assistant", content:'Hello. I\'m Your AI Virtual Assistant' }  | ||||||
|         'AI: Hi there!', |  | ||||||
|         'User: How are you?', |  | ||||||
|         'AI: I’m good, thank you!' |  | ||||||
|     ]); |     ]); | ||||||
| 
 | 
 | ||||||
|     const addMessage = (message: string) => { |     const addMessage = (role:string ,content: string) => { | ||||||
|         setMessages((prevMessages) => [...prevMessages, message]); |         setMessages((prevMessages) => [...prevMessages, {role,content}]); | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         workerRef.current = new Worker(new URL("./ProcessAPI.js", import.meta.url)) |         workerRef.current = new Worker(new URL("./ProcessAPI.js", import.meta.url)) | ||||||
|         workerRef.current.postMessage({}) |         workerRef.current.postMessage({}) | ||||||
|  | @ -65,20 +65,11 @@ const InputOutputBackend: React.FC = () => { | ||||||
|         } |         } | ||||||
|     },[]) |     },[]) | ||||||
|          |          | ||||||
|     const HandleGetRequest = (message: string, ai_model: string, system_prompt: string) => { |         const HandlePostRequest = (messages: Message[], ai_model: string, system_prompt: Message) => { | ||||||
|           if (workerRef.current) { |           if (workerRef.current) { | ||||||
|             workerRef.current.postMessage({ functionName: "getResponse", access_token: accessToken, message: message, ai_model: ai_model, system_prompt: system_prompt }) |             workerRef.current.postMessage({ functionName: "postRequest", access_token: accessToken, messages: messages, ai_model: ai_model, system_prompt: system_prompt }) | ||||||
|             workerRef.current.onmessage = (e) => { |             workerRef.current.onmessage = (e) => { | ||||||
|                 addMessage("AI: " + e.data) |               addMessage("assistant",e.data) | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const HandlePostRequest = (message: string, ai_model: string, system_prompt: string) => { |  | ||||||
|         if (workerRef.current) { |  | ||||||
|             workerRef.current.postMessage({ functionName: "postRequest", access_token: accessToken, message: message, ai_model: ai_model, system_prompt: system_prompt }) |  | ||||||
|             workerRef.current.onmessage = (e) => { |  | ||||||
|                 HandleGetRequest(message,ai_model,system_prompt) |  | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  | @ -97,7 +88,7 @@ const InputOutputBackend: React.FC = () => { | ||||||
|         onMicClick={handleMicClick} |         onMicClick={handleMicClick} | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   ); |     ) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default InputOutputBackend | export default InputOutputBackend | ||||||
|  |  | ||||||
|  | @ -1,16 +1,33 @@ | ||||||
| import axios from 'axios' | import axios from 'axios' | ||||||
|  | import { type } from 'os'; | ||||||
| 
 | 
 | ||||||
| onmessage = function (e) { | onmessage = function (e) { | ||||||
|     const { functionName = "getAccess", access_token = "", message = "", ai_model = "phi3.5", system_prompt = "You are a helpful assistant" } = e.data |     const { functionName = "getAccess", access_token = "", messages = [], ai_model = "phi3.5", system_prompt = {role:"system" ,content: "You are a helpful assistant that gives short answers"}} = e.data | ||||||
|     const data = { |      | ||||||
|         "ai_model": ai_model, |     let data = { | ||||||
|         "message": message, |         ai_model: ai_model, | ||||||
|         "system_prompt": system_prompt, |         messages: messages, | ||||||
|         "access_token": access_token |         access_token: access_token | ||||||
|     }; |     }; | ||||||
|  | 
 | ||||||
|  |     const getResponse = () => { | ||||||
|  |         messageComplete:boolean = false | ||||||
|  |         while(!messageComplete) | ||||||
|  |         axios.get('https://localhost:5000/interstellar/api/ai_get?access_token=' + access_token) | ||||||
|  |                 .then(Response => { | ||||||
|  |                     postMessage(Response.data.response) | ||||||
|  |                     if (Response.data.status == 200) { | ||||||
|  |                         messageComplete = true | ||||||
|  |                     } | ||||||
|  |                 }).catch(error => { | ||||||
|  |                     console.error("Error with GET response request:", error) | ||||||
|  |                 }) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     switch (functionName) { |     switch (functionName) { | ||||||
|         case "getAccess": |         case "getAccess": | ||||||
|             axios.get('https://127.0.0.1:5000/interstellar/api/ai_create') |             console.log("getting access...") | ||||||
|  |             axios.get('https://localhost:5000/interstellar/api/ai_create') | ||||||
|             .then(Response => { |             .then(Response => { | ||||||
|                 postMessage(Response.data.access_token) |                 postMessage(Response.data.access_token) | ||||||
|             }).catch(error => { |             }).catch(error => { | ||||||
|  | @ -18,21 +35,16 @@ onmessage = function (e) { | ||||||
|             }) |             }) | ||||||
|             break |             break | ||||||
|         case "postRequest": |         case "postRequest": | ||||||
|             axios.post('https://127.0.0.1:5000/interstellar/api/ai_send', data) |             messages.unshift(system_prompt) | ||||||
|  |             console.log("sending...") | ||||||
|  |             console.log(messages) | ||||||
|  |             axios.post('https://localhost:5000/interstellar/api/ai_send', data) | ||||||
|             .then(Response => { |             .then(Response => { | ||||||
|                     postMessage(Response.data) |                 getResponse() | ||||||
|             }).catch(error => { |             }).catch(error => { | ||||||
|                 console.error("Error:", error) |                 console.error("Error:", error) | ||||||
|             }) |             }) | ||||||
|             break |             break | ||||||
|         case "getResponse": |  | ||||||
|             axios.get('https://127.0.0.1:5000/interstellar/api/ai_get?access_token=' + access_token) |  | ||||||
|                 .then(Response => { |  | ||||||
|                     postMessage(Response.data.response) |  | ||||||
|                 }).catch(error => { |  | ||||||
|                     console.error("Error with GET response request:", error) |  | ||||||
|                 }) |  | ||||||
|             break |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,7 +1,12 @@ | ||||||
| import React, { ForwardedRef, useEffect, useRef } from 'react'; | import React, { ForwardedRef, useEffect, useRef } from 'react'; | ||||||
| 
 | 
 | ||||||
|  | type Message = { | ||||||
|  |   role: string | ||||||
|  |   content: string | ||||||
|  | } | ||||||
|  | 
 | ||||||
| interface ConversationProps { | interface ConversationProps { | ||||||
|   messages: string[]; |   messages: Message[]; | ||||||
|   onResendClick: () => void; |   onResendClick: () => void; | ||||||
|   onEditClick: () => void; |   onEditClick: () => void; | ||||||
|   onCopyClick: () => void; |   onCopyClick: () => void; | ||||||
|  | @ -22,14 +27,16 @@ const ConversationFrontend = React.forwardRef<HTMLDivElement, ConversationProps> | ||||||
|       <div className="output"> |       <div className="output"> | ||||||
|         <div className="conversation resize" id="conversation" ref={ref}> |         <div className="conversation resize" id="conversation" ref={ref}> | ||||||
|           {messages.map((message, index) => { |           {messages.map((message, index) => { | ||||||
|             const isUserMessage = message.startsWith('User:'); |             let isUserMessage | ||||||
|             console.log(messages) |             if (message.role == "user") { | ||||||
|  |               isUserMessage = message | ||||||
|  |             } | ||||||
|             return ( |             return ( | ||||||
|               <div |               <div | ||||||
|                 key={index} |                 key={index} | ||||||
|                 className={isUserMessage ? 'user-message' : 'ai-message'} |                 className={isUserMessage ? 'user-message' : 'ai-message'} | ||||||
|               > |               > | ||||||
|                 <p> {message}</p> |                 <p> {message.content}</p> | ||||||
|               </div> |               </div> | ||||||
|             ); |             ); | ||||||
|           })} |           })} | ||||||
|  |  | ||||||
|  | @ -14,19 +14,28 @@ const InputFrontend = React.forwardRef<HTMLDivElement, InputProps>( | ||||||
|       setInputValue(e.target.value); |       setInputValue(e.target.value); | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|  |     const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { | ||||||
|  |       if (event.key === 'Enter') { | ||||||
|  |         onSendClick(inputValue); // Call the function passed via props
 | ||||||
|  |         setInputValue(''); // Optionally clear input after submission
 | ||||||
|  |         event.preventDefault(); // Prevent default action (e.g., form submission)
 | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div className="input" id="inputForm"> |       <div className="input" id="inputForm" ref={ref}> | ||||||
|         <input |         <input | ||||||
|           type="text" |           type="text" | ||||||
|           name="user_message" |           name="user_message" | ||||||
|           placeholder="Type your message here..." |           placeholder="Type your message here..." | ||||||
|           value={inputValue} |           value={inputValue} | ||||||
|           onChange={handleInputChange} |           onChange={handleInputChange} | ||||||
|  |           onKeyDown={handleKeyDown} | ||||||
|         /> |         /> | ||||||
|         <button type="submit" onClick={() => onSendClick(inputValue)}> |         <button type="button" onClick={() => onSendClick(inputValue)}> | ||||||
|           <img src="/img/send.svg" alt="send" /> |           <img src="/img/send.svg" alt="send" /> | ||||||
|         </button> |         </button> | ||||||
|         <button type="submit" onClick={onMicClick}> |         <button type="button" onClick={onMicClick}> | ||||||
|           <img src="/img/microphone.svg" alt="microphone" /> |           <img src="/img/microphone.svg" alt="microphone" /> | ||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
							
								
								
									
										3
									
								
								py/.idea/.gitignore
									
										
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								py/.idea/.gitignore
									
										
									
										generated
									
									
										vendored
									
									
								
							|  | @ -1,3 +0,0 @@ | ||||||
| # Default ignored files |  | ||||||
| /shelf/ |  | ||||||
| /workspace.xml |  | ||||||
|  | @ -1,6 +0,0 @@ | ||||||
| <component name="InspectionProjectProfileManager"> |  | ||||||
|   <settings> |  | ||||||
|     <option name="USE_PROJECT_PROFILE" value="false" /> |  | ||||||
|     <version value="1.0" /> |  | ||||||
|   </settings> |  | ||||||
| </component> |  | ||||||
							
								
								
									
										10
									
								
								py/.idea/misc.xml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								py/.idea/misc.xml
									
										
									
										generated
									
									
									
								
							|  | @ -1,10 +0,0 @@ | ||||||
| <?xml version="1.0" encoding="UTF-8"?> |  | ||||||
| <project version="4"> |  | ||||||
|   <component name="Black"> |  | ||||||
|     <option name="sdkName" value="Python 3.12" /> |  | ||||||
|   </component> |  | ||||||
|   <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12" project-jdk-type="Python SDK" /> |  | ||||||
|   <component name="PyCharmProfessionalAdvertiser"> |  | ||||||
|     <option name="shown" value="true" /> |  | ||||||
|   </component> |  | ||||||
| </project> |  | ||||||
							
								
								
									
										8
									
								
								py/.idea/modules.xml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								py/.idea/modules.xml
									
										
									
										generated
									
									
									
								
							|  | @ -1,8 +0,0 @@ | ||||||
| <?xml version="1.0" encoding="UTF-8"?> |  | ||||||
| <project version="4"> |  | ||||||
|   <component name="ProjectModuleManager"> |  | ||||||
|     <modules> |  | ||||||
|       <module fileurl="file://$PROJECT_DIR$/.idea/py.iml" filepath="$PROJECT_DIR$/.idea/py.iml" /> |  | ||||||
|     </modules> |  | ||||||
|   </component> |  | ||||||
| </project> |  | ||||||
							
								
								
									
										13
									
								
								py/.idea/py.iml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								py/.idea/py.iml
									
										
									
										generated
									
									
									
								
							|  | @ -1,13 +0,0 @@ | ||||||
| <?xml version="1.0" encoding="UTF-8"?> |  | ||||||
| <module type="PYTHON_MODULE" version="4"> |  | ||||||
|   <component name="NewModuleRootManager"> |  | ||||||
|     <content url="file://$MODULE_DIR$"> |  | ||||||
|       <excludeFolder url="file://$MODULE_DIR$/venv" /> |  | ||||||
|     </content> |  | ||||||
|     <orderEntry type="inheritedJdk" /> |  | ||||||
|     <orderEntry type="sourceFolder" forTests="false" /> |  | ||||||
|   </component> |  | ||||||
|   <component name="PackageRequirementsSettings"> |  | ||||||
|     <option name="versionSpecifier" value="Greater or equal (>=x.y.z)" /> |  | ||||||
|   </component> |  | ||||||
| </module> |  | ||||||
							
								
								
									
										6
									
								
								py/.idea/vcs.xml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								py/.idea/vcs.xml
									
										
									
										generated
									
									
									
								
							|  | @ -1,6 +0,0 @@ | ||||||
| <?xml version="1.0" encoding="UTF-8"?> |  | ||||||
| <project version="4"> |  | ||||||
|   <component name="VcsDirectoryMappings"> |  | ||||||
|     <mapping directory="$PROJECT_DIR$/.." vcs="Git" /> |  | ||||||
|   </component> |  | ||||||
| </project> |  | ||||||
							
								
								
									
										
											BIN
										
									
								
								py/__pycache__/ai.cpython-312.pyc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								py/__pycache__/ai.cpython-312.pyc
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										39
									
								
								py/ai.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								py/ai.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,39 @@ | ||||||
|  | from mistralai import Mistral | ||||||
|  | import ollama | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class AI: | ||||||
|  |     @staticmethod | ||||||
|  |     def process_local(model, messages, return_class, access_token): | ||||||
|  |         stream = ollama.chat( | ||||||
|  |             model=model, | ||||||
|  |             messages=messages, | ||||||
|  |             stream=True, | ||||||
|  |             options={"temperature": 0.5}, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         for i in messages: | ||||||
|  |             print(i) | ||||||
|  | 
 | ||||||
|  |         return_class.ai_response[access_token] = "" | ||||||
|  | 
 | ||||||
|  |         for chunk in stream: | ||||||
|  |             print(chunk['message']['content']) | ||||||
|  |             return_class.ai_response[access_token] += chunk['message']['content'] | ||||||
|  | 
 | ||||||
|  |     @staticmethod | ||||||
|  |     def process_mistralai(model, messages, return_class, access_token): | ||||||
|  |         with open("api_key.txt", 'r') as f: | ||||||
|  |             api_key = f.read().strip() | ||||||
|  | 
 | ||||||
|  |         client = Mistral(api_key=api_key) | ||||||
|  | 
 | ||||||
|  |         stream_response = client.chat.stream( | ||||||
|  |             model=model, | ||||||
|  |             messages=messages | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         return_class.ai_response[access_token] = "" | ||||||
|  | 
 | ||||||
|  |         for chunk in stream_response: | ||||||
|  |             return_class.ai_response[access_token] += chunk.data.choices[0].delta.content | ||||||
							
								
								
									
										27
									
								
								py/api.py
									
										
									
									
									
								
							
							
						
						
									
										27
									
								
								py/api.py
									
										
									
									
									
								
							|  | @ -1,22 +1,8 @@ | ||||||
| from flask import Flask, request, jsonify | from flask import Flask, request, jsonify | ||||||
| from flask_cors import CORS | from flask_cors import CORS | ||||||
| import ollama |  | ||||||
| import secrets | import secrets | ||||||
| 
 | from ai import AI | ||||||
| 
 | from db import DB | ||||||
| class AI: |  | ||||||
|     @staticmethod |  | ||||||
|     def process_local(model, messages, return_class, access_token): |  | ||||||
|         stream = ollama.chat( |  | ||||||
|             model=model, |  | ||||||
|             messages=messages, |  | ||||||
|             stream=True, |  | ||||||
|             options={"temperature": 0}, |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         for chunk in stream: |  | ||||||
|             print(chunk['message']['content']) |  | ||||||
|             return_class.ai_response[access_token] += chunk['message']['content'] |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class API: | class API: | ||||||
|  | @ -24,6 +10,7 @@ class API: | ||||||
|         self.app = Flask(__name__) |         self.app = Flask(__name__) | ||||||
|         self.ai_response = {} |         self.ai_response = {} | ||||||
|         self.ai = AI() |         self.ai = AI() | ||||||
|  |         self.db = DB() | ||||||
|         CORS(self.app) |         CORS(self.app) | ||||||
| 
 | 
 | ||||||
|     def run(self): |     def run(self): | ||||||
|  | @ -51,6 +38,13 @@ class API: | ||||||
|                 return jsonify({'status': 401, 'error': 'Invalid access token'}) |                 return jsonify({'status': 401, 'error': 'Invalid access token'}) | ||||||
|             return jsonify({'status': 200, 'response': self.ai_response[data]}) |             return jsonify({'status': 200, 'response': self.ai_response[data]}) | ||||||
| 
 | 
 | ||||||
|  |         @self.app.route('/interstellar/api/db', methods=['POST']) | ||||||
|  |         def db_manipulate(): | ||||||
|  |             action = request.args.get('action') | ||||||
|  |             if action == "create_account": | ||||||
|  |                 print("ahh") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|         ssl_context = ('cert.pem', 'key.pem') |         ssl_context = ('cert.pem', 'key.pem') | ||||||
|         self.app.run(debug=True, host='0.0.0.0', port=5000, ssl_context=ssl_context) |         self.app.run(debug=True, host='0.0.0.0', port=5000, ssl_context=ssl_context) | ||||||
| 
 | 
 | ||||||
|  | @ -58,4 +52,3 @@ class API: | ||||||
| if __name__ == '__main__': | if __name__ == '__main__': | ||||||
|     api = API() |     api = API() | ||||||
|     api.run() |     api.run() | ||||||
| 
 |  | ||||||
|  |  | ||||||
							
								
								
									
										41
									
								
								py/db.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								py/db.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,41 @@ | ||||||
|  | import json | ||||||
|  | import hashlib | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class DB: | ||||||
|  |     def __init__(self): | ||||||
|  |         self.database = {} | ||||||
|  | 
 | ||||||
|  |     def _hash_password(self, password: str) -> str: | ||||||
|  |         salt = "your_secret_salt" | ||||||
|  |         hashed_password = hashlib.sha256((password + salt).encode()).hexdigest() | ||||||
|  |         return hashed_password | ||||||
|  | 
 | ||||||
|  |     def add_user(self, username: str, password: str) -> None: | ||||||
|  |         hashed_password = self._hash_password(password) | ||||||
|  |         user_data = {"hashed_password": hashed_password} | ||||||
|  |         self.database[username] = user_data | ||||||
|  | 
 | ||||||
|  |     def update_password(self, username: str, old_password: str, new_password: str) -> bool: | ||||||
|  |         if not self.check_credentials(username, old_password): | ||||||
|  |             return False | ||||||
|  | 
 | ||||||
|  |         hashed_new_password = self._hash_password(new_password) | ||||||
|  |         self.database[username].update({"hashed_password": hashed_new_password}) | ||||||
|  |         return True | ||||||
|  | 
 | ||||||
|  |     def check_credentials(self, username: str, password: str) -> bool: | ||||||
|  |         if username not in self.database: | ||||||
|  |             return False | ||||||
|  | 
 | ||||||
|  |         stored_hashed_password = self.database[username]["hashed_password"] | ||||||
|  |         entered_hashed_password = self._hash_password(password) | ||||||
|  |         return stored_hashed_password == entered_hashed_password | ||||||
|  | 
 | ||||||
|  |     def get_additional_info(self, username: str, password: str) -> dict | None: | ||||||
|  |         if not self.check_credentials(username, password): | ||||||
|  |             return None | ||||||
|  | 
 | ||||||
|  |         send_back = self.database[username] | ||||||
|  |         del send_back['hashed_password'] | ||||||
|  |         return send_back | ||||||
|  | @ -1,3 +1,4 @@ | ||||||
| flask | flask | ||||||
| flask-cors | flask-cors | ||||||
| ollama | ollama | ||||||
|  | mistralai | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue