diff --git a/package-lock.json b/package-lock.json index 41055804..f82e06b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "refact-chat-js", - "version": "2.0.3-alpha.3", + "version": "2.0.3-alpha.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "refact-chat-js", - "version": "2.0.3-alpha.3", + "version": "2.0.3-alpha.4", "license": "BSD-3-Clause", "dependencies": { "@reduxjs/toolkit": "^2.2.7", diff --git a/package.json b/package.json index f324cb87..db794507 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "refact-chat-js", - "version": "2.0.3-alpha.3", + "version": "2.0.3-alpha.4", "type": "module", "license": "BSD-3-Clause", "files": [ diff --git a/src/__fixtures__/caps.ts b/src/__fixtures__/caps.ts index e7d1e143..b8111b63 100644 --- a/src/__fixtures__/caps.ts +++ b/src/__fixtures__/caps.ts @@ -1,55 +1,341 @@ import { CapsResponse } from "../services/refact"; export const STUB_CAPS_RESPONSE: CapsResponse = { - caps_version: 0, cloud_name: "Refact", - code_chat_default_model: "gpt-3.5-turbo", - code_chat_models: { - "gpt-3.5-turbo": { - default_scratchpad: "", + endpoint_style: "openai", + + endpoint_template: "https://inference.smallcloud.ai/v1/completions", + + endpoint_chat_passthrough: + "https://inference.smallcloud.ai/v1/chat/completions", + tokenizer_path_template: + "https://huggingface.co/$MODEL/resolve/main/tokenizer.json", + tokenizer_rewrite_path: { + "o1-mini": "Xenova/gpt-4o", + "gpt-4-turbo-2024-04-09": "Xenova/gpt-4", + "Refact/1.6B": "smallcloudai/Refact-1_6B-fim", + "claude-3-5-sonnet-20240620": "Xenova/claude-tokenizer", + "gpt-4-turbo": "Xenova/gpt-4", + "qwen2.5/coder/1.5b/base": "Qwen/Qwen2.5-Coder-1.5B", + "text-embedding-3-small": "Xenova/text-embedding-ada-002", + "gpt-4": "Xenova/gpt-4", + "claude-3-5-sonnet-20241022": "Xenova/claude-tokenizer", + "claude-3-5-sonnet": "Xenova/claude-tokenizer", + "gpt-3.5-turbo-0125": "Xenova/gpt-3.5-turbo-16k", + "gpt-3.5-turbo": "Xenova/gpt-3.5-turbo-16k", + "gpt-4o-mini-2024-07-18": "Xenova/gpt-4o", + "gpt-4o-2024-08-06": "Xenova/gpt-4o", + "gpt-3.5-turbo-1106": "Xenova/gpt-3.5-turbo-16k", + "openai/gpt-4-turbo": "Xenova/gpt-4", + "gpt-4o-2024-05-13": "Xenova/gpt-4o", + "openai/gpt-4o-mini": "Xenova/gpt-4o", + "openai/gpt-4o": "Xenova/gpt-4o", + "gpt-4o-mini": "Xenova/gpt-4o", + "openai/gpt-4": "Xenova/gpt-4", + "gpt-4o": "Xenova/gpt-4o", + "openai/gpt-3.5-turbo": "Xenova/gpt-3.5-turbo-16k", + "cerebras-llama3.1-8b": "Xenova/Meta-Llama-3.1-Tokenizer", + "groq-llama-3.1-8b": "Xenova/Meta-Llama-3.1-Tokenizer", + "starcoder2/3b": "bigcode/starcoder2-3b", + }, + telemetry_basic_dest: "https://www.smallcloud.ai/v1/telemetry-basic", + + code_completion_models: { + "Refact/1.6B": { n_ctx: 4096, - similar_models: [], - supports_tools: true, supports_scratchpads: { - PASSTHROUGH: { - default_system_message: - "You are a coding assistant that outputs short answers, gives links to documentation.", - }, + "FIM-SPM": {}, }, + default_scratchpad: "FIM-SPM", + similar_models: ["Refact/1.6B", "Refact/1.6B/vllm"], + supports_tools: false, + supports_multimodality: false, + supports_clicks: false, }, - "test-model": { + "groq-llama-3.1-8b": { + n_ctx: 32000, + supports_scratchpads: { + REPLACE_PASSTHROUGH: { + context_format: "chat", + rag_ratio: 0.5, + }, + }, default_scratchpad: "", + similar_models: [ + "groq-llama-3.1-70b", + "groq-llama-3.2-1b", + "groq-llama-3.2-3b", + "groq-llama-3.2-11b-vision", + "groq-llama-3.2-90b-vision", + ], + supports_tools: true, + supports_multimodality: false, + supports_clicks: false, + }, + "qwen2.5/coder/1.5b/base": { n_ctx: 4096, - similar_models: [], + supports_scratchpads: { + "FIM-PSM": { + fim_prefix: "<|fim_prefix|>", + fim_suffix: "<|fim_suffix|>", + fim_middle: "<|fim_middle|>", + eot: "<|endoftext|>", + extra_stop_tokens: ["<|repo_name|>", "<|file_sep|>", "<|fim_pad|>"], + context_format: "qwen2.5", + rag_ratio: 0.5, + }, + }, + default_scratchpad: "FIM-PSM", + similar_models: [ + "qwen2.5/coder/1.5b/base", + "qwen2.5/coder/3b/base", + "qwen2.5/coder/7b/base", + "qwen2.5/coder/14b/base", + "qwen2.5/coder/32b/base", + "qwen2.5/coder/0.5b/base/vllm", + "qwen2.5/coder/1.5b/base/vllm", + "qwen2.5/coder/3b/base/vllm", + "qwen2.5/coder/7b/base/vllm", + "qwen2.5/coder/14b/base/vllm", + "qwen2.5/coder/32b/base/vllm", + ], supports_tools: false, + supports_multimodality: false, + supports_clicks: false, + }, + "gpt-4o": { + n_ctx: 32000, supports_scratchpads: { - PASSTHROUGH: { - default_system_message: - "You are a coding assistant that outputs short answers, gives links to documentation.", + REPLACE_PASSTHROUGH: { + context_format: "chat", + rag_ratio: 0.5, }, }, + default_scratchpad: "", + similar_models: [ + "gpt-4o-2024-05-13", + "gpt-4o-2024-08-06", + "openai/gpt-4o", + ], + supports_tools: true, + supports_multimodality: false, + supports_clicks: false, + }, + "gpt-4o-mini": { + n_ctx: 32000, + supports_scratchpads: { + REPLACE_PASSTHROUGH: { + context_format: "chat", + rag_ratio: 0.5, + }, + }, + default_scratchpad: "", + similar_models: ["gpt-4o-mini-2024-07-18"], + supports_tools: true, + supports_multimodality: false, + supports_clicks: false, }, - }, - code_completion_default_model: "smallcloudai/Refact-1_6B-fim", - code_completion_models: { "smallcloudai/Refact-1_6B-fim": { - default_scratchpad: "FIM-SPM", n_ctx: 4096, - similar_models: ["Refact/1.6B", "Refact/1.6B/vllm"], supports_scratchpads: { - "FIM-PSM": {}, "FIM-SPM": {}, }, + default_scratchpad: "FIM-SPM", + similar_models: ["Refact/1.6B", "Refact/1.6B/vllm"], + supports_tools: false, + supports_multimodality: false, + supports_clicks: false, + }, + "groq-llama-3.1-70b": { + n_ctx: 32000, + supports_scratchpads: { + REPLACE_PASSTHROUGH: { + context_format: "chat", + rag_ratio: 0.5, + }, + }, + default_scratchpad: "", + similar_models: [ + "groq-llama-3.1-70b", + "groq-llama-3.2-1b", + "groq-llama-3.2-3b", + "groq-llama-3.2-11b-vision", + "groq-llama-3.2-90b-vision", + ], + supports_tools: true, + supports_multimodality: false, + supports_clicks: false, + }, + "starcoder2/3b": { + n_ctx: 4096, + supports_scratchpads: { + "FIM-PSM": { + context_format: "starcoder", + rag_ratio: 0.5, + }, + }, + default_scratchpad: "FIM-PSM", + similar_models: [ + "bigcode/starcoderbase", + "starcoder/15b/base", + "starcoder/15b/plus", + "starcoder/1b/base", + "starcoder/3b/base", + "starcoder/7b/base", + "wizardcoder/15b", + "starcoder/1b/vllm", + "starcoder/3b/vllm", + "starcoder/7b/vllm", + "starcoder2/3b/base", + "starcoder2/7b/base", + "starcoder2/15b/base", + "starcoder2/3b/vllm", + "starcoder2/7b/vllm", + "starcoder2/15b/vllm", + "starcoder2/3b/neuron", + "starcoder2/7b/neuron", + "starcoder2/15b/neuron", + "starcoder2/3b", + "starcoder2/7b", + "starcoder2/15b", + "bigcode/starcoder2-3b", + "bigcode/starcoder2-7b", + "bigcode/starcoder2-15b", + ], + supports_tools: false, + supports_multimodality: false, + supports_clicks: false, }, }, - code_completion_n_ctx: 2048, - endpoint_chat_passthrough: - "https://inference.smallcloud.ai/v1/chat/completions", - endpoint_style: "openai", - endpoint_template: "https://inference.smallcloud.ai/v1/completions", - running_models: ["smallcloudai/Refact-1_6B-fim", "gpt-3.5-turbo"], - telemetry_basic_dest: "https://www.smallcloud.ai/v1/telemetry-basic", - tokenizer_path_template: - "https://huggingface.co/$MODEL/resolve/main/tokenizer.json", - tokenizer_rewrite_path: {}, + code_completion_default_model: "qwen2.5/coder/1.5b/base", + code_completion_n_ctx: 4000, + code_chat_models: { + "groq-llama-3.1-70b": { + n_ctx: 32000, + supports_scratchpads: { + PASSTHROUGH: {}, + }, + default_scratchpad: "", + similar_models: [ + "groq-llama-3.1-70b", + "groq-llama-3.2-1b", + "groq-llama-3.2-3b", + "groq-llama-3.2-11b-vision", + "groq-llama-3.2-90b-vision", + ], + supports_tools: true, + supports_multimodality: false, + supports_clicks: false, + }, + "gpt-3.5-turbo": { + n_ctx: 16000, + supports_scratchpads: { + PASSTHROUGH: {}, + }, + default_scratchpad: "", + similar_models: [ + "gpt-3.5-turbo-1106", + "gpt-3.5-turbo-0125", + "gpt-4", + "gpt-4-turbo", + "gpt-4-turbo-2024-04-09", + "openai/gpt-3.5-turbo", + "openai/gpt-4", + "openai/gpt-4-turbo", + ], + supports_tools: true, + supports_multimodality: false, + supports_clicks: false, + }, + "gpt-4o": { + n_ctx: 32000, + supports_scratchpads: { + PASSTHROUGH: {}, + }, + default_scratchpad: "", + similar_models: [ + "gpt-4o-2024-05-13", + "gpt-4o-2024-08-06", + "openai/gpt-4o", + ], + supports_tools: true, + supports_multimodality: true, + supports_clicks: false, + }, + "gpt-4o-mini": { + n_ctx: 32000, + supports_scratchpads: { + PASSTHROUGH: {}, + }, + default_scratchpad: "", + similar_models: ["gpt-4o-mini-2024-07-18"], + supports_tools: true, + supports_multimodality: true, + supports_clicks: false, + }, + "claude-3-5-sonnet": { + n_ctx: 32000, + supports_scratchpads: { + PASSTHROUGH: {}, + }, + default_scratchpad: "", + similar_models: ["claude-3-5-sonnet-20240620"], + supports_tools: true, + supports_multimodality: true, + supports_clicks: false, + }, + "groq-llama-3.1-8b": { + n_ctx: 32000, + supports_scratchpads: { + PASSTHROUGH: {}, + }, + default_scratchpad: "", + similar_models: [ + "groq-llama-3.1-70b", + "groq-llama-3.2-1b", + "groq-llama-3.2-3b", + "groq-llama-3.2-11b-vision", + "groq-llama-3.2-90b-vision", + ], + supports_tools: true, + supports_multimodality: false, + supports_clicks: false, + }, + "gpt-4-turbo": { + n_ctx: 16000, + supports_scratchpads: { + PASSTHROUGH: {}, + }, + default_scratchpad: "", + similar_models: [ + "gpt-3.5-turbo-1106", + "gpt-3.5-turbo-0125", + "gpt-4", + "gpt-4-turbo", + "gpt-4-turbo-2024-04-09", + "openai/gpt-3.5-turbo", + "openai/gpt-4", + "openai/gpt-4-turbo", + ], + supports_tools: true, + supports_multimodality: false, + supports_clicks: false, + }, + }, + code_chat_default_model: "gpt-4o-mini", + running_models: [ + "smallcloudai/Refact-1_6B-fim", + "Refact/1.6B", + "thenlper/gte-base", + "starcoder2/3b", + "qwen2.5/coder/1.5b/base", + "gpt-3.5-turbo", + "gpt-4-turbo", + "gpt-4o", + "gpt-4o-mini", + "claude-3-5-sonnet", + "groq-llama-3.1-8b", + "groq-llama-3.1-70b", + ], + caps_version: 0, }; diff --git a/src/__fixtures__/chat_config_thread.ts b/src/__fixtures__/chat_config_thread.ts index a5dbb748..d590bfa1 100644 --- a/src/__fixtures__/chat_config_thread.ts +++ b/src/__fixtures__/chat_config_thread.ts @@ -3,6 +3,7 @@ import type { Chat } from "../features/Chat/Thread"; export const CHAT_CONFIG_THREAD: Chat = { streaming: false, thread: { + mode: "CONFIGURE", id: "941fb8f4-409c-4430-a3b2-6450fafdb9f4", messages: [ { diff --git a/src/__fixtures__/chat_links_response.ts b/src/__fixtures__/chat_links_response.ts index 53dcab5d..f5ca4597 100644 --- a/src/__fixtures__/chat_links_response.ts +++ b/src/__fixtures__/chat_links_response.ts @@ -13,12 +13,18 @@ export const STUB_LINKS_FOR_CHAT_RESPONSE: LinksForChatResponse = { action: "follow-up", link_tooltip: "a nice tool tip message", }, - { text: 'git commit -m "message"', action: "commit", link_tooltip: "" }, - { text: "Save and return", goto: "SETTINGS:postgres", link_tooltip: "" }, + // { text: 'git commit -m "message"', action: "commit", link_tooltip: "" }, + // { text: "Save and return", goto: "SETTINGS:postgres", link_tooltip: "" }, { text: "Investigate Project", action: "summarize-project", link_tooltip: "", }, + + // { + // text: "long long long long long long long long long long long long long long long long long long ", + // action: "summarize-project", + // link_tooltip: "", + // }, ], }; diff --git a/src/__fixtures__/msw.ts b/src/__fixtures__/msw.ts new file mode 100644 index 00000000..513e01c2 --- /dev/null +++ b/src/__fixtures__/msw.ts @@ -0,0 +1,85 @@ +import { http, HttpResponse, type HttpHandler } from "msw"; +import { STUB_CAPS_RESPONSE } from "./caps"; +import { SYSTEM_PROMPTS } from "./prompts"; +import { STUB_LINKS_FOR_CHAT_RESPONSE } from "./chat_links_response"; +import { + AT_TOOLS_AVAILABLE_URL, + CHAT_LINKS_URL, +} from "../services/refact/consts"; +import { STUB_TOOL_RESPONSE } from "./tools_response"; + +export const goodPing: HttpHandler = http.get( + "http://127.0.0.1:8001/v1/ping", + () => { + return HttpResponse.text("pong"); + }, +); + +export const goodCaps: HttpHandler = http.get( + "http://127.0.0.1:8001/v1/caps", + () => { + return HttpResponse.json(STUB_CAPS_RESPONSE); + }, +); + +export const noTools: HttpHandler = http.get( + "http://127.0.0.1:8001/v1/tools", + () => { + return HttpResponse.json([]); + }, +); + +export const goodPrompts: HttpHandler = http.get( + "http://127.0.0.1:8001/v1/customization", + () => { + return HttpResponse.json({ system_prompts: SYSTEM_PROMPTS }); + }, +); + +export const noCompletions: HttpHandler = http.post( + "http://127.0.0.1:8001/v1/at-command-completion", + () => { + return HttpResponse.json({ + completions: [], + replace: [0, 0], + is_cmd_executable: false, + }); + }, +); + +export const noCommandPreview: HttpHandler = http.post( + "http://127.0.0.1:8001/v1/at-command-preview", + () => { + return HttpResponse.json({ + messages: [], + }); + }, +); + +export const goodUser: HttpHandler = http.get( + "https://www.smallcloud.ai/v1/login", + () => { + return HttpResponse.json({ + retcode: "OK", + account: "party@refact.ai", + inference_url: "https://www.smallcloud.ai/v1", + inference: "PRO", + metering_balance: -100000, + questionnaire: {}, + }); + }, +); + +export const chatLinks: HttpHandler = http.post( + `http://127.0.0.1:8001${CHAT_LINKS_URL}`, + () => { + return HttpResponse.json(STUB_LINKS_FOR_CHAT_RESPONSE); + }, +); + +export const goodTools: HttpHandler = http.get( + `http://127.0.0.1:8001${AT_TOOLS_AVAILABLE_URL}`, + () => { + return HttpResponse.json(STUB_TOOL_RESPONSE); + }, +); diff --git a/src/__fixtures__/tools_response.ts b/src/__fixtures__/tools_response.ts new file mode 100644 index 00000000..e4d5d3d3 --- /dev/null +++ b/src/__fixtures__/tools_response.ts @@ -0,0 +1,270 @@ +import { ToolCommand } from "../services/refact"; + +export const STUB_TOOL_RESPONSE: ToolCommand[] = [ + { + type: "function", + function: { + name: "search", + agentic: false, + description: "Find similar pieces of code or text using vector database", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: + "Single line, paragraph or code sample to search for similar content.", + }, + scope: { + type: "string", + description: + "'workspace' to search all files in workspace, 'dir/subdir/' to search in files within a directory, 'dir/file.ext' to search in a single file.", + }, + }, + required: ["query", "scope"], + }, + }, + }, + { + type: "function", + function: { + name: "definition", + agentic: false, + description: "Read definition of a symbol in the project using AST", + parameters: { + type: "object", + properties: { + symbol: { + type: "string", + description: + "The exact name of a function, method, class, type alias. No spaces allowed.", + }, + skeleton: { + type: "boolean", + description: + "Skeletonize ouput. Set true to explore, set false when as much context as possible is needed.", + }, + }, + required: ["symbol"], + }, + }, + }, + { + type: "function", + function: { + name: "references", + agentic: false, + description: "Find usages of a symbol within a project using AST", + parameters: { + type: "object", + properties: { + symbol: { + type: "string", + description: + "The exact name of a function, method, class, type alias. No spaces allowed.", + }, + skeleton: { + type: "boolean", + description: + "Skeletonize ouput. Set true to explore, set false when as much context as possible is needed.", + }, + }, + required: ["symbol"], + }, + }, + }, + { + type: "function", + function: { + name: "tree", + agentic: false, + description: + "Get a files tree with symbols for the project. Use it to get familiar with the project, file names and symbols", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "An absolute path to get files tree for. Do not pass it if you need a full project tree.", + }, + use_ast: { + type: "boolean", + description: + "If true, for each file an array of AST symbols will appear as well as its filename", + }, + }, + required: [], + }, + }, + }, + { + type: "function", + function: { + name: "web", + agentic: false, + description: "Fetch a web page and convert to readable plain text.", + parameters: { + type: "object", + properties: { + url: { + type: "string", + description: "URL of the web page to fetch.", + }, + }, + required: ["url"], + }, + }, + }, + { + type: "function", + function: { + name: "cat", + agentic: false, + description: + "Like cat in console, but better: it can read multiple files and skeletonize them. Give it AST symbols important for the goal (classes, functions, variables, etc) to see them in full. It can also read images just fine.", + parameters: { + type: "object", + properties: { + paths: { + type: "string", + description: + "Comma separated file names or directories: dir1/file1.ext, dir2/file2.ext, dir3/dir4", + }, + symbols: { + type: "string", + description: + "Comma separated AST symbols: MyClass, MyClass::method, my_function", + }, + skeleton: { + type: "boolean", + description: + "if true, files will be skeletonized - mostly only AST symbols will be visible", + }, + }, + required: ["paths"], + }, + }, + }, + { + type: "function", + function: { + name: "locate", + agentic: true, + description: + "Get a list of files that are relevant to solve a particular task.", + parameters: { + type: "object", + properties: { + problem_statement: { + type: "string", + description: + "Copy word-for-word the problem statement as provided by the user, if available. Otherwise, tell what you need to do in your own words.", + }, + }, + required: ["problem_statement"], + }, + }, + }, + { + type: "function", + function: { + name: "patch", + agentic: true, + description: + "Collect context first, then write the necessary changes using the 📍-notation before code blocks, then call this function to apply the changes.\nTo make this call correctly, you only need the tickets.\nIf you wrote changes for multiple files, call this tool in parallel for each file.\nIf you have several attempts to change a single thing, for example following a correction from the user, pass only the ticket for the latest one.\nMultiple tickets is allowed only for PARTIAL_EDIT, otherwise only one ticket must be provided.\n", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file to change.", + }, + tickets: { + type: "string", + description: + "Use 3-digit tickets comma separated to refer to the changes within ONE file. No need to copy anything else. Additionaly, you can put DELETE here to delete the file.", + }, + }, + required: ["tickets", "path"], + }, + }, + }, + { + type: "function", + function: { + name: "postgres", + agentic: true, + description: "PostgreSQL integration, can run a single query per call.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: + "Don't forget semicolon at the end, examples:\nSELECT * FROM table_name;\nCREATE INDEX my_index_users_email ON my_users (email);\n", + }, + }, + required: ["query"], + }, + }, + }, + { + type: "function", + function: { + name: "docker", + agentic: true, + description: + "Access to docker cli, in a non-interactive way, don't open a shell.", + parameters: { + type: "object", + properties: { + command: { + type: "string", + description: "Examples: docker images", + }, + }, + required: ["project_dir", "command"], + }, + }, + }, + { + type: "function", + function: { + name: "knowledge", + agentic: true, + description: + "Fetches successful trajectories to help you accomplish your task. Call each time you have a new task to increase your chances of success.", + parameters: { + type: "object", + properties: { + im_going_to_use_tools: { + type: "string", + description: + "Which tools are you about to use? Comma-separated list, examples: hg, git, gitlab, rust debugger, patch", + }, + im_going_to_apply_to: { + type: "string", + description: + "What your actions will be applied to? List all you can identify, starting with the project name. Comma-separated list, examples: project1, file1.cpp, MyClass, PRs, issues", + }, + goal: { + type: "string", + description: "What is your goal here?", + }, + language_slash_framework: { + type: "string", + description: + "What programming language and framework is the current project using? Use lowercase, dashes and dots. Examples: python/django, typescript/node.js, rust/tokio, ruby/rails, php/laravel, c++/boost-asio", + }, + }, + required: [ + "im_going_to_use_tools", + "im_going_to_apply_to", + "goal", + "language_slash_framework", + ], + }, + }, + }, +]; diff --git a/src/__tests__/ChatCapsFetchError.test.tsx b/src/__tests__/ChatCapsFetchError.test.tsx index 66b3d479..fc611732 100644 --- a/src/__tests__/ChatCapsFetchError.test.tsx +++ b/src/__tests__/ChatCapsFetchError.test.tsx @@ -7,6 +7,7 @@ import { noTools, goodUser, goodPing, + chatLinks, } from "../utils/mockServer"; import { Chat } from "../features/Chat"; @@ -19,6 +20,7 @@ describe("chat caps error", () => { noTools, goodPrompts, goodUser, + chatLinks, http.get("http://127.0.0.1:8001/v1/caps", () => { return HttpResponse.json( { diff --git a/src/__tests__/DeleteChat.test.tsx b/src/__tests__/DeleteChat.test.tsx index fcc59ca6..09571d81 100644 --- a/src/__tests__/DeleteChat.test.tsx +++ b/src/__tests__/DeleteChat.test.tsx @@ -1,11 +1,11 @@ import { render } from "../utils/test-utils"; import { describe, expect, it } from "vitest"; -import { server, goodUser, goodPing } from "../utils/mockServer"; +import { server, goodUser, goodPing, chatLinks } from "../utils/mockServer"; import { InnerApp } from "../features/App"; import { HistoryState } from "../features/History/historySlice"; describe("Delete a Chat form history", () => { - server.use(goodUser, goodPing); + server.use(goodUser, goodPing, chatLinks); it("can delete a chat", async () => { const now = new Date().toISOString(); const history: HistoryState = { diff --git a/src/__tests__/PinMessages.test.tsx b/src/__tests__/PinMessages.test.tsx index 2f5305a7..fd63c975 100644 --- a/src/__tests__/PinMessages.test.tsx +++ b/src/__tests__/PinMessages.test.tsx @@ -9,6 +9,7 @@ import { noCompletions, goodUser, goodPing, + chatLinks, } from "../utils/mockServer"; import { InnerApp } from "../features/App"; @@ -21,6 +22,7 @@ describe("Pin messages", () => { noCommandPreview, noCompletions, goodUser, + chatLinks, ); test("it should replace 📍PARTIAL_EDIT 000 /Users/refact/code/refact-lsp/src/ast/ast_db.rs", () => { diff --git a/src/__tests__/RestoreChat.test.tsx b/src/__tests__/RestoreChat.test.tsx index 40da8785..f8d155d0 100644 --- a/src/__tests__/RestoreChat.test.tsx +++ b/src/__tests__/RestoreChat.test.tsx @@ -9,6 +9,7 @@ import { noCompletions, goodUser, goodPing, + chatLinks, } from "../utils/mockServer"; import { InnerApp } from "../features/App"; @@ -22,6 +23,7 @@ describe("Restore Chat from history", () => { noCommandPreview, noCompletions, goodUser, + chatLinks, ); const { user, ...app } = render(, { diff --git a/src/__tests__/StartNewChat.test.tsx b/src/__tests__/StartNewChat.test.tsx index 00d21fa7..2973fc3f 100644 --- a/src/__tests__/StartNewChat.test.tsx +++ b/src/__tests__/StartNewChat.test.tsx @@ -9,6 +9,7 @@ import { noCompletions, goodUser, goodPing, + chatLinks, } from "../utils/mockServer"; import { InnerApp } from "../features/App"; @@ -21,6 +22,7 @@ describe("Start a new chat", () => { noCommandPreview, noCompletions, goodUser, + chatLinks, ); const { user, ...app } = render(, { diff --git a/src/__tests__/UserSurvey.test.tsx b/src/__tests__/UserSurvey.test.tsx index 0dce5afa..a7edb115 100644 --- a/src/__tests__/UserSurvey.test.tsx +++ b/src/__tests__/UserSurvey.test.tsx @@ -11,6 +11,7 @@ import { noCompletions, goodPing, goodUser, + chatLinks, } from "../utils/mockServer"; import { InnerApp } from "../features/App"; @@ -56,6 +57,7 @@ describe("Start a new chat", () => { goodUser, questionnaireMock, saveQuestionnaireMock, + chatLinks, ); const { user, ...app } = render(, { diff --git a/src/components/Chat/Chat.stories.tsx b/src/components/Chat/Chat.stories.tsx new file mode 100644 index 00000000..04d12da3 --- /dev/null +++ b/src/components/Chat/Chat.stories.tsx @@ -0,0 +1,168 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Chat } from "./Chat"; +import { ChatThread } from "../../features/Chat/Thread/types"; +import { setUpStore } from "../../app/store"; +import { Provider } from "react-redux"; +import { Theme } from "../Theme"; +import { AbortControllerProvider } from "../../contexts/AbortControllers"; +import { CHAT_CONFIG_THREAD } from "../../__fixtures__"; + +import { + goodCaps, + goodPing, + goodPrompts, + goodUser, + chatLinks, + goodTools, +} from "../../__fixtures__/msw"; +import { TourProvider } from "../../features/Tour"; +import { Flex } from "@radix-ui/themes"; + +const Template: React.FC<{ + thread?: ChatThread; +}> = ({ thread }) => { + const threadData = thread ?? { + id: "test", + model: "gpt-4o", // or any model from STUB CAPS REQUEst + messages: [], + }; + const store = setUpStore({ + tour: { + type: "finished", + }, + chat: { + streaming: false, + prevent_send: false, + waiting_for_response: false, + tool_use: "agent", + send_immediately: false, + error: null, + cache: {}, + system_prompt: {}, + thread: threadData, + }, + }); + + return ( + + + + + + ({})} + caps={{ + error: null, + fetching: false, + default_cap: "gpt-4o-mini", + available_caps: { + "groq-llama-3.1-70b": { + n_ctx: 32000, + default_scratchpad: "", + supports_scratchpads: {}, + similar_models: [ + "groq-llama-3.1-70b", + "groq-llama-3.2-1b", + "groq-l…", + ], + supports_tools: true, + }, + "gpt-3.5-turbo": { + n_ctx: 16000, + default_scratchpad: "", + supports_scratchpads: {}, + similar_models: [ + "gpt-3.5-turbo-1106", + "gpt-3.5-turbo-0125", + "gpt-4…", + ], + supports_tools: true, + }, + "gpt-4o": { + n_ctx: 32000, + supports_scratchpads: {}, + default_scratchpad: "", + similar_models: [], + supports_tools: true, + }, + "gpt-4o-mini": { + n_ctx: 32000, + supports_scratchpads: {}, + default_scratchpad: "", + similar_models: [], + supports_tools: true, + }, + "claude-3-5-sonnet": { + n_ctx: 32000, + supports_scratchpads: {}, + default_scratchpad: "", + similar_models: [], + supports_tools: true, + }, + "groq-llama-3.1-8b": { + n_ctx: 32000, + supports_scratchpads: {}, + default_scratchpad: "", + similar_models: [], + supports_tools: true, + }, + "gpt-4-turbo": { + n_ctx: 16000, + supports_scratchpads: {}, + default_scratchpad: "", + similar_models: [], + supports_tools: true, + }, + }, + }} + maybeSendToSidebar={() => ({})} + /> + + + + + + ); +}; + +const meta = { + title: "Chat", + component: Template, + parameters: { + msw: { + handlers: [ + goodCaps, + goodPing, + goodPrompts, + goodUser, + chatLinks, + goodTools, + ], + }, + }, + argTypes: {}, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = {}; + +export const Configuration: Story = { + args: { + thread: CHAT_CONFIG_THREAD.thread, + }, + + // parameters: { + // parameters: { + // msw: { + // handlers: [goodCaps, goodPing, goodPrompts, goodUser, chatLinks], + // }, + // }, + // }, +}; diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 9295d07d..1c80563a 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -22,11 +22,12 @@ import { getSelectedToolUse, getSelectedSystemPrompt, setSystemPrompt, + ToolUse, } from "../../features/Chat/Thread"; import { ThreadHistoryButton } from "../Buttons"; import { push } from "../../features/Pages/pagesSlice"; import { DropzoneProvider } from "../Dropzone"; -import { SystemPrompts } from "../../services/refact"; +import { CodeChatModel, SystemPrompts } from "../../services/refact"; export type ChatProps = { host: Config["host"]; @@ -105,6 +106,14 @@ export const Chat: React.FC = ({ useAutoSend(); + // TODO: ideally this could be set when the chat is created. + useEffect(() => { + if (chatToolUse === "agent" && !modelSupportsAgent(chatModel)) { + const modelToUse = modelForMode(chatModel, caps, chatToolUse); + onSetChatModel(modelToUse); + } + }, [caps, chatModel, chatToolUse, onSetChatModel]); + return ( = ({ onSubmit={handleSummit} model={chatModel} onSetChatModel={onSetChatModel} - caps={caps} + caps={{ + ...caps, + available_caps: capOptionsForMode(caps.available_caps, chatToolUse), + }} onClose={maybeSendToSidebar} prompts={promptsRequest.data ?? {}} onSetSystemPrompt={onSetSelectedSystemPrompt} @@ -177,3 +189,39 @@ export const Chat: React.FC = ({ ); }; + +const AGENT_ALLOW_LIST = ["gpt-4o", "claude-3-5-sonnet"]; +function modelForMode( + model: string, + caps: ChatFormProps["caps"], + toolUse?: ToolUse, +) { + if (toolUse !== "agent") return model; + + if (AGENT_ALLOW_LIST.includes(model)) return model; + + const available = Object.keys(caps.available_caps); + + const hasModels = AGENT_ALLOW_LIST.find((agent) => available.includes(agent)); + if (hasModels) return hasModels; + + return model || caps.default_cap; +} + +function modelSupportsAgent(model: string) { + return AGENT_ALLOW_LIST.includes(model); +} + +function capOptionsForMode( + caps: Record, + toolUse?: string, +) { + if (toolUse !== "agent") return caps; + const agentEntries = Object.entries(caps).filter(([key]) => + AGENT_ALLOW_LIST.includes(key), + ); + + if (agentEntries.length === 0) return caps; + + return Object.fromEntries(agentEntries); +} diff --git a/src/components/ChatContent/ChatContent.stories.tsx b/src/components/ChatContent/ChatContent.stories.tsx index 785ed156..67445864 100644 --- a/src/components/ChatContent/ChatContent.stories.tsx +++ b/src/components/ChatContent/ChatContent.stories.tsx @@ -133,7 +133,7 @@ export const IntegrationChat: Story = { parameters: { msw: { handlers: [ - http.post(CHAT_LINKS_URL, () => { + http.post(`http://127.0.0.1:8001${CHAT_LINKS_URL}`, () => { return HttpResponse.json(STUB_LINKS_FOR_CHAT_RESPONSE); }), ], diff --git a/src/components/ChatContent/ChatContent.tsx b/src/components/ChatContent/ChatContent.tsx index 7e9ba444..2c0a0779 100644 --- a/src/components/ChatContent/ChatContent.tsx +++ b/src/components/ChatContent/ChatContent.tsx @@ -9,7 +9,7 @@ import { import { UserInput } from "./UserInput"; import { ScrollArea } from "../ScrollArea"; import { Spinner } from "../Spinner"; -import { Flex, Text, Container, Link, Button } from "@radix-ui/themes"; +import { Flex, Text, Container, Link, Button, Box } from "@radix-ui/themes"; import styles from "./ChatContent.module.css"; import { ContextFiles } from "./ContextFiles"; import { AssistantInput } from "./AssistantInput"; @@ -118,7 +118,7 @@ export const ChatContent: React.FC = ({ const messages = useAppSelector(selectMessages); const isStreaming = useAppSelector(selectIsStreaming); const thread = useAppSelector(selectThread); - const isConfig = !!thread.integration; + const isConfig = thread.mode === "CONFIGURE"; const isWaiting = useAppSelector(selectIsWaiting); const { @@ -167,8 +167,6 @@ export const ChatContent: React.FC = ({ {messages.length === 0 && } {renderMessages(messages, onRetryWrapper)} - - @@ -177,28 +175,40 @@ export const ChatContent: React.FC = ({ )} - - {isStreaming && ( - - )} - {isConfig && ( - - )} - + + + + {isStreaming && ( + + )} + {isConfig && ( + + )} + + + + + ); }; diff --git a/src/components/ChatContent/ScrollToBottomButton.tsx b/src/components/ChatContent/ScrollToBottomButton.tsx index 7f20ec52..c0622669 100644 --- a/src/components/ChatContent/ScrollToBottomButton.tsx +++ b/src/components/ChatContent/ScrollToBottomButton.tsx @@ -17,6 +17,7 @@ export const ScrollToBottomButton = ({ height: 35, bottom: 15, right: 15, + zIndex: 1, }} onClick={onClick} > diff --git a/src/components/ChatLinks/ChatLinks.tsx b/src/components/ChatLinks/ChatLinks.tsx index 7d6d137e..d25b5f29 100644 --- a/src/components/ChatLinks/ChatLinks.tsx +++ b/src/components/ChatLinks/ChatLinks.tsx @@ -1,25 +1,7 @@ -import React, { useMemo } from "react"; -import { Flex, Button, Container, Box } from "@radix-ui/themes"; -import { linksApi, type ChatLink } from "../../services/refact/links"; -import { diffApi, isUserMessage } from "../../services/refact"; -import { - useAppDispatch, - useAppSelector, - useEventsBusForIDE, - useGetCapsQuery, - useSendChatRequest, -} from "../../hooks"; -import { - selectChatId, - selectIntegration, - selectIsStreaming, - selectIsWaiting, - selectMessages, - selectModel, - selectThreadMode, - setIntegrationData, -} from "../../features/Chat"; -import { popBackTo } from "../../features/Pages/pagesSlice"; +import React from "react"; +import { Button } from "@radix-ui/themes"; +import { type ChatLink } from "../../services/refact/links"; +import { useLinksFromLsp } from "../../hooks"; import { Spinner } from "@radix-ui/themes"; import { TruncateRight } from "../Text/TruncateRight"; @@ -32,178 +14,29 @@ function maybeConcatActionAndGoToStrings(link: ChatLink): string | undefined { return `goto: ${link.goto}`; } -const isAbsolutePath = (path: string) => { - const absolutePathRegex = /^(?:[a-zA-Z]:\\|\/|\\\\|\/\/).*/; - return absolutePathRegex.test(path); -}; - export const ChatLinks: React.FC = () => { - const dispatch = useAppDispatch(); - const { queryPathThenOpenFile } = useEventsBusForIDE(); - const { submit } = useSendChatRequest(); - - const [applyPatches, _applyPatchesResult] = - diffApi.useApplyAllPatchesInMessagesMutation(); - - const isStreaming = useAppSelector(selectIsStreaming); - const isWaiting = useAppSelector(selectIsWaiting); - const messages = useAppSelector(selectMessages); - const chatId = useAppSelector(selectChatId); - const maybeIntegration = useAppSelector(selectIntegration); - const threadMode = useAppSelector(selectThreadMode); - - // TODO: add the model - const caps = useGetCapsQuery(); - - const model = - useAppSelector(selectModel) || caps.data?.code_chat_default_model; - - const unCalledTools = React.useMemo(() => { - if (messages.length === 0) return false; - const last = messages[messages.length - 1]; - //TODO: handle multiple tool calls in last assistant message - if (last.role !== "assistant") return false; - const maybeTools = last.tool_calls; - if (maybeTools && maybeTools.length > 0) return true; - return false; - }, [messages]); - - const handleGoTo = (goto?: string) => { - if (!goto) return; - // TODO: duplicated in smart links. - const [action, payload] = goto.split(":"); - - switch (action.toLowerCase()) { - case "editor": { - void queryPathThenOpenFile({ file_name: payload }); - return; - } - case "settings": { - const isFile = isAbsolutePath(payload); - dispatch( - popBackTo({ - name: "integrations page", - // projectPath: isFile ? payload : "", - integrationName: - !isFile && payload !== "DEFAULT" - ? payload - : maybeIntegration?.name, - integrationPath: isFile ? payload : maybeIntegration?.path, - projectPath: maybeIntegration?.project, - }), - ); - // TODO: open in the integrations - return; - } - default: { - // eslint-disable-next-line no-console - console.log(`[DEBUG]: unexpected action, doing nothing`); - return; - } - } - }; - const handleLinkAction = (link: ChatLink) => { - if (!("action" in link)) return; - - if (link.action === "goto" && "goto" in link) { - handleGoTo(link.goto); - return; - } - - if (link.action === "patch-all") { - void applyPatches(messages).then(() => { - if ("goto" in link) { - handleGoTo(link.goto); - } - }); - return; - } - - if (link.action === "follow-up") { - submit(link.text); - return; - } - - if (link.action === "summarize-project") { - if ("current_config_file" in link && link.current_config_file) { - dispatch(setIntegrationData({ path: link.current_config_file })); - // set the integration data - } - submit(link.text, "PROJECT_SUMMARY"); - return; - } + const { linksResult, handleLinkAction, streaming } = useLinksFromLsp(); - // if (link.action === "commit") { - // // TODO: there should be an endpoint for this - // void applyPatches(messages).then(() => { - // if ("goto" in link && link.goto) { - // handleGoTo(link.goto); - // } - // }); - - // return; - // } - - // eslint-disable-next-line no-console - console.warn(`unknown action: ${JSON.stringify(link)}`); - }; - const handleClick = (link: ChatLink) => { - handleLinkAction(link); - }; - - const skipLinksRequest = useMemo(() => { - const lastMessageIsUserMessage = - messages.length > 0 && isUserMessage(messages[messages.length - 1]); - if (!model) return true; - if (!caps.data) return true; - return ( - isStreaming || isWaiting || unCalledTools || lastMessageIsUserMessage - ); - }, [caps.data, isStreaming, isWaiting, messages, model, unCalledTools]); - - const linksResult = linksApi.useGetLinksForChatQuery( - { - chat_id: chatId, - messages, - model: model ?? "", - mode: threadMode, // TODO: Changing thread mode invalidates the cache. - current_config_file: maybeIntegration?.path, - }, - { skip: skipLinksRequest }, - ); + if (streaming) return null; // TODO: waiting, errors, maybe add a title - if (isStreaming || isWaiting || unCalledTools) { - return null; - } - - const Wrapper = messages.length === 0 ? Box : Container; - - if (linksResult.isLoading) { + if (linksResult.isLoading || linksResult.isFetching) { return ( - - - + ); } if (linksResult.data && linksResult.data.links.length > 0) { - return ( - - - {linksResult.data.links.map((link, index) => { - const key = `chat-link-${index}`; - return ( - - ); - })} - - - ); + return linksResult.data.links.map((link, index) => { + const key = `chat-link-${index}`; + return ( + + ); + }); } return null; diff --git a/src/components/IntegrationsView/CustomFieldsAndWidgets.tsx b/src/components/IntegrationsView/CustomFieldsAndWidgets.tsx index 61c2750d..a8fdb200 100644 --- a/src/components/IntegrationsView/CustomFieldsAndWidgets.tsx +++ b/src/components/IntegrationsView/CustomFieldsAndWidgets.tsx @@ -18,7 +18,6 @@ export const CustomInputField = ({ type, id, name, - width, size = "long", color = "gray", onChange, @@ -48,18 +47,7 @@ export const CustomInputField = ({ onChange?: ChangeEventHandler; }) => { return ( - + {size !== "multiline" ? ( = ({ weight="medium" align={isNotConfigured ? "center" : "left"} > - {/* {toPascalCase( - integration.integr_name.startsWith("cmdline") - ? integration.integr_name.split("_")[0] - : integration.integr_name, - )} */} - {toPascalCase(integration.integr_name)} + {integration.integr_name.includes("TEMPLATE") + ? integration.integr_name.startsWith("cmdline") + ? "Command-line Tool" + : "Command-line Service" + : toPascalCase(integration.integr_name)} {!isNotConfigured && ( = ({ /> ))} - - {dockerData.smartlinks.map((smartlink) => ( - - ))} + + + Ask AI to do it for you (experimental) + + + {dockerData.smartlinks.map((smartlink) => ( + + ))} + ); diff --git a/src/components/IntegrationsView/IntegrationForm/IntegrationAvailability.tsx b/src/components/IntegrationsView/IntegrationForm/IntegrationAvailability.tsx index 7e79e641..d978cbb1 100644 --- a/src/components/IntegrationsView/IntegrationForm/IntegrationAvailability.tsx +++ b/src/components/IntegrationsView/IntegrationForm/IntegrationAvailability.tsx @@ -1,4 +1,4 @@ -import { DataList, Flex, Switch } from "@radix-ui/themes"; +import { Flex, Switch } from "@radix-ui/themes"; import type { FC } from "react"; import { CustomLabel } from "../CustomFieldsAndWidgets"; import { toPascalCase } from "../../../utils/toPascalCase"; @@ -18,32 +18,27 @@ export const IntegrationAvailability: FC = ({ onChange(fieldName, checked); }; + // TODO: temporal solution to hide the switch for isolated mode + if (fieldName === "when_isolated") return null; + return ( - - - - - - - + + - - + + + + ); }; diff --git a/src/components/IntegrationsView/IntegrationForm/IntegrationForm.module.css b/src/components/IntegrationsView/IntegrationForm/IntegrationForm.module.css index d91ac723..d5c5c8b0 100644 --- a/src/components/IntegrationsView/IntegrationForm/IntegrationForm.module.css +++ b/src/components/IntegrationsView/IntegrationForm/IntegrationForm.module.css @@ -13,3 +13,38 @@ max-width: 40px; object-fit: cover; } +.advancedButton { + background-color: transparent; + text-decoration: underline; + border-radius: 0; + cursor: pointer; + overflow: hidden; + text-wrap: nowrap; +} +.applyButton { + min-width: 140px; + display: block; +} + +@media (max-width: 538px) { + .applyButton { + width: 50%; + } +} +@media (max-width: 238px) { + .applyButton { + width: 100%; + } +} +@media (max-width: 368px) { + .switchInline { + flex-direction: column; + gap: 0; + margin-bottom: 10px; + } +} + +.gridContainer { + grid-template-columns: repeat(1, 1fr); + gap: 10px; +} diff --git a/src/components/IntegrationsView/IntegrationForm/IntegrationForm.tsx b/src/components/IntegrationsView/IntegrationForm/IntegrationForm.tsx index 8a520891..a003ccad 100644 --- a/src/components/IntegrationsView/IntegrationForm/IntegrationForm.tsx +++ b/src/components/IntegrationsView/IntegrationForm/IntegrationForm.tsx @@ -11,7 +11,7 @@ import type { import styles from "./IntegrationForm.module.css"; import { Spinner } from "../../Spinner"; -import { Button, DataList, Flex, Heading } from "@radix-ui/themes"; +import { Button, Flex, Grid, Heading, Text } from "@radix-ui/themes"; import { IntegrationDocker } from "../IntegrationDocker"; import { SmartLink } from "../../SmartLink"; import { renderIntegrationFormField } from "../../../features/Integrations/renderIntegrationFormField"; @@ -20,6 +20,26 @@ import { toPascalCase } from "../../../utils/toPascalCase"; import { debugIntegrations } from "../../../debugConfig"; import { iconMap } from "../icons/iconMap"; +// TODO: should be extracted in the future +function jsonHasWhenIsolated( + json: unknown, +): json is Record & { when_isolated: boolean } { + return ( + typeof json === "object" && + json !== null && + "when_isolated" in json && + typeof json.when_isolated === "boolean" + ); +} + +function areAllFieldsBoolean(json: unknown): json is Record { + return ( + typeof json === "object" && + json !== null && + Object.values(json).every((value) => typeof value === "boolean") + ); +} + type IntegrationFormProps = { integrationPath: string; isApplying: boolean; @@ -64,7 +84,8 @@ export const IntegrationForm: FC = ({ useEffect(() => { if ( integration.data?.integr_values.available && - typeof integration.data.integr_values.available === "object" + typeof integration.data.integr_values.available === "object" && + areAllFieldsBoolean(integration.data.integr_values.available) ) { Object.entries(integration.data.integr_values.available).forEach( ([key, value]) => { @@ -121,58 +142,59 @@ export const IntegrationForm: FC = ({ return ( + {integration.data.integr_schema.description && ( + + {integration.data.integr_schema.description} + + )}
- - {integration.data.integr_values.available && - Object.entries(integration.data.integr_values.available).map( - ([key, _]: [string, boolean]) => ( - - ), - )} - {Object.keys(importantFields).map((fieldKey) => { - if (integration.data) { - return renderIntegrationFormField({ - fieldKey: fieldKey, - values: integration.data.integr_values, - field: integration.data.integr_schema.fields[fieldKey], - integrationName: integration.data.integr_name, - integrationPath: integration.data.integr_config_path, - integrationProject: integration.data.project_path, - }); - } - })} - {Object.keys(extraFields).map((fieldKey) => { - if (integration.data) { - return renderIntegrationFormField({ - fieldKey: fieldKey, - values: integration.data.integr_values, - field: integration.data.integr_schema.fields[fieldKey], - integrationName: integration.data.integr_name, - integrationPath: integration.data.integr_config_path, - integrationProject: integration.data.project_path, - isFieldVisible: areExtraFieldsRevealed, - }); - } - })} - + + + {integration.data.integr_values.available && + Object.entries(integration.data.integr_values.available).map( + ([key, _]: [string, boolean]) => ( + + ), + )} + + + {Object.keys(importantFields).map((fieldKey) => { + if (integration.data) { + return renderIntegrationFormField({ + fieldKey: fieldKey, + values: integration.data.integr_values, + field: integration.data.integr_schema.fields[fieldKey], + integrationName: integration.data.integr_name, + integrationPath: integration.data.integr_config_path, + integrationProject: integration.data.project_path, + }); + } + })} + {Object.keys(extraFields).map((fieldKey) => { + if (integration.data) { + return renderIntegrationFormField({ + fieldKey: fieldKey, + values: integration.data.integr_values, + field: integration.data.integr_schema.fields[fieldKey], + integrationName: integration.data.integr_name, + integrationPath: integration.data.integr_config_path, + integrationProject: integration.data.project_path, + isFieldVisible: areExtraFieldsRevealed, + }); + } + })} + + {Object.values(extraFields).length > 0 && ( )} - + - {integration.data.integr_schema.smartlinks && ( - - {integration.data.integr_schema.smartlinks.map( - (smartlink, index) => { - return ( - - ); - }, - )} - - )} - {integration.data.integr_schema.docker && ( - - - {integration.data.integr_name} - - {toPascalCase(integration.data.integr_name)} Containers + {integration.data.integr_schema.smartlinks && + integration.data.integr_schema.smartlinks.length > 0 && ( + + + Ask AI to do it for you (experimental) + + {integration.data.integr_schema.smartlinks.map( + (smartlink, index) => { + return ( + + ); + }, + )} + - - - )} + )} + {integration.data.integr_schema.docker && + jsonHasWhenIsolated(integration.data.integr_values.available) && + integration.data.integr_values.available.when_isolated && ( + + + {integration.data.integr_name} + + {toPascalCase(integration.data.integr_name)} Containers + + + + + )} ); }; diff --git a/src/components/IntegrationsView/IntegrationsHeader.module.css b/src/components/IntegrationsView/IntegrationsHeader.module.css index b630ef9c..1e815881 100644 --- a/src/components/IntegrationsView/IntegrationsHeader.module.css +++ b/src/components/IntegrationsView/IntegrationsHeader.module.css @@ -10,6 +10,6 @@ } .IntegrationsHeaderIcon { - max-width: 30px; + max-width: 24px; object-fit: cover; } diff --git a/src/components/IntegrationsView/IntegrationsHeader.tsx b/src/components/IntegrationsView/IntegrationsHeader.tsx index 4330ee7b..f4479710 100644 --- a/src/components/IntegrationsView/IntegrationsHeader.tsx +++ b/src/components/IntegrationsView/IntegrationsHeader.tsx @@ -4,6 +4,7 @@ import type { FC } from "react"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; import styles from "./IntegrationsHeader.module.css"; import { LeftRightPadding } from "../../features/Integrations/Integrations"; +import { toPascalCase } from "../../utils/toPascalCase"; type IntegrationsHeaderProps = { handleFormReturn: () => void; @@ -25,8 +26,8 @@ export const IntegrationsHeader: FC = ({ @@ -40,15 +41,20 @@ export const IntegrationsHeader: FC = ({ )} - - Setup {integrationName} + {integrationName} + + Setup{" "} + {integrationName.includes("TEMPLATE") + ? integrationName.startsWith("cmdline") + ? "Command Line Tool" + : "Command Line Service" + : toPascalCase(integrationName)} - {integrationName}
); diff --git a/src/components/IntegrationsView/IntegrationsView.tsx b/src/components/IntegrationsView/IntegrationsView.tsx index 144758eb..baed17be 100644 --- a/src/components/IntegrationsView/IntegrationsView.tsx +++ b/src/components/IntegrationsView/IntegrationsView.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, Heading, Text } from "@radix-ui/themes"; +import { Box, Flex, Heading, Text, Grid } from "@radix-ui/themes"; import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; import type { FC, FormEvent } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -22,13 +22,16 @@ import { import { useAppDispatch, useAppSelector } from "../../hooks"; import { useSaveIntegrationData } from "../../hooks/useSaveIntegrationData"; import { + areIntegrationsNotConfigured, dockerApi, + GroupedIntegrationWithIconRecord, Integration, integrationsApi, IntegrationWithIconRecord, IntegrationWithIconResponse, isDetailMessage, isNotConfiguredIntegrationWithIconRecord, + isPrimitive, NotConfiguredIntegrationWithIconRecord, } from "../../services/refact"; import { ErrorCallout } from "../Callout"; @@ -41,7 +44,8 @@ import { IntegrationsHeader } from "./IntegrationsHeader"; import styles from "./IntegrationsView.module.css"; import { iconMap } from "./icons/iconMap"; import { LeftRightPadding } from "../../features/Integrations/Integrations"; -import { IntegrationCmdline } from "./IntegrationCmdline"; +import { IntermediateIntegration } from "./IntermediateIntegration"; +import { parseOrElse } from "../../utils"; type IntegrationViewProps = { integrationsMap?: IntegrationWithIconResponse; @@ -76,6 +80,7 @@ export const IntegrationsView: FC = ({ const maybeIntegration = useMemo(() => { if (!currentThreadIntegration) return null; if (!integrationsMap) return null; + // TODO: check for extra flag in currentThreadIntegration to return different find() call from notConfiguredGrouped integrations if it's set to true return ( integrationsMap.integrations.find( (integration) => @@ -93,6 +98,19 @@ export const IntegrationsView: FC = ({ const [currentNotConfiguredIntegration, setCurrentNotConfiguredIntegration] = useState(null); + // TODO: uncomment when ready + // useEffect(() => { + // if (maybeIntegration) { + // if (maybeIntegration.shouldBeOpenedOnIntermediatePage) { + // setNotConfiguredIntegration(maybeIntegration); + // setCurrentIntegration(null); + // } else { + // setCurrentIntegration(maybeIntegration); + // setNotConfiguredIntegration(null); + // } + // } + // }, [maybeIntegration]); + useEffect(() => { if (maybeIntegration) { setCurrentIntegration(maybeIntegration); @@ -166,47 +184,50 @@ export const IntegrationsView: FC = ({ } }, [projectSpecificIntegrations]); - const nonConfiguredIntegrations = useMemo(() => { + const availableIntegrationsToConfigure = useMemo(() => { if (integrationsMap?.integrations) { const groupedIntegrations = integrationsMap.integrations.reduce< - Record + Record >((acc, integration) => { - if (!integration.integr_config_exists) { - if (!(integration.integr_name in acc)) { - acc[integration.integr_name] = { - ...integration, - project_path: [integration.project_path], - integr_config_path: [integration.integr_config_path], - integr_config_exists: false, - }; - } else { - if ( - !acc[integration.integr_name].project_path.includes( - integration.project_path, - ) - ) { - acc[integration.integr_name].project_path.push( - integration.project_path, - ); - } - acc[integration.integr_name].integr_config_path.push( - integration.integr_config_path, - ); - } + if (!(integration.integr_name in acc)) { + acc[integration.integr_name] = { + ...integration, + project_path: [integration.project_path], + integr_config_path: [integration.integr_config_path], + }; + } else { + acc[integration.integr_name].project_path.push( + integration.project_path, + ); + acc[integration.integr_name].integr_config_path.push( + integration.integr_config_path, + ); } return acc; }, {}); - return Object.values(groupedIntegrations); + const filteredIntegrations = Object.values(groupedIntegrations).filter( + areIntegrationsNotConfigured, + ); + + // Sort paths so that paths containing ".config" are first + Object.values(filteredIntegrations).forEach((integration) => { + integration.project_path.sort((a, _b) => (a === "" ? -1 : 1)); + integration.integr_config_path.sort((a, _b) => + a.includes(".config") ? -1 : 1, + ); + }); + + return Object.values(filteredIntegrations); } }, [integrationsMap]); useEffect(() => { debugIntegrations( - `[DEBUG]: nonConfiguredIntegrations: `, - nonConfiguredIntegrations, + `[DEBUG]: availableIntegrationsToConfigure: `, + availableIntegrationsToConfigure, ); - }, [nonConfiguredIntegrations]); + }, [availableIntegrationsToConfigure]); const handleSetCurrentIntegrationSchema = ( schema: Integration["integr_schema"], @@ -233,6 +254,7 @@ export const IntegrationsView: FC = ({ setCurrentNotConfiguredIntegration(null); setIsDisabledIntegrationForm(true); } + setAvailabilityValues({}); information && dispatch(clearInformation()); globalError && dispatch(clearError()); dispatch(integrationsApi.util.resetApiState()); @@ -277,6 +299,18 @@ export const IntegrationsView: FC = ({ case "bool": acc[key] = rawFormValues[key] === "on" ? true : false; break; + case "tool": + acc[key] = parseOrElse( + rawFormValues[key] as string, + {}, + ); + break; + case "output": + acc[key] = parseOrElse( + rawFormValues[key] as string, + {}, + ); + break; default: acc[key] = rawFormValues[key] as string; break; @@ -349,6 +383,18 @@ export const IntegrationsView: FC = ({ case "bool": acc[key] = rawFormValues[key] === "on" ? true : false; break; + case "tool": + acc[key] = parseOrElse( + rawFormValues[key] as string, + {}, + ); + break; + case "output": + acc[key] = parseOrElse( + rawFormValues[key] as string, + {}, + ); + break; default: acc[key] = rawFormValues[key] as string; break; @@ -358,13 +404,28 @@ export const IntegrationsView: FC = ({ const eachFormValueIsNotChanged = Object.entries(formValues).every( ([fieldKey, fieldValue]) => { - return ( - fieldKey in currentIntegrationValues && - fieldValue === currentIntegrationValues[fieldKey] - ); + if (isPrimitive(fieldValue)) { + return ( + fieldKey in currentIntegrationValues && + fieldValue === currentIntegrationValues[fieldKey] + ); + } + // TODO: better comparison of objects? + if (typeof fieldValue === "object") { + return ( + fieldKey in currentIntegrationValues && + JSON.stringify(fieldValue) === + JSON.stringify(currentIntegrationValues[fieldKey]) + ); + } }, ); + debugIntegrations( + `[DEBUG]: eachFormValueIsNotChanged: `, + eachFormValueIsNotChanged, + ); + const eachAvailabilityOptionIsNotChanged = Object.entries( availabilityValues, ).every(([fieldKey, fieldValue]) => { @@ -376,6 +437,17 @@ export const IntegrationsView: FC = ({ } return false; }); + + debugIntegrations(`[DEBUG]: formValues: `, formValues); + debugIntegrations( + `[DEBUG]: currentIntegrationValues: `, + currentIntegrationValues, + ); + debugIntegrations( + `[DEBUG]: eachAvailabilityOptionIsNotChanged: `, + eachAvailabilityOptionIsNotChanged, + ); + debugIntegrations(`[DEBUG]: availabilityValues: `, availabilityValues); const maybeDisabled = eachFormValueIsNotChanged && eachAvailabilityOptionIsNotChanged; debugIntegrations(`[DEBUG CHANGE]: maybeDisabled: `, maybeDisabled); @@ -407,11 +479,10 @@ export const IntegrationsView: FC = ({ ) { // making integration-get call and setting the result as currentIntegration const commandName = rawFormValues.command_name; - const configPath = - rawFormValues.integr_config_path.split("_")[0] + - "_" + - commandName + - ".yaml"; + const configPath = rawFormValues.integr_config_path.replace( + "TEMPLATE", + commandName, + ); debugIntegrations( `[DEBUG]: config path for \`v1/integration-get\`: `, @@ -585,7 +656,7 @@ export const IntegrationsView: FC = ({ justify="between" height="100%" > - handleNotConfiguredIntegrationSubmit(event) } @@ -735,9 +806,14 @@ export const IntegrationsView: FC = ({ Add new integration
- - {nonConfiguredIntegrations && - Object.entries(nonConfiguredIntegrations).map( + + {availableIntegrationsToConfigure && + Object.entries(availableIntegrationsToConfigure).map( ([_projectPath, integration], index) => { return ( = ({ ); }, )} - + )} diff --git a/src/components/IntegrationsView/IntegrationCmdline/IntegrationCmdline.module.css b/src/components/IntegrationsView/IntermediateIntegration/IntermediateIntegration.module.css similarity index 100% rename from src/components/IntegrationsView/IntegrationCmdline/IntegrationCmdline.module.css rename to src/components/IntegrationsView/IntermediateIntegration/IntermediateIntegration.module.css diff --git a/src/components/IntegrationsView/IntegrationCmdline/IntegrationCmdline.tsx b/src/components/IntegrationsView/IntermediateIntegration/IntermediateIntegration.tsx similarity index 84% rename from src/components/IntegrationsView/IntegrationCmdline/IntegrationCmdline.tsx rename to src/components/IntegrationsView/IntermediateIntegration/IntermediateIntegration.tsx index 0c575c1b..c6385a3b 100644 --- a/src/components/IntegrationsView/IntegrationCmdline/IntegrationCmdline.tsx +++ b/src/components/IntegrationsView/IntermediateIntegration/IntermediateIntegration.tsx @@ -9,13 +9,15 @@ import { Text, } from "@radix-ui/themes"; import { iconMap } from "../icons/iconMap"; -import styles from "./IntegrationCmdline.module.css"; +import styles from "./IntermediateIntegration.module.css"; import { toPascalCase } from "../../../utils/toPascalCase"; import { formatProjectName } from "../../../utils/formatProjectName"; import { CustomInputField } from "../CustomFieldsAndWidgets"; import { Link } from "../../Link"; +import { useGetIntegrationDataByPathQuery } from "../../../hooks/useGetIntegrationDataByPathQuery"; const validateSnakeCase = (value: string) => { + // TODO: include numbers 0-9 const snakeCaseRegex = /^[a-z]+(_[a-z]+)*$/; return snakeCaseRegex.test(value); }; @@ -50,7 +52,7 @@ const renderIntegrationCmdlineField = ({ const CMDLINE_TOOLS = ["cmdline", "service"]; -export const IntegrationCmdline: FC = ({ +export const IntermediateIntegration: FC = ({ integration, handleSubmit, }) => { @@ -60,6 +62,10 @@ export const IntegrationCmdline: FC = ({ const [commandName, setCommandName] = useState(""); const [errorMessage, setErrorMessage] = useState(""); + const { integration: relatedIntegration } = useGetIntegrationDataByPathQuery( + integration.integr_config_path[0], + ); + const handleCommandNameChange: ChangeEventHandler = ( event, ) => { @@ -83,10 +89,17 @@ export const IntegrationCmdline: FC = ({ className={styles.integrationIcon} /> {isIntegrationAComamndLine - ? "Command Line Tool" + ? `Command Line ${ + integrationType.includes("cmdline") ? "Tool" : "Service" + }` : toPascalCase(integrationType)} + {relatedIntegration.data?.integr_schema.description && ( + + {relatedIntegration.data.integr_schema.description} + + )} Please, choose where you want to setup your integration @@ -104,7 +117,9 @@ export const IntegrationCmdline: FC = ({ {renderIntegrationCmdlineField({ path, - label: !shouldPathBeFormatted ? "Global" : path, + label: !shouldPathBeFormatted + ? "Global (IDE level) configuration" + : path, shouldBeFormatted: shouldPathBeFormatted, })} @@ -150,7 +165,7 @@ export const IntegrationCmdline: FC = ({ } title={ !!errorMessage || !commandName - ? "Please, fix all issues with the data" + ? "Please, fill out all required fields first" : "Continue setting up integration" } > diff --git a/src/components/IntegrationsView/IntermediateIntegration/index.ts b/src/components/IntegrationsView/IntermediateIntegration/index.ts new file mode 100644 index 00000000..ac816c80 --- /dev/null +++ b/src/components/IntegrationsView/IntermediateIntegration/index.ts @@ -0,0 +1 @@ +export { IntermediateIntegration } from "./IntermediateIntegration"; diff --git a/src/components/IntegrationsView/icons/iconMap.ts b/src/components/IntegrationsView/icons/iconMap.ts index 08511206..8db67c60 100644 --- a/src/components/IntegrationsView/icons/iconMap.ts +++ b/src/components/IntegrationsView/icons/iconMap.ts @@ -12,6 +12,7 @@ export const iconMap: Record = { postgres: postgresIcon, mysql: mysqlIcon, docker: dockerIcon, + isolation: dockerIcon, chrome: chromeIcon, pdb: pdbIcon, github: githubIcon, diff --git a/src/components/SmartLink/SmartLink.module.css b/src/components/SmartLink/SmartLink.module.css new file mode 100644 index 00000000..4b5bc6df --- /dev/null +++ b/src/components/SmartLink/SmartLink.module.css @@ -0,0 +1,11 @@ +@media screen and (max-width: 387px) { + .magicButton { + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + text-align: left; + justify-content: flex-start; + display: inline; + } +} diff --git a/src/components/SmartLink/SmartLink.tsx b/src/components/SmartLink/SmartLink.tsx index 7750737e..a91a32fe 100644 --- a/src/components/SmartLink/SmartLink.tsx +++ b/src/components/SmartLink/SmartLink.tsx @@ -1,62 +1,9 @@ import { useCallback } from "react"; import type { FC } from "react"; -import type { - LspChatMessage, - SmartLink as SmartLinkType, -} from "../../services/refact"; -import { - type OpenFilePayload, - useAppDispatch, - useEventsBusForIDE, -} from "../../hooks"; -import { formatMessagesForChat } from "../../features/Chat/Thread/utils"; -import { clearInformation } from "../../features/Errors/informationSlice"; -import { newIntegrationChat } from "../../features/Chat"; -import { push } from "../../features/Pages/pagesSlice"; +import type { SmartLink as SmartLinkType } from "../../services/refact"; import { Button, DropdownMenu } from "@radix-ui/themes"; -import { AppDispatch } from "../../app/store"; - -const handleGotoAction = ( - sl_goto: string, - queryPathThenOpenFile: (file: OpenFilePayload) => Promise, -) => { - const [action, payload] = sl_goto.split(":"); - switch (action.toLowerCase()) { - // TODO: could be possible to share it between Marc's implementation - case "editor": - void queryPathThenOpenFile({ file_name: payload }); - break; - case "setting": - // Handling SETTING smartlink action - break; - default: - // For unexpected actions - break; - } -}; - -const handleChatAction = ( - sl_chat: LspChatMessage[], - dispatch: AppDispatch, - integrationName: string, - integrationPath: string, - integrationProject: string, -) => { - const messages = formatMessagesForChat(sl_chat); - - dispatch(clearInformation()); - dispatch( - newIntegrationChat({ - integration: { - name: integrationName, - path: integrationPath, - project: integrationProject, - }, - messages, - }), - ); - dispatch(push({ name: "chat" })); -}; +import { useSmartLinks } from "../../hooks"; +import styles from "./SmartLink.module.css"; export const SmartLink: FC<{ smartlink: SmartLinkType; @@ -73,21 +20,18 @@ export const SmartLink: FC<{ isSmall = false, isDockerSmartlink = false, }) => { - const dispatch = useAppDispatch(); - - const { queryPathThenOpenFile } = useEventsBusForIDE(); + const { handleGoTo, handleSmartLink } = useSmartLinks(); const { sl_goto, sl_chat } = smartlink; const handleClick = useCallback(() => { if (sl_goto) { - handleGotoAction(sl_goto, queryPathThenOpenFile); + handleGoTo(sl_goto); return; } if (sl_chat) { - handleChatAction( + handleSmartLink( sl_chat, - dispatch, integrationName, integrationPath, integrationProject, @@ -96,10 +40,10 @@ export const SmartLink: FC<{ }, [ sl_goto, sl_chat, - dispatch, + handleGoTo, + handleSmartLink, integrationName, integrationPath, - queryPathThenOpenFile, integrationProject, ]); @@ -114,7 +58,7 @@ export const SmartLink: FC<{ onClick={handleClick} title={title ? title.join("\n") : ""} > - {smartlink.sl_label} 🪄 + ✨ {smartlink.sl_label} ) : ( ); diff --git a/src/components/Toolbar/Dropdown.tsx b/src/components/Toolbar/Dropdown.tsx index 5d82c542..8c4e3d59 100644 --- a/src/components/Toolbar/Dropdown.tsx +++ b/src/components/Toolbar/Dropdown.tsx @@ -11,7 +11,7 @@ import { import { useOpenUrl } from "../../hooks/useOpenUrl"; import { DropdownMenu, Flex, IconButton } from "@radix-ui/themes"; import { HamburgerMenuIcon, DiscordLogoIcon } from "@radix-ui/react-icons"; -import { Coin } from "../../images"; +//import { Coin } from "../../images"; export type DropdownNavigationOptions = | "fim" @@ -85,13 +85,15 @@ export const Dropdown: React.FC = ({ )} + {/* + Hide coins (until coins logic is reworked) {user.data && ( {user.data.metering_balance} coins - )} + )} */} {user.data && ( @@ -100,20 +102,6 @@ export const Dropdown: React.FC = ({ )} - handleNavigation("stats")}> - Your Stats - - - { - event.preventDefault(); - logout(); - handleNavigation("cloud login"); - }} - > - Logout - - { event.preventDefault(); @@ -126,26 +114,19 @@ export const Dropdown: React.FC = ({ - { - event.preventDefault(); - openUrl(bugUrl); - }} - > - Report a bug... - - - handleNavigation("restart tour")}> - Restart tour + handleNavigation("integrations")}> + Setup Agent Integrations - handleNavigation("fim")}> - Fill-in-the-middle Context + handleNavigation("hot keys")}> + IDE Hotkeys Settings - + handleNavigation("settings")}> + IDE Settings + { @@ -172,16 +153,38 @@ export const Dropdown: React.FC = ({ Edit Bring Your Own Key )} - handleNavigation("integrations")}> - Setup Agent Integrations + + + + handleNavigation("restart tour")}> + Restart tour - handleNavigation("hot keys")}> - Hot Keys... + { + event.preventDefault(); + openUrl(bugUrl); + }} + > + Report a bug - handleNavigation("settings")}> - Settings... + handleNavigation("fim")}> + Fill-in-the-middle Context + + + handleNavigation("stats")}> + Your Stats + + + { + event.preventDefault(); + logout(); + handleNavigation("cloud login"); + }} + > + Logout diff --git a/src/debugConfig.ts b/src/debugConfig.ts index 6b532bc8..d6ccf008 100644 --- a/src/debugConfig.ts +++ b/src/debugConfig.ts @@ -7,15 +7,15 @@ export const debugApp = createDebug("app"); export const debugComponent = createDebug("component"); export const debugIntegrations = createDebug("integrations"); -// createDebug.enable("root"); +createDebug.enable("root"); debugRoot(`Debugging: ${debugNamespaces ? "enabled" : "disabled"}`); -// if (debugNamespaces) { -// if (debugNamespaces === "*") { -// debugRoot("Enabling debug logging for all namespaces"); -// createDebug.enable("*"); -// } else { -// debugRoot(`Enabling debug logging for namespaces [${debugNamespaces}]`); -// createDebug.enable(debugNamespaces); -// } -// } +if (debugNamespaces) { + if (debugNamespaces === "*") { + debugRoot("Enabling debug logging for all namespaces"); + createDebug.enable("*"); + } else { + debugRoot(`Enabling debug logging for namespaces [${debugNamespaces}]`); + createDebug.enable(debugNamespaces); + } +} diff --git a/src/features/Chat/Chat.test.tsx b/src/features/Chat/Chat.test.tsx index f9a29d34..da000af8 100644 --- a/src/features/Chat/Chat.test.tsx +++ b/src/features/Chat/Chat.test.tsx @@ -43,6 +43,7 @@ import { noCompletions, goodUser, goodPing, + chatLinks, } from "../../utils/mockServer"; const handlers = [ @@ -53,6 +54,7 @@ const handlers = [ noCompletions, goodUser, goodPing, + chatLinks, ]; // const handlers = [ @@ -210,7 +212,7 @@ describe("Chat", () => { }, }); }, - { once: true }, + // { once: true }, // TODO: title ), ); @@ -226,7 +228,7 @@ describe("Chat", () => { // app.debug(select.parentElement?.parentElement, 10000); // expect(app.container.textContent).toContain("gpt-3.5-turbo"); - expect(screen.findByText("gpt-3.5-turbo")).not.toBeNull(); + expect(screen.findByText("gpt-4o")).not.toBeNull(); }); const textarea = screen.getByTestId("chat-form-textarea"); @@ -373,6 +375,7 @@ describe("Chat", () => { noCommandPreview, noCompletions, noTools, + chatLinks, ); server.use( http.post( @@ -396,7 +399,7 @@ describe("Chat", () => { }, }); }, - { once: true }, + // { once: true }, TODO: title ), ); const { user, ...app } = render(); diff --git a/src/features/Chat/Thread/actions.ts b/src/features/Chat/Thread/actions.ts index 2b043706..73c55b2e 100644 --- a/src/features/Chat/Thread/actions.ts +++ b/src/features/Chat/Thread/actions.ts @@ -8,9 +8,11 @@ import { LspChatMode, } from "./types"; import { + isAssistantDelta, isAssistantMessage, isCDInstructionMessage, - isChatGetTitleResponse, + isChatResponseChoice, + // isChatGetTitleResponse, isToolCallMessage, isToolMessage, ToolCall, @@ -25,6 +27,7 @@ import { generateChatTitle, sendChat } from "../../../services/refact/chat"; import { ToolCommand } from "../../../services/refact/tools"; import { scanFoDuplicatesWith, takeFromEndWhile } from "../../../utils"; import { sendTelemetryEvent } from "../../../utils/telemetryHelper"; +import { debugApp } from "../../../debugConfig"; export const newChatAction = createAction("chatThread/new"); @@ -37,6 +40,10 @@ export const chatResponse = createAction( "chatThread/response", ); +export const chatTitleGenerationResponse = createAction< + PayloadWithId & ChatResponse +>("chatTitleGeneration/response"); + export const chatAskedQuestion = createAction( "chatThread/askQuestion", ); @@ -111,18 +118,20 @@ export const chatGenerateTitleThunk = createAppAsyncThunk< >("chatThread/generateTitle", ({ messages, chatId }, thunkAPI) => { const state = thunkAPI.getState(); - const messagesToSend = messages - .filter((msg) => !isToolMessage(msg) && msg.content !== "") - .map((msg) => { - if (isAssistantMessage(msg)) { - return { - role: msg.role, - content: msg.content, - }; - } - return msg; - }); - + const messagesToSend = messages.filter( + (msg) => + !isToolMessage(msg) && !isAssistantMessage(msg) && msg.content !== "", + ); + // .map((msg) => { + // if (isAssistantMessage(msg)) { + // return { + // role: msg.role, + // content: msg.content, + // }; + // } + // return msg; + // }); + debugApp(`[DEBUG TITLE]: messagesToSend: `, messagesToSend); const messagesForLsp = formatMessagesForLsp([ ...messagesToSend, { @@ -132,6 +141,8 @@ export const chatGenerateTitleThunk = createAppAsyncThunk< }, ]); + const chatResponseChunks: ChatResponse[] = []; + return generateChatTitle({ messages: messagesForLsp, model: state.chat.thread.model, @@ -144,26 +155,33 @@ export const chatGenerateTitleThunk = createAppAsyncThunk< if (!response.ok) { return Promise.reject(new Error(response.statusText)); } - return response.json(); - }) - .then((data) => { - if (!isChatGetTitleResponse(data)) { - return; - } - - const title = data.choices[0].message.content; - const cleanedTitle = title.replace(/"/g, ""); - - // Dispatching saveTitle action for a chatThread - thunkAPI.dispatch( - saveTitle({ id: chatId, title: cleanedTitle, isTitleGenerated: true }), - ); - return { title: cleanedTitle, chatId: state.chat.thread.id }; + const reader = response.body?.getReader(); + if (!reader) return; + const onAbort = () => thunkAPI.dispatch(setPreventSend({ id: chatId })); + const onChunk = (json: Record) => { + chatResponseChunks.push(json as ChatResponse); + }; + return consumeStream(reader, thunkAPI.signal, onAbort, onChunk); }) .catch((err: Error) => { - // console.log("Catch called"); + thunkAPI.dispatch(doneStreaming({ id: chatId })); thunkAPI.dispatch(chatError({ id: chatId, message: err.message })); return thunkAPI.rejectWithValue(err.message); + }) + .finally(() => { + const title = chatResponseChunks.reduce((acc, chunk) => { + if (isChatResponseChoice(chunk)) { + if (isAssistantDelta(chunk.choices[0].delta)) { + return acc + chunk.choices[0].delta.content; + } + } + return acc; + }, ""); + + thunkAPI.dispatch( + saveTitle({ id: chatId, title, isTitleGenerated: true }), + ); + thunkAPI.dispatch(doneStreaming({ id: chatId })); }); }); diff --git a/src/features/Chat/Thread/reducer.ts b/src/features/Chat/Thread/reducer.ts index eae45f1d..3bdafa37 100644 --- a/src/features/Chat/Thread/reducer.ts +++ b/src/features/Chat/Thread/reducer.ts @@ -48,7 +48,7 @@ const createChatThread = ( }; const createInitialState = ( - tool_use: ToolUse = "explore", + tool_use: ToolUse = "agent", integration?: IntegrationMeta | null, maybeMode?: LspChatMode, ): Chat => { diff --git a/src/features/Integrations/renderIntegrationFormField.module.css b/src/features/Integrations/renderIntegrationFormField.module.css new file mode 100644 index 00000000..aaa6ca3d --- /dev/null +++ b/src/features/Integrations/renderIntegrationFormField.module.css @@ -0,0 +1,9 @@ +@media screen and (min-width: 424px) { + .flexField { + flex-direction: row; + margin-bottom: 15px; + } + .flexLabel { + width: 200px; + } +} diff --git a/src/features/Integrations/renderIntegrationFormField.tsx b/src/features/Integrations/renderIntegrationFormField.tsx index bc3fd86e..7794c2f4 100644 --- a/src/features/Integrations/renderIntegrationFormField.tsx +++ b/src/features/Integrations/renderIntegrationFormField.tsx @@ -9,7 +9,8 @@ import type { IntegrationField, IntegrationPrimitive, } from "../../services/refact"; -import { DataList, Flex } from "@radix-ui/themes"; +import styles from "./renderIntegrationFormField.module.css"; +import { Flex } from "@radix-ui/themes"; import { toPascalCase } from "../../utils/toPascalCase"; import { SmartLink } from "../../components/SmartLink"; import { Markdown } from "../../components/Markdown"; @@ -86,62 +87,64 @@ export const renderIntegrationFormField = ({ const maybeSmartlinks = field.smartlinks; return ( - - + - - - - {f_type !== "bool" && f_type !== "output" && f_type !== "tool" && ( + + + {f_type !== "bool" && f_type !== "output" && f_type !== "tool" && ( + + )} + {f_type === "bool" && ( + + )} + {(f_type === "output" || f_type === "tool") && ( + <> + + {"```json\n" + + JSON.stringify(values[fieldKey], null, 2) + + "\n```"} + - )} - {f_type === "bool" && ( - - )} - {(f_type === "output" || f_type === "tool") && ( - <> - - {"```json\n" + - JSON.stringify(values[fieldKey], null, 2) + - "\n```"} - - - - )} - {field.f_desc && ( - {field.f_desc} - )} - {maybeSmartlinks && ( + + )} + {field.f_desc && ( + {field.f_desc} + )} + {/* TODO: implement EDITOR goto, and remove this condition */} + {maybeSmartlinks && + !maybeSmartlinks.every( + (smartlink) => smartlink.sl_goto?.startsWith("EDITOR"), + ) && ( {maybeSmartlinks.map((smartlink, index) => ( )} - - - + + ); }; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index aa04d7c9..8f9daf08 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,3 +22,6 @@ export * from "./useAppSelector"; export * from "./useSendChatRequest"; export * from "./usePatchActions"; export * from "./useGetUserSurvey"; +export * from "./useLinksFromLsp"; +export * from "./useGoToLink"; +export * from "./useSmartLinks"; diff --git a/src/hooks/useGoToLink.ts b/src/hooks/useGoToLink.ts new file mode 100644 index 00000000..e7051c2e --- /dev/null +++ b/src/hooks/useGoToLink.ts @@ -0,0 +1,59 @@ +import { useCallback } from "react"; +import { useEventsBusForIDE } from "./useEventBusForIDE"; +import { isAbsolutePath } from "../utils/isAbsolutePath"; +import { useAppDispatch } from "./useAppDispatch"; +import { popBackTo } from "../features/Pages/pagesSlice"; +import { useAppSelector } from "./useAppSelector"; +import { selectIntegration } from "../features/Chat/Thread/selectors"; + +export function useGoToLink() { + const dispatch = useAppDispatch(); + const { queryPathThenOpenFile } = useEventsBusForIDE(); + const maybeIntegration = useAppSelector(selectIntegration); + + const handleGoTo = useCallback( + (goto?: string) => { + if (!goto) return; + // TODO: duplicated in smart links. + const [action, payload] = goto.split(":"); + + switch (action.toLowerCase()) { + case "editor": { + void queryPathThenOpenFile({ file_name: payload }); + return; + } + case "settings": { + const isFile = isAbsolutePath(payload); + dispatch( + popBackTo({ + name: "integrations page", + // projectPath: isFile ? payload : "", + integrationName: + !isFile && payload !== "DEFAULT" + ? payload + : maybeIntegration?.name, + integrationPath: isFile ? payload : maybeIntegration?.path, + projectPath: maybeIntegration?.project, + }), + ); + // TODO: open in the integrations + return; + } + default: { + // eslint-disable-next-line no-console + console.log(`[DEBUG]: unexpected action, doing nothing`); + return; + } + } + }, + [ + dispatch, + maybeIntegration?.name, + maybeIntegration?.path, + maybeIntegration?.project, + queryPathThenOpenFile, + ], + ); + + return { handleGoTo }; +} diff --git a/src/hooks/useLinksFromLsp.ts b/src/hooks/useLinksFromLsp.ts new file mode 100644 index 00000000..6100809c --- /dev/null +++ b/src/hooks/useLinksFromLsp.ts @@ -0,0 +1,141 @@ +import React, { useCallback, useMemo } from "react"; +import { + diffApi, + isUserMessage, + linksApi, + type ChatLink, +} from "..//services/refact"; +import { useAppDispatch } from "./useAppDispatch"; +import { useAppSelector } from "./useAppSelector"; +import { useGetCapsQuery } from "./useGetCapsQuery"; +import { useSendChatRequest } from "./useSendChatRequest"; +import { + selectChatId, + selectIntegration, + selectIsStreaming, + selectIsWaiting, + selectMessages, + selectModel, + selectThreadMode, + selectThreadToolUse, + setIntegrationData, +} from "../features/Chat"; +import { useGoToLink } from "./useGoToLink"; + +export function useLinksFromLsp() { + const dispatch = useAppDispatch(); + const { handleGoTo } = useGoToLink(); + const { submit } = useSendChatRequest(); + + const [applyPatches, _applyPatchesResult] = + diffApi.useApplyAllPatchesInMessagesMutation(); + + const isStreaming = useAppSelector(selectIsStreaming); + const isWaiting = useAppSelector(selectIsWaiting); + const messages = useAppSelector(selectMessages); + const chatId = useAppSelector(selectChatId); + const maybeIntegration = useAppSelector(selectIntegration); + const threadMode = useAppSelector(selectThreadMode); + const toolUse = useAppSelector(selectThreadToolUse); + + // TODO: add the model + const caps = useGetCapsQuery(); + + const model = + useAppSelector(selectModel) || caps.data?.code_chat_default_model; + + const unCalledTools = React.useMemo(() => { + if (messages.length === 0) return false; + const last = messages[messages.length - 1]; + //TODO: handle multiple tool calls in last assistant message + if (last.role !== "assistant") return false; + const maybeTools = last.tool_calls; + if (maybeTools && maybeTools.length > 0) return true; + return false; + }, [messages]); + + const handleLinkAction = useCallback( + (link: ChatLink) => { + if (!("action" in link)) return; + + if (link.action === "goto" && "goto" in link) { + handleGoTo(link.goto); + return; + } + + if (link.action === "patch-all") { + void applyPatches(messages).then(() => { + if ("goto" in link) { + handleGoTo(link.goto); + } + }); + return; + } + + if (link.action === "follow-up") { + submit(link.text); + return; + } + + if (link.action === "summarize-project") { + if ("current_config_file" in link && link.current_config_file) { + dispatch(setIntegrationData({ path: link.current_config_file })); + // set the integration data + } + submit(link.text, "PROJECT_SUMMARY"); + return; + } + + // if (link.action === "commit") { + // // TODO: there should be an endpoint for this + // void applyPatches(messages).then(() => { + // if ("goto" in link && link.goto) { + // handleGoTo(link.goto); + // } + // }); + + // return; + // } + + // eslint-disable-next-line no-console + console.warn(`unknown action: ${JSON.stringify(link)}`); + }, + [applyPatches, dispatch, handleGoTo, messages, submit], + ); + + const skipLinksRequest = useMemo(() => { + const lastMessageIsUserMessage = + messages.length > 0 && isUserMessage(messages[messages.length - 1]); + if (!model) return true; + if (!caps.data) return true; + if (toolUse !== "agent") return true; + return ( + isStreaming || isWaiting || unCalledTools || lastMessageIsUserMessage + ); + }, [ + caps.data, + isStreaming, + isWaiting, + messages, + model, + toolUse, + unCalledTools, + ]); + + const linksResult = linksApi.useGetLinksForChatQuery( + { + chat_id: chatId, + messages, + model: model ?? "", + mode: threadMode, // TODO: Changing thread mode invalidates the cache. + current_config_file: maybeIntegration?.path, + }, + { skip: skipLinksRequest }, + ); + + return { + linksResult, + handleLinkAction, + streaming: isWaiting || isStreaming || unCalledTools, + }; +} diff --git a/src/hooks/useSmartLinks.ts b/src/hooks/useSmartLinks.ts new file mode 100644 index 00000000..99d6c05f --- /dev/null +++ b/src/hooks/useSmartLinks.ts @@ -0,0 +1,42 @@ +import { useCallback } from "react"; +import { LspChatMessage } from "../services/refact/chat"; +import { formatMessagesForChat } from "../features/Chat/Thread/utils"; +import { useAppDispatch } from "./useAppDispatch"; +import { clearInformation } from "../features/Errors/informationSlice"; +import { newIntegrationChat } from "../features/Chat/Thread/actions"; +import { push } from "../features/Pages/pagesSlice"; +import { useGoToLink } from "./useGoToLink"; + +export function useSmartLinks() { + const dispatch = useAppDispatch(); + const { handleGoTo } = useGoToLink(); + const handleSmartLink = useCallback( + ( + sl_chat: LspChatMessage[], + integrationName: string, + integrationPath: string, + integrationProject: string, + ) => { + const messages = formatMessagesForChat(sl_chat); + + dispatch(clearInformation()); + dispatch( + newIntegrationChat({ + integration: { + name: integrationName, + path: integrationPath, + project: integrationProject, + }, + messages, + }), + ); + dispatch(push({ name: "chat" })); + }, + [dispatch], + ); + + return { + handleSmartLink, + handleGoTo, + }; +} diff --git a/src/services/refact/caps.ts b/src/services/refact/caps.ts index db5eb2d0..1a8a446f 100644 --- a/src/services/refact/caps.ts +++ b/src/services/refact/caps.ts @@ -55,9 +55,11 @@ export type CodeChatModel = { supports_scratchpads: Record< string, { - default_system_message: string; + default_system_message?: string; } >; + supports_multimodality?: boolean; + supports_clicks?: boolean; }; export type CodeCompletionModel = { @@ -65,6 +67,9 @@ export type CodeCompletionModel = { n_ctx: number; similar_models: string[]; supports_scratchpads: Record>; + supports_tools?: boolean; + supports_multimodality?: boolean; + supports_clicks?: boolean; }; export type CapsResponse = { diff --git a/src/services/refact/integrations.ts b/src/services/refact/integrations.ts index c561ff91..1b7e6ff6 100644 --- a/src/services/refact/integrations.ts +++ b/src/services/refact/integrations.ts @@ -132,7 +132,10 @@ export type Integration = { integr_name: string; integr_config_path: string; integr_schema: IntegrationSchema; - integr_values: Record>; + integr_values: Record< + string, + IntegrationPrimitive | Record | Record + >; error_log: null | YamlError[]; }; @@ -182,15 +185,20 @@ function isIntegration(json: unknown): json is Integration { return false; } const integrValues = json.integr_values as Record; - if ( - !Object.values(integrValues).every( - (value) => - isPrimitive(value) || - (typeof value === "object" && - value !== null && - Object.values(value).every(isPrimitive)), - ) - ) { + debugIntegrations("integrValues:", integrValues); // Log the integrValues + + function isValidNestedObject(value: unknown): boolean { + if (isPrimitive(value)) { + return true; + } + if (typeof value === "object" && value !== null) { + return Object.values(value).every(isValidNestedObject); + } + return false; + } + + if (!Object.values(integrValues).every(isValidNestedObject)) { + debugIntegrations(`[DEBUG]: integr_values are not valid json`); return false; } @@ -501,6 +509,22 @@ export type NotConfiguredIntegrationWithIconRecord = { // unparsed: unknown; }; +export type GroupedIntegrationWithIconRecord = { + project_path: string[]; + integr_name: string; + integr_config_path: string[]; + integr_config_exists: boolean; + on_your_laptop: boolean; + when_isolated: boolean; + // unparsed: unknown; +}; + +export function areIntegrationsNotConfigured( + json: GroupedIntegrationWithIconRecord, +): json is NotConfiguredIntegrationWithIconRecord { + return !json.integr_config_exists; +} + export function isNotConfiguredIntegrationWithIconRecord( json: unknown, ): json is NotConfiguredIntegrationWithIconRecord { diff --git a/src/services/refact/tools.ts b/src/services/refact/tools.ts index 915cffeb..9c5acd09 100644 --- a/src/services/refact/tools.ts +++ b/src/services/refact/tools.ts @@ -90,8 +90,9 @@ export type ToolFunction = { agentic?: boolean; name: string; description: string; - parameters: ToolParams[]; - parameters_required: string[]; + // parameters: ToolParams[]; + parameters: Record; + parameters_required?: string[]; }; export type ToolCommand = { diff --git a/src/utils/index.ts b/src/utils/index.ts index e3380ab7..83769bc7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,4 @@ export * from "./scanForDuplicates"; export * from "./extractFilePathFromPin"; export * from "./partition"; export * from "./fencedBackticks"; +export * from "./isAbsolutePath"; diff --git a/src/utils/isAbsolutePath.ts b/src/utils/isAbsolutePath.ts new file mode 100644 index 00000000..150c4704 --- /dev/null +++ b/src/utils/isAbsolutePath.ts @@ -0,0 +1,4 @@ +const absolutePathRegex = /^(?:[a-zA-Z]:\\|\/|\\\\|\/\/).*/; +export function isAbsolutePath(path: string): boolean { + return absolutePathRegex.test(path); +} diff --git a/src/utils/mockServer.ts b/src/utils/mockServer.ts index 0fcc5f3e..8a917fcb 100644 --- a/src/utils/mockServer.ts +++ b/src/utils/mockServer.ts @@ -1,8 +1,5 @@ import { afterAll, afterEach, beforeAll } from "vitest"; -import { http, HttpResponse, type HttpHandler } from "msw"; import { setupServer } from "msw/node"; -import { SYSTEM_PROMPTS } from "../__fixtures__/prompts"; -import { STUB_CAPS_RESPONSE } from "../__fixtures__/caps"; import type { Store } from "../app/store"; import { capsApi, @@ -13,6 +10,8 @@ import { pingApi, } from "../services/refact"; +export * from "../__fixtures__/msw"; + export const resetApi = (store: Store) => { store.dispatch(capsApi.util.resetApiState()); store.dispatch(statisticsApi.util.resetApiState()); @@ -37,65 +36,3 @@ afterAll(() => { // Clean up once the tests are done. server.close(); }); - -export const goodPing: HttpHandler = http.get( - "http://127.0.0.1:8001/v1/ping", - () => { - return HttpResponse.text("pong"); - }, -); - -export const goodCaps: HttpHandler = http.get( - "http://127.0.0.1:8001/v1/caps", - () => { - return HttpResponse.json(STUB_CAPS_RESPONSE); - }, -); - -export const noTools: HttpHandler = http.get( - "http://127.0.0.1:8001/v1/tools", - () => { - return HttpResponse.json([]); - }, -); - -export const goodPrompts: HttpHandler = http.get( - "http://127.0.0.1:8001/v1/customization", - () => { - return HttpResponse.json({ system_prompts: SYSTEM_PROMPTS }); - }, -); - -export const noCompletions: HttpHandler = http.post( - "http://127.0.0.1:8001/v1/at-command-completion", - () => { - return HttpResponse.json({ - completions: [], - replace: [0, 0], - is_cmd_executable: false, - }); - }, -); - -export const noCommandPreview: HttpHandler = http.post( - "http://127.0.0.1:8001/v1/at-command-preview", - () => { - return HttpResponse.json({ - messages: [], - }); - }, -); - -export const goodUser: HttpHandler = http.get( - "https://www.smallcloud.ai/v1/login", - () => { - return HttpResponse.json({ - retcode: "OK", - account: "party@refact.ai", - inference_url: "https://www.smallcloud.ai/v1", - inference: "PRO", - metering_balance: -100000, - questionnaire: {}, - }); - }, -);