diff --git a/package-lock.json b/package-lock.json index 8838054..728dc9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,22 @@ "name": "infinite-monitor-tmp", "version": "0.1.0", "dependencies": { + "@ai-sdk/alibaba": "^1.0.10", "@ai-sdk/anthropic": "^3.0.58", + "@ai-sdk/cerebras": "^2.0.39", + "@ai-sdk/cohere": "^3.0.25", + "@ai-sdk/deepinfra": "^2.0.39", + "@ai-sdk/deepseek": "^2.0.24", + "@ai-sdk/fireworks": "^2.0.40", + "@ai-sdk/google": "^3.0.43", + "@ai-sdk/groq": "^3.0.29", + "@ai-sdk/mistral": "^3.0.24", + "@ai-sdk/moonshotai": "^2.0.10", + "@ai-sdk/openai": "^3.0.41", + "@ai-sdk/perplexity": "^3.0.23", "@ai-sdk/react": "^3.0.118", + "@ai-sdk/togetherai": "^2.0.39", + "@ai-sdk/xai": "^3.0.67", "@base-ui/react": "^1.2.0", "@radix-ui/react-use-controllable-state": "^1.2.2", "@streamdown/cjk": "^1.0.2", @@ -59,6 +73,23 @@ "typescript": "^5" } }, + "node_modules/@ai-sdk/alibaba": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/alibaba/-/alibaba-1.0.10.tgz", + "integrity": "sha512-NoPYN2njQSAR9LS118VZuZJY9znlzTnLXoal0IpaSkF4qZvYUtwRK90HpYw+sg/YsIenOVRXRcOevaDYlkaLUQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.35", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/anthropic": { "version": "3.0.58", "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.58.tgz", @@ -75,6 +106,89 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/cerebras": { + "version": "2.0.39", + "resolved": "https://registry.npmjs.org/@ai-sdk/cerebras/-/cerebras-2.0.39.tgz", + "integrity": "sha512-aVDYGbRh+59auoZeUNoMxNlp4ZBEqiKt6tjRNbBpQyJbeOv92DekCcjQLnULjcQoPTZwrAaeRQDv2fe/aY4RSw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.35", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/cohere": { + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/@ai-sdk/cohere/-/cohere-3.0.25.tgz", + "integrity": "sha512-Ox6MeK+AcGWY+WDKA5LMfPsESKym7ohZO9aPWL6JsGkt7/gXC4t+rT4j+5eXtFtFXah8SQqs09gVt4xRgZAvxg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/deepinfra": { + "version": "2.0.39", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepinfra/-/deepinfra-2.0.39.tgz", + "integrity": "sha512-mvJXkmwQEy1RjbAydk3gHdpMJ2C32+I47lIXINB618xjX3zR6OQYel/XdYxr5U4iLrABG6ctOp0BTA1X/k6c8w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.35", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/deepseek": { + "version": "2.0.24", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.24.tgz", + "integrity": "sha512-4vOEekW4TAYVHN0qgiwoUOQZhguGwZBiEw8LDeUmpWBm07QkLRAtxYCaSoMiA4hZZojao5mj6NRGEBW1CnDPtg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/fireworks": { + "version": "2.0.40", + "resolved": "https://registry.npmjs.org/@ai-sdk/fireworks/-/fireworks-2.0.40.tgz", + "integrity": "sha512-ARjygiBQtVSgNBp3Sag+Bkwn68ub+cZPC05UpRGG+VY8/Q896K2yU1j4I0+S1eU0BQW/9DKbRG04d9Ayi2DUmA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.35", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/gateway": { "version": "3.0.66", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.66.tgz", @@ -92,6 +206,119 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/google": { + "version": "3.0.43", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.43.tgz", + "integrity": "sha512-NGCgP5g8HBxrNdxvF8Dhww+UKfqAkZAmyYBvbu9YLoBkzAmGKDBGhVptN/oXPB5Vm0jggMdoLycZ8JReQM8Zqg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-3.0.29.tgz", + "integrity": "sha512-I/tUoHuOvGXbIr1dJ0CLRLA7W0UPDMtrYT5mgeb3O+P+6I5BAm/7riPwr22Xw5YTzpwQxcoDQlIczOU9XDXBpA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mistral": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/@ai-sdk/mistral/-/mistral-3.0.24.tgz", + "integrity": "sha512-krBTH2KHxtX8lCkSYSL4ZKSpn2EoJ5cNmBa9BmFL62KO1h5lYY6ivEwQb93TgY/hs2pkAIe4HJFIMX5kG1XtXg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/moonshotai": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/moonshotai/-/moonshotai-2.0.10.tgz", + "integrity": "sha512-XtBqVQHb6069XQQARtjOq1MxbrA56Ox2hTP3tmsnFVUlXMvS+SINCL6mU7Lq3oFQKADXjjEQibq49e7Gee9n1A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.35", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.41", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.41.tgz", + "integrity": "sha512-IZ42A+FO+vuEQCVNqlnAPYQnnUpUfdJIwn1BEDOBywiEHa23fw7PahxVtlX9zm3/zMvTW4JKPzWyvAgDu+SQ2A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "2.0.35", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.35.tgz", + "integrity": "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/perplexity": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/perplexity/-/perplexity-3.0.23.tgz", + "integrity": "sha512-LyizJlT3rVJ9WgU7C8RqCf4/QuV/aZT/D3s77sa2M0FtzA9tX5VsWDBR3GN0eAKnXxbC3Lhp5PaNuvqQgoMDiw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", @@ -139,6 +366,40 @@ "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, + "node_modules/@ai-sdk/togetherai": { + "version": "2.0.39", + "resolved": "https://registry.npmjs.org/@ai-sdk/togetherai/-/togetherai-2.0.39.tgz", + "integrity": "sha512-IPw6mSJyg9pK/ldScML7G/sKVETpnrTg/PNZzYPF0DdL0j/Vkh/XYm+MCg6VgdMVoj6WvXI4fQIAatx9PQWrzA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.35", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/xai": { + "version": "3.0.67", + "resolved": "https://registry.npmjs.org/@ai-sdk/xai/-/xai-3.0.67.tgz", + "integrity": "sha512-KQQIDc91dUA5IGFMnXBuvPBeraYNTdpDC1qUS+JG8vE+/299//5sZFafI1kKYUu3f3p7LaZrKXYgZ1Ni7QIRbw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.35", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", diff --git a/package.json b/package.json index 79d203c..cec4f4a 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,22 @@ ] }, "dependencies": { + "@ai-sdk/alibaba": "^1.0.10", "@ai-sdk/anthropic": "^3.0.58", + "@ai-sdk/cerebras": "^2.0.39", + "@ai-sdk/cohere": "^3.0.25", + "@ai-sdk/deepinfra": "^2.0.39", + "@ai-sdk/deepseek": "^2.0.24", + "@ai-sdk/fireworks": "^2.0.40", + "@ai-sdk/google": "^3.0.43", + "@ai-sdk/groq": "^3.0.29", + "@ai-sdk/mistral": "^3.0.24", + "@ai-sdk/moonshotai": "^2.0.10", + "@ai-sdk/openai": "^3.0.41", + "@ai-sdk/perplexity": "^3.0.23", "@ai-sdk/react": "^3.0.118", + "@ai-sdk/togetherai": "^2.0.39", + "@ai-sdk/xai": "^3.0.67", "@base-ui/react": "^1.2.0", "@radix-ui/react-use-controllable-state": "^1.2.2", "@streamdown/cjk": "^1.0.2", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 5669032..1b4119e 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,6 +1,7 @@ import { streamText, stepCountIs, tool } from "ai"; import { anthropic } from "@ai-sdk/anthropic"; import { z } from "zod"; +import { createModel, isAnthropicModel } from "@/lib/create-model"; import { Bash } from "just-bash"; import { createBashTool } from "bash-tool"; import { @@ -117,18 +118,23 @@ Keep the widget focused, clean, and production-quality.`; export async function POST(request: Request) { const body = await request.json(); - const { messages, widgetId } = body as { + const { messages, widgetId, model: modelStr, apiKey } = body as { messages: Array<{ role: "user" | "assistant"; content: string | Array>; }>; widgetId: string; + model?: string; + apiKey?: string; }; if (!widgetId) { return Response.json({ error: "widgetId required" }, { status: 400 }); } + const selectedModel = modelStr ?? "anthropic:claude-sonnet-4-6"; + const useAnthropic = isAnthropicModel(selectedModel); + const SANDBOX_ROOT = "/widget"; const widgetSandbox = { @@ -209,27 +215,35 @@ export async function POST(request: Request) { }, }); - const webSearchTool = anthropic.tools.webSearch_20250305({ maxUses: 5 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tools: Record = { + ...bashTools, + listDashboardWidgets: listDashboardWidgetsTool, + readWidgetCode: readWidgetCodeTool, + }; + + if (useAnthropic) { + tools.web_search = anthropic.tools.webSearch_20250305({ maxUses: 5 }); + } const result = streamText({ - model: anthropic("claude-opus-4-6"), + model: createModel(selectedModel, apiKey), system: SYSTEM_PROMPT, // eslint-disable-next-line @typescript-eslint/no-explicit-any messages: messages as any, - tools: { - ...bashTools, - listDashboardWidgets: listDashboardWidgetsTool, - readWidgetCode: readWidgetCodeTool, - web_search: webSearchTool, - }, + tools, stopWhen: stepCountIs(40), abortSignal: request.signal, - providerOptions: { - anthropic: { - thinking: { type: "adaptive" }, - effort: "high", - }, - }, + ...(useAnthropic + ? { + providerOptions: { + anthropic: { + thinking: { type: "adaptive" }, + effort: "high", + }, + }, + } + : {}), }); const encoder = new TextEncoder(); diff --git a/src/app/api/generate-title/route.ts b/src/app/api/generate-title/route.ts index a359767..e907604 100644 --- a/src/app/api/generate-title/route.ts +++ b/src/app/api/generate-title/route.ts @@ -1,8 +1,12 @@ import { generateText } from "ai"; -import { anthropic } from "@ai-sdk/anthropic"; +import { createModel } from "@/lib/create-model"; export async function POST(request: Request) { - const { message } = (await request.json()) as { message: string }; + const { message, model, apiKey } = (await request.json()) as { + message: string; + model?: string; + apiKey?: string; + }; if (!message?.trim()) { return Response.json({ title: null }, { status: 400 }); @@ -10,9 +14,8 @@ export async function POST(request: Request) { try { const { text } = await generateText({ - model: anthropic("claude-haiku-4-5"), + model: createModel(model ?? "anthropic:claude-haiku-4-5", apiKey), prompt: `Generate a short widget title (2-4 words max, no punctuation) for this request: "${message}". Reply with only the title, nothing else.`, - maxOutputTokens: 20, }); return Response.json({ title: text.trim() }); diff --git a/src/app/page.tsx b/src/app/page.tsx index fb785a2..4947266 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,29 @@ "use client"; +import { useEffect, useState } from "react"; import { DashboardGrid } from "@/components/dashboard-grid"; import { ChatSidebar } from "@/components/chat-sidebar"; import { CreateWidgetDialog } from "@/components/create-widget-dialog"; import { DashboardPicker } from "@/components/dashboard-picker"; import { ScrambleText } from "@/components/scramble-text"; +import { Star } from "lucide-react"; +import { buttonVariants } from "@/components/ui/button"; + +function useGitHubStars() { + const [stars, setStars] = useState(null); + useEffect(() => { + fetch("https://api.github.com/repos/homanp/infinite-monitor") + .then((r) => r.ok ? r.json() : null) + .then((data) => { + if (data?.stargazers_count != null) setStars(data.stargazers_count); + }) + .catch(() => {}); + }, []); + return stars; +} export default function Home() { + const stars = useGitHubStars(); const infiniteLen = "Infinite".length; return ( @@ -22,6 +39,21 @@ export default function Home() { />
+ + + GitHub + {stars !== null && ( + <> + · + {stars.toLocaleString()} + + )} +
diff --git a/src/components/ai-elements/model-selector.tsx b/src/components/ai-elements/model-selector.tsx new file mode 100644 index 0000000..1716557 --- /dev/null +++ b/src/components/ai-elements/model-selector.tsx @@ -0,0 +1,213 @@ +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { ComponentProps, ReactNode } from "react"; + +export type ModelSelectorProps = ComponentProps; + +export const ModelSelector = (props: ModelSelectorProps) => ( + +); + +export type ModelSelectorTriggerProps = ComponentProps; + +export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => ( + +); + +export type ModelSelectorContentProps = ComponentProps & { + title?: ReactNode; +}; + +export const ModelSelectorContent = ({ + className, + children, + title = "Model Selector", + ...props +}: ModelSelectorContentProps) => ( + + {title} + + {children} + + +); + +export type ModelSelectorDialogProps = ComponentProps; + +export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => ( + +); + +export type ModelSelectorInputProps = ComponentProps; + +export const ModelSelectorInput = ({ + className, + ...props +}: ModelSelectorInputProps) => ( + +); + +export type ModelSelectorListProps = ComponentProps; + +export const ModelSelectorList = (props: ModelSelectorListProps) => ( + +); + +export type ModelSelectorEmptyProps = ComponentProps; + +export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => ( + +); + +export type ModelSelectorGroupProps = ComponentProps; + +export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => ( + +); + +export type ModelSelectorItemProps = ComponentProps; + +export const ModelSelectorItem = (props: ModelSelectorItemProps) => ( + +); + +export type ModelSelectorShortcutProps = ComponentProps; + +export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => ( + +); + +export type ModelSelectorSeparatorProps = ComponentProps< + typeof CommandSeparator +>; + +export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => ( + +); + +export type ModelSelectorLogoProps = Omit< + ComponentProps<"img">, + "src" | "alt" +> & { + provider: + | "moonshotai-cn" + | "lucidquery" + | "moonshotai" + | "zai-coding-plan" + | "alibaba" + | "xai" + | "vultr" + | "nvidia" + | "upstage" + | "groq" + | "github-copilot" + | "mistral" + | "vercel" + | "nebius" + | "deepseek" + | "alibaba-cn" + | "google-vertex-anthropic" + | "venice" + | "chutes" + | "cortecs" + | "github-models" + | "togetherai" + | "azure" + | "baseten" + | "huggingface" + | "opencode" + | "fastrouter" + | "google" + | "google-vertex" + | "cloudflare-workers-ai" + | "inception" + | "wandb" + | "openai" + | "zhipuai-coding-plan" + | "perplexity" + | "openrouter" + | "zenmux" + | "v0" + | "iflowcn" + | "synthetic" + | "deepinfra" + | "zhipuai" + | "submodel" + | "zai" + | "inference" + | "requesty" + | "morph" + | "lmstudio" + | "anthropic" + | "aihubmix" + | "fireworks-ai" + | "modelscope" + | "llama" + | "scaleway" + | "amazon-bedrock" + | "cerebras" + // oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union + | (string & {}); +}; + +export const ModelSelectorLogo = ({ + provider, + className, + ...props +}: ModelSelectorLogoProps) => ( + {`${provider} +); + +export type ModelSelectorLogoGroupProps = ComponentProps<"div">; + +export const ModelSelectorLogoGroup = ({ + className, + ...props +}: ModelSelectorLogoGroupProps) => ( +
img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground", + className + )} + {...props} + /> +); + +export type ModelSelectorNameProps = ComponentProps<"span">; + +export const ModelSelectorName = ({ + className, + ...props +}: ModelSelectorNameProps) => ( + +); diff --git a/src/components/chat-sidebar.tsx b/src/components/chat-sidebar.tsx index bccaf65..0de01f6 100644 --- a/src/components/chat-sidebar.tsx +++ b/src/components/chat-sidebar.tsx @@ -27,9 +27,23 @@ import { PromptInputTextarea, PromptInputFileUpload, } from "@/components/ai-elements/prompt-input"; +import { + ModelSelector, + ModelSelectorTrigger, + ModelSelectorContent, + ModelSelectorInput, + ModelSelectorList, + ModelSelectorEmpty, + ModelSelectorGroup, + ModelSelectorItem, + ModelSelectorLogo, + ModelSelectorName, +} from "@/components/ai-elements/model-selector"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useWidgetStore, type WidgetMessage, type MessageAttachment } from "@/store/widget-store"; +import { useSettingsStore } from "@/store/settings-store"; +import { PROVIDERS, parseModelString, findProvider } from "@/lib/model-registry"; interface PendingFile { id: string; @@ -193,7 +207,9 @@ function updateAssistantMessage( async function streamToWidget( widgetId: string, - messages: Array<{ role: "user" | "assistant"; content: string | Record[] }> + messages: Array<{ role: "user" | "assistant"; content: string | Record[] }>, + model?: string, + apiKey?: string, ) { const { addMessage, @@ -228,7 +244,7 @@ async function streamToWidget( const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages, widgetId }), + body: JSON.stringify({ messages, widgetId, model, apiKey }), signal: controller.signal, }); @@ -336,7 +352,94 @@ async function streamToWidget( } } +function useModelSelector() { + const selectedModel = useSettingsStore((s) => s.selectedModel); + const setModel = useSettingsStore((s) => s.setModel); + const apiKeys = useSettingsStore((s) => s.apiKeys); + const setApiKey = useSettingsStore((s) => s.setApiKey); + const [open, setOpen] = useState(false); + const [keyInput, setKeyInput] = useState(""); + const [showKeyInput, setShowKeyInput] = useState(false); + + const { providerId, modelId } = parseModelString(selectedModel); + const provider = findProvider(providerId); + const model = provider?.models.find((m) => m.id === modelId); + const hasKey = !!apiKeys[providerId]; + + const handleSelect = (newModel: string) => { + setModel(newModel); + setOpen(false); + const { providerId: pid } = parseModelString(newModel); + if (!apiKeys[pid]) { + setShowKeyInput(true); + } else { + setShowKeyInput(false); + } + }; + + const handleSaveKey = () => { + if (keyInput.trim()) { + setApiKey(providerId, keyInput.trim()); + setKeyInput(""); + setShowKeyInput(false); + } + }; + + const trigger = ( + + + + {model?.name ?? modelId} + {!hasKey && } + + + + + No models found. + {PROVIDERS.map((p) => ( + + {p.models.map((m) => ( + handleSelect(`${p.id}:${m.id}`)} + className="flex items-center gap-2" + > + + {m.name} + + ))} + + ))} + + + + ); + + const keyInputEl = (showKeyInput || !hasKey) ? ( +
+ setKeyInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSaveKey()} + placeholder={`${provider?.name ?? providerId} API key...`} + className="flex-1 bg-zinc-900 border border-zinc-800 text-xs px-2.5 py-1.5 text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600" + /> + +
+ ) : null; + + return { trigger, keyInputEl }; +} + export function ChatSidebar() { + const { trigger: modelTrigger, keyInputEl: modelKeyInput } = useModelSelector(); const widgets = useWidgetStore((s) => s.widgets); const activeWidgetId = useWidgetStore((s) => s.activeWidgetId); const streamingWidgetIds = useWidgetStore((s) => s.streamingWidgetIds); @@ -534,11 +637,15 @@ export function ChatSidebar() { attachments, }); + const { selectedModel, apiKeys } = useSettingsStore.getState(); + const { providerId } = parseModelString(selectedModel); + const byokKey = apiKeys[providerId]; + if (isFirstUserMessage && userContent) { fetch("/api/generate-title", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: userContent }), + body: JSON.stringify({ message: userContent, model: selectedModel, apiKey: byokKey }), }) .then((res) => res.ok ? res.json() : null) .then((data) => { @@ -547,7 +654,7 @@ export function ChatSidebar() { .catch(() => {}); } - streamToWidget(widgetId, messagesForApi); + streamToWidget(widgetId, messagesForApi, selectedModel, byokKey); } return ( @@ -620,7 +727,8 @@ export function ChatSidebar() { -
+
+ {modelKeyInput} {pendingFiles.length > 0 && (
@@ -656,16 +764,15 @@ export function ChatSidebar() { disabled={isActiveStreaming} />
- - {isActiveStreaming ? ( - +
+ {modelTrigger} + {isActiveStreaming && ( + esc to interrupt - ) : ( - "Enter to send · Shift+Enter for newline" )} - +
) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = false, + ...props +}: Omit, "children"> & { + title?: string + description?: string + className?: string + showCloseButton?: boolean + children: React.ReactNode +}) { + return ( + + + {title} + {description} + + + {children} + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + + + + + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index be5408f..3d0e8ea 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -53,7 +53,7 @@ function DialogContent({ } diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx new file mode 100644 index 0000000..da8f1dd --- /dev/null +++ b/src/components/ui/input-group.tsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", + "inline-end": + "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": + "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset" + }) { + return ( +