diff --git a/src/ai-assistant/activityTracker.ts b/src/ai-assistant/activityTracker.ts new file mode 100644 index 0000000..a49a02e --- /dev/null +++ b/src/ai-assistant/activityTracker.ts @@ -0,0 +1,13 @@ +const editorActivityMap: Map = new Map(); + +export const updateEditorActivity = (editorType: 'markdown' | 'concerto' | 'json') => { + editorActivityMap.set(editorType, Date.now()); +}; + +export const getLastActivity = (editorType: 'markdown' | 'concerto' | 'json'): number => { + return editorActivityMap.get(editorType) || 0; +}; + +export const clearActivity = (editorType: 'markdown' | 'concerto' | 'json') => { + editorActivityMap.delete(editorType); +}; diff --git a/src/ai-assistant/autocompletion.ts b/src/ai-assistant/autocompletion.ts new file mode 100644 index 0000000..895cb43 --- /dev/null +++ b/src/ai-assistant/autocompletion.ts @@ -0,0 +1,155 @@ +import * as monaco from 'monaco-editor'; +import { sendMessage } from './chatRelay'; +import useAppStore from '../store/store'; +import { editorsContent } from '../types/components/AIAssistant.types'; +import { getLastActivity } from './activityTracker'; + +let lastRequestTime: number = 0; +let isProcessing: boolean = false; + +export const registerAutocompletion = ( + language: 'concerto' | 'markdown' | 'json', + monacoInstance: typeof monaco +) => { + try { + const provider = monacoInstance.languages.registerInlineCompletionsProvider(language, { + provideInlineCompletions: async (model, position, _context, token) => { + if (token.isCancellationRequested) { + return { items: [] }; + } + + const { aiConfig } = useAppStore.getState(); + const enableInlineSuggestions = aiConfig?.enableInlineSuggestions !== false; + if (!enableInlineSuggestions) { + return { items: [] }; + } + + const initialTime = Date.now(); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const lastActivity = getLastActivity(language); + if (lastActivity > initialTime) { + return { items: [] }; + } + + if (token.isCancellationRequested) { + return { items: [] }; + } + + const currentTime = Date.now(); + if (isProcessing || (currentTime - lastRequestTime < 2000)) { + return { items: [] }; + } + + isProcessing = true; + lastRequestTime = currentTime; + + try { + const result = await getInlineCompletions(model, position, language, monacoInstance); + return result; + } finally { + isProcessing = false; + } + }, + freeInlineCompletions: (_completions) => { + }, + }); + return provider; + } catch (error) { + console.error('Error registering completion provider:', error); + return null; + } +}; + +const getInlineCompletions = async ( + model: monaco.editor.ITextModel, + position: monaco.Position, + language: 'concerto' | 'markdown' | 'json', + monacoInstance: typeof monaco +): Promise<{ items: monaco.languages.InlineCompletion[] }> => { + const { aiConfig } = useAppStore.getState(); + + if (!aiConfig) { + return { items: [] }; + } + + const lineContent = model.getLineContent(position.lineNumber); + const textBeforeCursor = lineContent.substring(0, position.column - 1); + const textAfterCursor = lineContent.substring(position.column - 1); + + if (!textBeforeCursor.trim() || textBeforeCursor.length < 2) { + return { + items: [] + }; + } + + const startLine = Math.max(1, position.lineNumber - 20); + const endLine = Math.min(model.getLineCount(), position.lineNumber + 20); + const contextLines: string[] = []; + + for (let i = startLine; i <= endLine; i++) { + if (i === position.lineNumber) { + const fullCurrentLine = textBeforeCursor + '' + textAfterCursor; + contextLines.push(fullCurrentLine); + } else if (i < position.lineNumber) { + contextLines.push(model.getLineContent(i)); + } else { + contextLines.push(model.getLineContent(i)); + } + } + + const contextText = contextLines.join('\n'); + + const editorsContent: editorsContent = { + editorTemplateMark: useAppStore.getState().editorValue, + editorModelCto: useAppStore.getState().editorModelCto, + editorAgreementData: useAppStore.getState().editorAgreementData, + }; + const prompt = `Current context:\n${contextText}`; + + try { + let completion = ''; + + await sendMessage( + prompt, + 'inlineSuggestion', + editorsContent, + false, + language, + (chunk) => { + completion += chunk; + }, + (error) => { + console.error('Autocompletion error:', error); + } + ); + + completion = completion.trim(); + + completion = completion.replace(/^```[\s\S]*?\n/, '').replace(/\n```$/, ''); + completion = completion.replace(/^`/, '').replace(/`$/, ''); + + if (!completion || completion.length < 2) { + return { items: [] }; + } + + const inlineCompletion: monaco.languages.InlineCompletion = { + insertText: completion, + range: new monacoInstance.Range( + position.lineNumber, + position.column, + position.lineNumber, + position.column + ), + filterText: textBeforeCursor, + }; + + return { + items: [inlineCompletion], + }; + } catch (error) { + console.error('Error getting AI completion:', error); + return { items: [] }; + } +}; diff --git a/src/ai-assistant/chatRelay.ts b/src/ai-assistant/chatRelay.ts index e28fb80..e73b583 100644 --- a/src/ai-assistant/chatRelay.ts +++ b/src/ai-assistant/chatRelay.ts @@ -18,7 +18,8 @@ export const loadConfigFromLocalStorage = () => { const savedIncludeData = localStorage.getItem('aiIncludeData') === 'true'; const savedShowFullPrompt = localStorage.getItem('aiShowFullPrompt') === 'true'; - const savedEnableCodeSelectionMenu = localStorage.getItem('aiEnableCodeSelectionMenu') === 'true'; + const savedEnableCodeSelectionMenu = localStorage.getItem('aiEnableCodeSelectionMenu') !== 'false'; + const savedEnableInlineSuggestions = localStorage.getItem('aiEnableInlineSuggestions') !== 'false'; if (savedProvider && savedModel && savedApiKey) { const config: AIConfig = { @@ -30,6 +31,7 @@ export const loadConfigFromLocalStorage = () => { includeDataContent: savedIncludeData, showFullPrompt: savedShowFullPrompt, enableCodeSelectionMenu: savedEnableCodeSelectionMenu, + enableInlineSuggestions: savedEnableInlineSuggestions, }; if (savedCustomEndpoint && savedProvider === 'openai-compatible') { @@ -132,6 +134,8 @@ export const sendMessage = async ( systemPrompt = prepareSystemPrompt.createConcertoModel(editorsContent, aiConfig); } else if (promptPreset === "explainCode") { systemPrompt = prepareSystemPrompt.explainCode(editorsContent, aiConfig, editorType); + } else if (promptPreset === "inlineSuggestion") { + systemPrompt = prepareSystemPrompt.inlineSuggestion(editorsContent, aiConfig, editorType); } else { systemPrompt = prepareSystemPrompt.default(editorsContent, aiConfig); } diff --git a/src/ai-assistant/prompts.ts b/src/ai-assistant/prompts.ts index 49e16c4..6d06e41 100644 --- a/src/ai-assistant/prompts.ts +++ b/src/ai-assistant/prompts.ts @@ -51,9 +51,30 @@ export const prepareSystemPrompt = { return includeEditorContents(prompt, aiConfig, editorsContent); }, + inlineSuggestion: (editorsContent: editorsContent, aiConfig?: any, editorType?: 'markdown' | 'concerto' | 'json') => { + const editorName = editorType === 'markdown' ? 'TemplateMark' : editorType === 'concerto' ? 'Concerto' : 'JSON Data'; + let prompt = `You are a helpful assistant that provides inline code completion suggestions for Accord Project + ${editorName} code. You should only suggest valid code that will + compile. For instance, while making a suggestion in TemplateMark you must make sure that it conforms with + provided (if any) Concerto model/JSON data. + + IMPORTANT: Your response will be directly used for inline suggestions in the code editor. + - represents the current cursor position in the editor + - Return ONLY the code completion text that should be inserted at the cursor position. + - Do NOT include any explanations, markdown formatting, backticks, or additional text. + - Do NOT repeat the existing code that's already in the editor. + - Do NOT suggest replacement for existing code after the cursor, just addition. + - Provide only the logical continuation from the cursor position. + - If no meaningful completion can be suggested, return an empty response. + - Responses should ideally not be very long unless the situation demands it. + - Make sure completion is well-formatted with appropriate newlines and spaces as per context. + - The tendency should be to return a contentful ${editorName} completion every time and not empty response. + - Focus on syntactically correct and contextually appropriate completions\n\n`; + return includeEditorContents(prompt, aiConfig, editorsContent); + }, + default: (editorsContent: editorsContent, aiConfig?: any) => { - let prompt = `You are a helpful assistant that answers questions about open source Accord Project. You assist the user - to work with TemplateMark, Concerto models and JSON data. Code blocks returned by you should enclosed in backticks\n\n`; + let prompt = `You are a helpful assistant that answers questions about open source Accord Project. You assist the user in working with TemplateMark, Concerto models and JSON data. Code blocks returned by you should be enclosed in backticks, the language names that you can use after three backticks are- "concerto","templatemark" and "json", suffix 'Apply' to the language name if it is a complete code block that can be used to replace the corresponding editor content, precisely, concertoApply, templatemarkApply and jsonApply. You must always try to return complete code block that can be applied to the editors. Concerto code, TemplateMark code and JSON data are supplied to TemplateEngine to produce the final output. For instance, a data field that is not in Concerto data model can't be in JSON data and therefore can't be used in TemplateMark you generate. Analyze the JSON data and Concerto model (if provided) carefully, only the fields with simple data types (String, Integer etc.) present in concept annotated with @template decorator can be directly accessed anywhere in the template. Other complex data fields that have custom concept declaration in the Concerto model and are represented as nested fields in JSON data, can only be used within {{#clause conceptName}} {{concept_property_name}} {{/clause}} tags. Therefore, in most cases you have to create a scope using clause tag in TemplateMark to access properties defined under a concept in Concerto. For enumerating through a list you can create a scope to access the properties in list items via {{#olist listName}} {{instancePropertyName}} {{/olist}} or {{#ulist listName}} {{instancePropertyName}} {{/ulist}}. For TemplateMark code, there's no such thing as 'this' keyword within list scope. Optional fields shouldn't be wrapped in an if or with block to check for their availability e.g. if Concerto model has age as optional don't wrap it in if block in TemplateMark. You can also use Typescript within TemplateMark by enclosing the Typescript code in {{% %}}, you must write all of the Typescript code within a single line enclosed in a single pair of opening {{% and closing %}}. You may use Typescript to achieve an objective in TemplateMark only if TemplateMark syntax makes doing something hard, the data objects from JSON are readily available within {{% %}} enclosed Typescript using direct access, e.g. {{% return order.orderLines %}}. For e.g., you could use TypeScript to render ordered/unordered primitive list types such as String[]. Keep your focus on generating valid output based on current editors' contents but if you make a change that isn't compatible with the content of existing editors, you must return the full code for those editors as well. You mustn't add any placeholder in TemplateMark which isn't in Concerto model and JSON data unless you modify the Concerto and JSON data to have that field at the appropriate place.\n\n`; return includeEditorContents(prompt, aiConfig, editorsContent); } }; \ No newline at end of file diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx index b8f45a9..cdc5426 100644 --- a/src/components/AIChatPanel.tsx +++ b/src/components/AIChatPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useMemo } from "react"; import ReactMarkdown from "react-markdown"; import useAppStore from "../store/store"; import { sendMessage, stopMessage } from "../ai-assistant/chatRelay"; @@ -15,9 +15,62 @@ export const AIChatPanel = () => { editorAgreementData: state.editorAgreementData, })); - const { chatState, resetChat, aiConfig, setAIConfig, setAIConfigOpen, setAIChatOpen, textColor } = useAppStore.getState() + const { chatState, resetChat, aiConfig, setAIConfig, setAIConfigOpen, setAIChatOpen, textColor, backgroundColor } = useAppStore((state) => ({ + chatState: state.chatState, + resetChat: state.resetChat, + aiConfig: state.aiConfig, + setAIConfig: state.setAIConfig, + setAIConfigOpen: state.setAIConfigOpen, + setAIChatOpen: state.setAIChatOpen, + textColor: state.textColor, + backgroundColor: state.backgroundColor + })); const latestMessageRef = useRef(null); + + const theme = useMemo(() => { + const isDarkMode = backgroundColor !== '#ffffff'; + return { + header: `h-10 -ml-4 -mr-4 -mt-1 p-2 border-gray-200 text-sm font-medium flex justify-between items-center ${ + isDarkMode ? 'bg-gray-700 text-white' : 'bg-slate-100 text-gray-700' + }`, + + welcomeMessage: isDarkMode ? 'bg-blue-900' : 'bg-blue-100', + welcomeText: isDarkMode ? 'text-gray-300' : 'text-gray-600', + messageAssistant: isDarkMode ? 'bg-blue-900' : 'bg-blue-100', + messageUser: isDarkMode ? 'bg-gray-700' : 'bg-gray-100', + thinkingText: isDarkMode ? 'text-gray-400' : 'text-gray-500', + + inputContainer: isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-gray-50 border-gray-200', + textarea: { + base: isDarkMode ? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400' : 'bg-white border-gray-300 text-gray-900', + loading: isDarkMode ? 'bg-gray-600' : 'bg-gray-100' + }, + dropdownButton: isDarkMode ? 'bg-gray-700 text-gray-300 hover:bg-gray-600' : 'bg-gray-100 text-gray-700 hover:bg-gray-200', + dropdownMenu: isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200', + dropdownItem: isDarkMode ? 'hover:bg-gray-700 text-gray-100' : 'hover:bg-gray-50 text-gray-900', + + contextButtons: { + templateMark: { + active: isDarkMode ? 'bg-blue-900 text-blue-300 border-blue-700' : 'bg-blue-100 text-blue-700 border-blue-200', + inactive: isDarkMode ? 'bg-gray-700 text-gray-400 border-gray-600 line-through opacity-70 hover:bg-blue-900 hover:text-blue-300 hover:border-blue-700' : 'bg-gray-200 text-gray-500 border-gray-300 line-through opacity-70 hover:bg-blue-50 hover:text-blue-700 hover:border-blue-200', + cross: isDarkMode ? 'text-blue-300 hover:text-blue-100' : 'text-blue-700 hover:text-blue-900' + }, + concerto: { + active: isDarkMode ? 'bg-green-900 text-green-300 border-green-700' : 'bg-green-100 text-green-700 border-green-200', + inactive: isDarkMode ? 'bg-gray-700 text-gray-400 border-gray-600 line-through opacity-70 hover:bg-green-900 hover:text-green-300 hover:border-green-700' : 'bg-gray-200 text-gray-500 border-gray-300 line-through opacity-70 hover:bg-green-50 hover:text-green-700 hover:border-green-200', + cross: isDarkMode ? 'text-green-300 hover:text-green-100' : 'text-green-700 hover:text-green-900' + }, + data: { + active: isDarkMode ? 'bg-yellow-900 text-yellow-300 border-yellow-700' : 'bg-yellow-100 text-yellow-700 border-yellow-200', + inactive: isDarkMode ? 'bg-gray-700 text-gray-400 border-gray-600 line-through opacity-70 hover:bg-yellow-900 hover:text-yellow-300 hover:border-yellow-700' : 'bg-gray-200 text-gray-500 border-gray-300 line-through opacity-70 hover:bg-yellow-50 hover:text-yellow-700 hover:border-yellow-200', + cross: isDarkMode ? 'text-yellow-300 hover:text-yellow-100' : 'text-yellow-700 hover:text-yellow-900' + } + }, + + inlineCode: isDarkMode ? 'bg-gray-700 text-gray-200' : 'bg-gray-200 text-gray-800' + }; + }, [backgroundColor]); const [includeTemplateMarkContent, setIncludeTemplateMarkContent] = useState( localStorage.getItem('aiIncludeTemplateMark') === 'true' @@ -109,10 +162,10 @@ export const AIChatPanel = () => { if (!content || !content.includes('```')) { console.log("content is", content); return ( -
+
{children}, + code: ({ children, className }) => {children}, }}> {content} @@ -127,7 +180,7 @@ export const AIChatPanel = () => { if (segments[0]) { parts.push( -
+
{segments[0]} @@ -153,7 +206,7 @@ export const AIChatPanel = () => { ); } else if (i % 2 === 0 && segments[i]) { parts.push( -
+
{segments[i]} @@ -180,8 +233,8 @@ export const AIChatPanel = () => { }, [chatState.messages, chatState.isLoading]); return ( -
-
+
+

AI Assistant

- {/* Reset Chat Button */}