Add option to choose different models

This commit is contained in:
Saoud Rizwan
2024-08-11 00:28:22 -04:00
parent a863b26b7a
commit f54774b943
22 changed files with 487 additions and 151 deletions

View File

@@ -1,19 +1,18 @@
import React, { useEffect, useState, useCallback } from "react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { useEvent } from "react-use"
import { ApiConfiguration } from "../../src/shared/api"
import { ClaudeMessage, ExtensionMessage } from "../../src/shared/ExtensionMessage"
import "./App.css"
import { normalizeApiConfiguration } from "./components/ApiOptions"
import ChatView from "./components/ChatView"
import SettingsView from "./components/SettingsView"
import { ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
import WelcomeView from "./components/WelcomeView"
import { vscode } from "./utils/vscode"
import { useEvent } from "react-use"
import { ApiConfiguration } from "@shared/api"
/*
The contents of webviews however are created when the webview becomes visible and destroyed when the webview is moved into the background. Any state inside the webview will be lost when the webview is moved to a background tab.
The best way to solve this is to make your webview stateless. Use message passing to save off the webview's state and then restore the state when the webview becomes visible again.
*/
const App: React.FC = () => {
@@ -70,6 +69,10 @@ const App: React.FC = () => {
useEvent("message", handleMessage)
const { selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
if (!didHydrateState) {
return null
}
@@ -97,6 +100,7 @@ const App: React.FC = () => {
isHidden={showSettings}
vscodeThemeName={vscodeThemeName}
showAnnouncement={showAnnouncement}
selectedModelSupportsImages={selectedModelInfo.supportsImages}
hideAnnouncement={() => setShowAnnouncement(false)}
/>
</>

View File

@@ -1,34 +1,78 @@
import { ApiConfiguration } from "@shared/api"
import { VSCodeDropdown, VSCodeLink, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import React from "react"
import React, { useMemo } from "react"
import {
ApiConfiguration,
ApiModelId,
ModelInfo,
anthropicDefaultModelId,
anthropicModels,
bedrockDefaultModelId,
bedrockModels,
openRouterDefaultModelId,
openRouterModels,
} from "../../../src/shared/api"
interface ApiOptionsProps {
showModelOptions: boolean
apiConfiguration?: ApiConfiguration
setApiConfiguration: React.Dispatch<React.SetStateAction<ApiConfiguration | undefined>>
}
const ApiOptions: React.FC<ApiOptionsProps> = ({ apiConfiguration, setApiConfiguration }) => {
const ApiOptions: React.FC<ApiOptionsProps> = ({ showModelOptions, apiConfiguration, setApiConfiguration }) => {
const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => {
setApiConfiguration((prev) => ({ ...prev, [field]: event.target.value }))
}
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
/*
VSCodeDropdown has an open bug where dynamically rendered options don't auto select the provided value prop. You can see this for yourself by comparing it with normal select/option elements, which work as expected.
https://github.com/microsoft/vscode-webview-ui-toolkit/issues/433
In our case, when the user switches between providers, we recalculate the selectedModelId depending on the provider, the default model for that provider, and a modelId that the user may have selected. Unfortunately, the VSCodeDropdown component wouldn't select this calculated value, and would default to the first "Select a model..." option instead, which makes it seem like the model was cleared out when it wasn't.
As a workaround, we create separate instances of the dropdown for each provider, and then conditionally render the one that matches the current provider.
*/
const createDropdown = (models: Record<string, ModelInfo>) => {
return (
<VSCodeDropdown
id="model-id"
value={selectedModelId}
onChange={handleInputChange("apiModelId")}
style={{ width: "100%" }}>
<VSCodeOption value="">Select a model...</VSCodeOption>
{Object.keys(models).map((modelId) => (
<VSCodeOption
key={modelId}
value={modelId}
style={{
whiteSpace: "normal",
wordWrap: "break-word",
maxWidth: "100%",
}}>
{modelId}
</VSCodeOption>
))}
</VSCodeDropdown>
)
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<div className="dropdown-container">
<label htmlFor="api-provider">
<span style={{ fontWeight: 500 }}>API Provider</span>
</label>
<VSCodeDropdown
id="api-provider"
value={apiConfiguration?.apiProvider || "anthropic"}
onChange={handleInputChange("apiProvider")}>
<VSCodeDropdown id="api-provider" value={selectedProvider} onChange={handleInputChange("apiProvider")}>
<VSCodeOption value="anthropic">Anthropic</VSCodeOption>
<VSCodeOption value="openrouter">OpenRouter</VSCodeOption>
<VSCodeOption value="bedrock">AWS Bedrock</VSCodeOption>
</VSCodeDropdown>
</div>
{apiConfiguration?.apiProvider === "anthropic" && (
{selectedProvider === "anthropic" && (
<div>
<VSCodeTextField
value={apiConfiguration?.apiKey || ""}
@@ -51,7 +95,7 @@ const ApiOptions: React.FC<ApiOptionsProps> = ({ apiConfiguration, setApiConfigu
</div>
)}
{apiConfiguration?.apiProvider === "openrouter" && (
{selectedProvider === "openrouter" && (
<div>
<VSCodeTextField
value={apiConfiguration?.openRouterApiKey || ""}
@@ -74,7 +118,7 @@ const ApiOptions: React.FC<ApiOptionsProps> = ({ apiConfiguration, setApiConfigu
</div>
)}
{apiConfiguration?.apiProvider === "bedrock" && (
{selectedProvider === "bedrock" && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<VSCodeTextField
value={apiConfiguration?.awsAccessKey || ""}
@@ -100,9 +144,9 @@ const ApiOptions: React.FC<ApiOptionsProps> = ({ apiConfiguration, setApiConfigu
style={{ width: "100%" }}
onChange={handleInputChange("awsRegion")}>
<VSCodeOption value="">Select a region...</VSCodeOption>
{/* Currently Claude 3.5 Sonnet is only available in us-east-1 */}
{/* The user will have to choose a region that supports the model they use, but this shouldn't be a problem since they'd have to request access for it in that region in the first place. */}
<VSCodeOption value="us-east-1">US East (N. Virginia)</VSCodeOption>
{/* <VSCodeOption value="us-east-2">US East (Ohio)</VSCodeOption>
<VSCodeOption value="us-east-2">US East (Ohio)</VSCodeOption>
<VSCodeOption value="us-west-1">US West (N. California)</VSCodeOption>
<VSCodeOption value="us-west-2">US West (Oregon)</VSCodeOption>
<VSCodeOption value="af-south-1">Africa (Cape Town)</VSCodeOption>
@@ -120,7 +164,7 @@ const ApiOptions: React.FC<ApiOptionsProps> = ({ apiConfiguration, setApiConfigu
<VSCodeOption value="eu-west-3">Europe (Paris)</VSCodeOption>
<VSCodeOption value="eu-north-1">Europe (Stockholm)</VSCodeOption>
<VSCodeOption value="me-south-1">Middle East (Bahrain)</VSCodeOption>
<VSCodeOption value="sa-east-1">South America (São Paulo)</VSCodeOption> */}
<VSCodeOption value="sa-east-1">South America (São Paulo)</VSCodeOption>
</VSCodeDropdown>
</div>
<p
@@ -138,8 +182,91 @@ const ApiOptions: React.FC<ApiOptionsProps> = ({ apiConfiguration, setApiConfigu
</p>
</div>
)}
{showModelOptions && (
<>
<div className="dropdown-container">
<label htmlFor="model-id">
<span style={{ fontWeight: 500 }}>Model</span>
</label>
{selectedProvider === "anthropic" && createDropdown(anthropicModels)}
{selectedProvider === "openrouter" && createDropdown(openRouterModels)}
{selectedProvider === "bedrock" && createDropdown(bedrockModels)}
</div>
<ModelInfoView modelInfo={selectedModelInfo} />
</>
)}
</div>
)
}
const ModelInfoView = ({ modelInfo }: { modelInfo: ModelInfo }) => {
const formatPrice = (price: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(price)
}
return (
<p style={{ fontSize: "12px", marginTop: "2px", color: "var(--vscode-descriptionForeground)" }}>
<span
style={{
fontWeight: 500,
color: modelInfo.supportsImages
? "var(--vscode-testing-iconPassed)"
: "var(--vscode-errorForeground)",
}}>
<i
className={`codicon codicon-${modelInfo.supportsImages ? "check" : "x"}`}
style={{
marginRight: 4,
marginBottom: modelInfo.supportsImages ? 1 : -1,
fontSize: modelInfo.supportsImages ? 11 : 13,
fontWeight: 700,
display: "inline-block",
verticalAlign: "bottom",
}}></i>
{modelInfo.supportsImages ? "Supports images" : "Does not support images"}
</span>
<br />
<span style={{ fontWeight: 500 }}>Max output:</span> {modelInfo.maxTokens.toLocaleString()} tokens
<br />
<span style={{ fontWeight: 500 }}>Input price:</span> {formatPrice(modelInfo.inputPrice)} per million tokens
<br />
<span style={{ fontWeight: 500 }}>Output price:</span> {formatPrice(modelInfo.outputPrice)} per million
tokens
</p>
)
}
export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
const provider = apiConfiguration?.apiProvider || "anthropic"
const modelId = apiConfiguration?.apiModelId
const getProviderData = (models: Record<string, ModelInfo>, defaultId: ApiModelId) => {
let selectedModelId: ApiModelId
let selectedModelInfo: ModelInfo
if (modelId && modelId in models) {
selectedModelId = modelId
selectedModelInfo = models[modelId]
} else {
selectedModelId = defaultId
selectedModelInfo = models[defaultId]
}
return { selectedProvider: provider, selectedModelId, selectedModelInfo }
}
switch (provider) {
case "anthropic":
return getProviderData(anthropicModels, anthropicDefaultModelId)
case "openrouter":
return getProviderData(openRouterModels, openRouterDefaultModelId)
case "bedrock":
return getProviderData(bedrockModels, bedrockDefaultModelId)
}
}
export default ApiOptions

View File

@@ -1,11 +1,11 @@
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "@shared/ExtensionMessage"
import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import React from "react"
import Markdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "../../../src/shared/ExtensionMessage"
import { COMMAND_OUTPUT_STRING } from "../utils/combineCommandSequences"
import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
import CodeBlock from "./CodeBlock/CodeBlock"
import Markdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import Thumbnails from "./Thumbnails"
interface ChatRowProps {

View File

@@ -1,18 +1,18 @@
import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import vsDarkPlus from "react-syntax-highlighter/dist/esm/styles/prism/vsc-dark-plus"
import DynamicTextArea from "react-textarea-autosize"
import { useEvent, useMount } from "react-use"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "../../../src/shared/ExtensionMessage"
import { combineApiRequests } from "../utils/combineApiRequests"
import { combineCommandSequences } from "../utils/combineCommandSequences"
import { getApiMetrics } from "../utils/getApiMetrics"
import { getSyntaxHighlighterStyleFromTheme } from "../utils/getSyntaxHighlighterStyleFromTheme"
import { vscode } from "../utils/vscode"
import Announcement from "./Announcement"
import ChatRow from "./ChatRow"
import TaskHeader from "./TaskHeader"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import Announcement from "./Announcement"
import Thumbnails from "./Thumbnails"
interface ChatViewProps {
@@ -21,6 +21,7 @@ interface ChatViewProps {
isHidden: boolean
vscodeThemeName?: string
showAnnouncement: boolean
selectedModelSupportsImages: boolean
hideAnnouncement: () => void
}
@@ -32,6 +33,7 @@ const ChatView = ({
isHidden,
vscodeThemeName,
showAnnouncement,
selectedModelSupportsImages,
hideAnnouncement,
}: ChatViewProps) => {
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined
@@ -278,6 +280,11 @@ const ChatView = ({
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (shouldDisableImages) {
e.preventDefault()
return
}
const items = e.clipboardData.items
const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg)
const imageItems = Array.from(items).filter((item) => {
@@ -412,6 +419,12 @@ const ChatView = ({
return [text, false]
}, [task, messages])
const shouldDisableImages =
!selectedModelSupportsImages ||
textAreaDisabled ||
selectedImages.length >= MAX_IMAGES_PER_MESSAGE ||
isInputPipingToStdin
return (
<div
style={{
@@ -590,9 +603,7 @@ const ChatView = ({
height: "calc(100% - 20px)", // Full height minus top and bottom padding
}}>
<VSCodeButton
disabled={
textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE || isInputPipingToStdin
}
disabled={shouldDisableImages}
appearance="icon"
aria-label="Attach Images"
onClick={selectImages}

View File

@@ -1,6 +1,6 @@
import { ApiConfiguration } from "@shared/api"
import { VSCodeButton, VSCodeDivider, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useState } from "react"
import { ApiConfiguration } from "../../../src/shared/api"
import { validateApiConfiguration, validateMaxRequestsPerTask } from "../utils/validate"
import { vscode } from "../utils/vscode"
import ApiOptions from "./ApiOptions"
@@ -60,78 +60,92 @@ const SettingsView = ({
*/
return (
<div style={{ margin: "0 auto", paddingTop: "10px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "17px",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
<VSCodeButton onClick={handleSubmit}>Done</VSCodeButton>
</div>
<div style={{ marginBottom: 5 }}>
<ApiOptions apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
{apiErrorMessage && (
<p
style={{
margin: "-5px 0 12px 0",
fontSize: "12px",
color: "var(--vscode-errorForeground)",
}}>
{apiErrorMessage}
</p>
)}
</div>
<div style={{ marginBottom: "20px" }}>
<VSCodeTextField
value={maxRequestsPerTask}
style={{ width: "100%" }}
placeholder="20"
onInput={(e: any) => setMaxRequestsPerTask(e.target?.value)}>
<span style={{ fontWeight: "500" }}>Maximum # Requests Per Task</span>
</VSCodeTextField>
<p
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
padding: "10px 18px 18px 20px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div style={{ flexGrow: 1, overflow: "auto" }}>
<div
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "17px",
}}>
If Claude Dev reaches this limit, it will pause and ask for your permission before making additional
requests.
</p>
{maxRequestsErrorMessage && (
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
<VSCodeButton onClick={handleSubmit}>Done</VSCodeButton>
</div>
<div style={{ marginBottom: 5 }}>
<ApiOptions
apiConfiguration={apiConfiguration}
setApiConfiguration={setApiConfiguration}
showModelOptions={true}
/>
{apiErrorMessage && (
<p
style={{
margin: "-5px 0 12px 0",
fontSize: "12px",
color: "var(--vscode-errorForeground)",
}}>
{apiErrorMessage}
</p>
)}
</div>
<div style={{ marginBottom: "20px" }}>
<VSCodeTextField
value={maxRequestsPerTask}
style={{ width: "100%" }}
placeholder="20"
onInput={(e: any) => setMaxRequestsPerTask(e.target?.value)}>
<span style={{ fontWeight: "500" }}>Maximum # Requests Per Task</span>
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-errorForeground)",
color: "var(--vscode-descriptionForeground)",
}}>
{maxRequestsErrorMessage}
If Claude Dev reaches this limit, it will pause and ask for your permission before making
additional requests.
</p>
)}
{maxRequestsErrorMessage && (
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-errorForeground)",
}}>
{maxRequestsErrorMessage}
</p>
)}
</div>
</div>
<VSCodeDivider />
<div
style={{
marginTop: "20px",
textAlign: "center",
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
lineHeight: "1.2",
}}>
<p style={{ wordWrap: "break-word" }}>
<p style={{ wordWrap: "break-word", margin: 0, padding: 0 }}>
If you have any questions or feedback, feel free to open an issue at{" "}
<VSCodeLink href="https://github.com/saoudrizwan/claude-dev" style={{ display: "inline" }}>
https://github.com/saoudrizwan/claude-dev
</VSCodeLink>
</p>
<p style={{ fontStyle: "italic" }}>v{version}</p>
<p style={{ fontStyle: "italic", margin: "10px 0 0 0", padding: 0 }}>v{version}</p>
</div>
</div>
)

View File

@@ -2,8 +2,8 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useRef, useState } from "react"
import TextTruncate from "react-text-truncate"
import { useWindowSize } from "react-use"
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
import { vscode } from "../utils/vscode"
import { ClaudeMessage } from "@shared/ExtensionMessage"
import Thumbnails from "./Thumbnails"
interface TaskHeaderProps {

View File

@@ -1,4 +1,4 @@
import { ApiConfiguration } from "@shared/api"
import { ApiConfiguration } from "../../../src/shared/api"
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useState } from "react"
import { validateApiConfiguration } from "../utils/validate"
@@ -40,7 +40,11 @@ const WelcomeView: React.FC<WelcomeViewProps> = ({ apiConfiguration, setApiConfi
<b>To get started, this extension needs an API key for Claude 3.5 Sonnet:</b>
<div style={{ marginTop: "15px" }}>
<ApiOptions apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
<ApiOptions
apiConfiguration={apiConfiguration}
setApiConfiguration={setApiConfiguration}
showModelOptions={false}
/>
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} style={{ marginTop: "3px" }}>
Let's go!
</VSCodeButton>

View File

@@ -1,15 +1,15 @@
import { ClaudeMessage } from "@shared/ExtensionMessage"
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
/**
* Combines API request start and finish messages in an array of ClaudeMessages.
*
*
* This function looks for pairs of 'api_req_started' and 'api_req_finished' messages.
* When it finds a pair, it combines them into a single 'api_req_combined' message.
* The JSON data in the text fields of both messages are merged.
*
*
* @param messages - An array of ClaudeMessage objects to process.
* @returns A new array of ClaudeMessage objects with API requests combined.
*
*
* @example
* const messages = [
* { type: "say", say: "api_req_started", text: '{"request":"GET /api/data"}', ts: 1000 },

View File

@@ -1,4 +1,4 @@
import { ClaudeMessage } from "@shared/ExtensionMessage"
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
/**
* Combines sequences of command and command_output messages in an array of ClaudeMessages.

View File

@@ -1,4 +1,4 @@
import { ClaudeMessage } from "@shared/ExtensionMessage"
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
interface ApiMetrics {
totalTokensIn: number

View File

@@ -1,4 +1,4 @@
import { ClaudeMessage } from "@shared/ExtensionMessage";
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
export const mockMessages: ClaudeMessage[] = [
{

View File

@@ -1,4 +1,4 @@
import { ApiConfiguration } from "@shared/api"
import { ApiConfiguration } from "../../../src/shared/api"
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
if (apiConfiguration) {

View File

@@ -1,4 +1,4 @@
import { WebviewMessage } from "@shared/WebviewMessage"
import { WebviewMessage } from "../../../src/shared/WebviewMessage"
import type { WebviewApi } from "vscode-webview"
/**