From ffab287d160e401c2b7103399806aa717ffe4435 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 16 Feb 2026 18:57:39 +0000 Subject: [PATCH] Put in a recent project picker --- frontend/src/App.tsx | 194 ++-- frontend/src/api/client.ts | 404 ++++--- frontend/src/components/Chat.tsx | 1717 +++++++++++++++--------------- frontend/src/main.tsx | 6 +- frontend/vite.config.ts | 24 +- server/src/http/anthropic.rs | 75 ++ server/src/http/project.rs | 7 + server/src/http/ws.rs | 3 +- server/src/io/fs.rs | 20 + 9 files changed, 1334 insertions(+), 1116 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd6c5b3..81f9774 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,90 +4,128 @@ import { Chat } from "./components/Chat"; import "./App.css"; function App() { - const [projectPath, setProjectPath] = React.useState(null); - const [errorMsg, setErrorMsg] = React.useState(null); - const [pathInput, setPathInput] = React.useState(""); - const [isOpening, setIsOpening] = React.useState(false); + const [projectPath, setProjectPath] = React.useState(null); + const [errorMsg, setErrorMsg] = React.useState(null); + const [pathInput, setPathInput] = React.useState(""); + const [isOpening, setIsOpening] = React.useState(false); + const [knownProjects, setKnownProjects] = React.useState([]); - async function openProject(path: string) { - if (!path.trim()) { - setErrorMsg("Please enter a project path."); - return; - } + React.useEffect(() => { + api + .getKnownProjects() + .then((projects) => setKnownProjects(projects)) + .catch((error) => console.error(error)); + }, []); - try { - setErrorMsg(null); - setIsOpening(true); - const confirmedPath = await api.openProject(path.trim()); - setProjectPath(confirmedPath); - } catch (e) { - console.error(e); - const message = - e instanceof Error - ? e.message - : typeof e === "string" - ? e - : "An error occurred opening the project."; - setErrorMsg(message); - } finally { - setIsOpening(false); - } - } + async function openProject(path: string) { + if (!path.trim()) { + setErrorMsg("Please enter a project path."); + return; + } - function handleOpen() { - void openProject(pathInput); - } + try { + setErrorMsg(null); + setIsOpening(true); + const confirmedPath = await api.openProject(path.trim()); + setProjectPath(confirmedPath); + } catch (e) { + console.error(e); + const message = + e instanceof Error + ? e.message + : typeof e === "string" + ? e + : "An error occurred opening the project."; + setErrorMsg(message); + } finally { + setIsOpening(false); + } + } - async function closeProject() { - try { - await api.closeProject(); - setProjectPath(null); - } catch (e) { - console.error(e); - } - } + function handleOpen() { + void openProject(pathInput); + } - return ( -
- {!projectPath ? ( -
-

AI Code Assistant

-

Paste a project path to start the Story-Driven Spec Workflow.

- setPathInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleOpen(); - } - }} - style={{ width: "100%", padding: "10px", marginTop: "12px" }} - /> - -
- ) : ( -
- -
- )} + async function closeProject() { + try { + await api.closeProject(); + setProjectPath(null); + } catch (e) { + console.error(e); + } + } - {errorMsg && ( -
-

Error: {errorMsg}

-
- )} -
- ); + return ( +
+ {!projectPath ? ( +
+

AI Code Assistant

+

Paste a project path to start the Story-Driven Spec Workflow.

+ {knownProjects.length > 0 && ( +
+
+ Recent projects +
+
    + {knownProjects.map((project) => ( +
  • + +
  • + ))} +
+
+ )} + setPathInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleOpen(); + } + }} + style={{ width: "100%", padding: "10px", marginTop: "12px" }} + /> + +
+ ) : ( +
+ +
+ )} + + {errorMsg && ( +
+

Error: {errorMsg}

+
+ )} +
+ ); } export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a49a287..93f043d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,226 +1,276 @@ export type WsRequest = - | { - type: "chat"; - messages: Message[]; - config: ProviderConfig; - } - | { - type: "cancel"; - }; + | { + type: "chat"; + messages: Message[]; + config: ProviderConfig; + } + | { + type: "cancel"; + }; export type WsResponse = - | { type: "token"; content: string } - | { type: "update"; messages: Message[] } - | { type: "error"; message: string }; + | { type: "token"; content: string } + | { type: "update"; messages: Message[] } + | { type: "error"; message: string }; export interface ProviderConfig { - provider: string; - model: string; - base_url?: string; - enable_tools?: boolean; + provider: string; + model: string; + base_url?: string; + enable_tools?: boolean; } export type Role = "system" | "user" | "assistant" | "tool"; export interface ToolCall { - id?: string; - type: string; - function: { - name: string; - arguments: string; - }; + id?: string; + type: string; + function: { + name: string; + arguments: string; + }; } export interface Message { - role: Role; - content: string; - tool_calls?: ToolCall[]; - tool_call_id?: string; + role: Role; + content: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; } export interface FileEntry { - name: string; - kind: "file" | "dir"; + name: string; + kind: "file" | "dir"; } export interface SearchResult { - path: string; - matches: number; + path: string; + matches: number; } export interface CommandOutput { - stdout: string; - stderr: string; - exit_code: number; + stdout: string; + stderr: string; + exit_code: number; } const DEFAULT_API_BASE = "/api"; const DEFAULT_WS_PATH = "/ws"; function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { - return `${baseUrl}${path}`; + return `${baseUrl}${path}`; } async function requestJson( - path: string, - options: RequestInit = {}, - baseUrl = DEFAULT_API_BASE, + path: string, + options: RequestInit = {}, + baseUrl = DEFAULT_API_BASE, ): Promise { - const res = await fetch(buildApiUrl(path, baseUrl), { - headers: { - "Content-Type": "application/json", - ...(options.headers ?? {}), - }, - ...options, - }); + const res = await fetch(buildApiUrl(path, baseUrl), { + headers: { + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + ...options, + }); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Request failed (${res.status})`); - } + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed (${res.status})`); + } - return res.json() as Promise; + return res.json() as Promise; } export const api = { - getCurrentProject(baseUrl?: string) { - return requestJson("/project", {}, baseUrl); - }, - openProject(path: string, baseUrl?: string) { - return requestJson( - "/project", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - closeProject(baseUrl?: string) { - return requestJson("/project", { method: "DELETE" }, baseUrl); - }, - getModelPreference(baseUrl?: string) { - return requestJson("/model", {}, baseUrl); - }, - setModelPreference(model: string, baseUrl?: string) { - return requestJson( - "/model", - { method: "POST", body: JSON.stringify({ model }) }, - baseUrl, - ); - }, - getOllamaModels(baseUrlParam?: string, baseUrl?: string) { - const url = new URL( - buildApiUrl("/ollama/models", baseUrl), - window.location.origin, - ); - if (baseUrlParam) { - url.searchParams.set("base_url", baseUrlParam); - } - return requestJson(url.pathname + url.search, {}, ""); - }, - getAnthropicApiKeyExists(baseUrl?: string) { - return requestJson("/anthropic/key/exists", {}, baseUrl); - }, - setAnthropicApiKey(api_key: string, baseUrl?: string) { - return requestJson( - "/anthropic/key", - { method: "POST", body: JSON.stringify({ api_key }) }, - baseUrl, - ); - }, - readFile(path: string, baseUrl?: string) { - return requestJson( - "/fs/read", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - writeFile(path: string, content: string, baseUrl?: string) { - return requestJson( - "/fs/write", - { method: "POST", body: JSON.stringify({ path, content }) }, - baseUrl, - ); - }, - listDirectory(path: string, baseUrl?: string) { - return requestJson( - "/fs/list", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - searchFiles(query: string, baseUrl?: string) { - return requestJson( - "/fs/search", - { method: "POST", body: JSON.stringify({ query }) }, - baseUrl, - ); - }, - execShell(command: string, args: string[], baseUrl?: string) { - return requestJson( - "/shell/exec", - { method: "POST", body: JSON.stringify({ command, args }) }, - baseUrl, - ); - }, - cancelChat(baseUrl?: string) { - return requestJson("/chat/cancel", { method: "POST" }, baseUrl); - }, + getCurrentProject(baseUrl?: string) { + return requestJson("/project", {}, baseUrl); + }, + getKnownProjects(baseUrl?: string) { + return requestJson("/projects", {}, baseUrl); + }, + openProject(path: string, baseUrl?: string) { + return requestJson( + "/project", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + closeProject(baseUrl?: string) { + return requestJson("/project", { method: "DELETE" }, baseUrl); + }, + getModelPreference(baseUrl?: string) { + return requestJson("/model", {}, baseUrl); + }, + setModelPreference(model: string, baseUrl?: string) { + return requestJson( + "/model", + { method: "POST", body: JSON.stringify({ model }) }, + baseUrl, + ); + }, + getOllamaModels(baseUrlParam?: string, baseUrl?: string) { + const url = new URL( + buildApiUrl("/ollama/models", baseUrl), + window.location.origin, + ); + if (baseUrlParam) { + url.searchParams.set("base_url", baseUrlParam); + } + return requestJson(url.pathname + url.search, {}, ""); + }, + getAnthropicApiKeyExists(baseUrl?: string) { + return requestJson("/anthropic/key/exists", {}, baseUrl); + }, + getAnthropicModels(baseUrl?: string) { + return requestJson("/anthropic/models", {}, baseUrl); + }, + setAnthropicApiKey(api_key: string, baseUrl?: string) { + return requestJson( + "/anthropic/key", + { method: "POST", body: JSON.stringify({ api_key }) }, + baseUrl, + ); + }, + readFile(path: string, baseUrl?: string) { + return requestJson( + "/fs/read", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + writeFile(path: string, content: string, baseUrl?: string) { + return requestJson( + "/fs/write", + { method: "POST", body: JSON.stringify({ path, content }) }, + baseUrl, + ); + }, + listDirectory(path: string, baseUrl?: string) { + return requestJson( + "/fs/list", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + searchFiles(query: string, baseUrl?: string) { + return requestJson( + "/fs/search", + { method: "POST", body: JSON.stringify({ query }) }, + baseUrl, + ); + }, + execShell(command: string, args: string[], baseUrl?: string) { + return requestJson( + "/shell/exec", + { method: "POST", body: JSON.stringify({ command, args }) }, + baseUrl, + ); + }, + cancelChat(baseUrl?: string) { + return requestJson("/chat/cancel", { method: "POST" }, baseUrl); + }, }; export class ChatWebSocket { - private socket?: WebSocket; - private onToken?: (content: string) => void; - private onUpdate?: (messages: Message[]) => void; - private onError?: (message: string) => void; + private static sharedSocket: WebSocket | null = null; + private static refCount = 0; + private socket?: WebSocket; + private onToken?: (content: string) => void; + private onUpdate?: (messages: Message[]) => void; + private onError?: (message: string) => void; + private connected = false; + private closeTimer?: number; - connect( - handlers: { - onToken?: (content: string) => void; - onUpdate?: (messages: Message[]) => void; - onError?: (message: string) => void; - }, - wsPath = DEFAULT_WS_PATH, - ) { - this.onToken = handlers.onToken; - this.onUpdate = handlers.onUpdate; - this.onError = handlers.onError; + connect( + handlers: { + onToken?: (content: string) => void; + onUpdate?: (messages: Message[]) => void; + onError?: (message: string) => void; + }, + wsPath = DEFAULT_WS_PATH, + ) { + this.onToken = handlers.onToken; + this.onUpdate = handlers.onUpdate; + this.onError = handlers.onError; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const wsUrl = `${protocol}://${window.location.host}${wsPath}`; - this.socket = new WebSocket(wsUrl); + if (this.connected) { + return; + } + this.connected = true; + ChatWebSocket.refCount += 1; - this.socket.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as WsResponse; - if (data.type === "token") this.onToken?.(data.content); - if (data.type === "update") this.onUpdate?.(data.messages); - if (data.type === "error") this.onError?.(data.message); - } catch (err) { - this.onError?.(String(err)); - } - }; + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const wsHost = import.meta.env.DEV + ? "127.0.0.1:3001" + : window.location.host; + const wsUrl = `${protocol}://${wsHost}${wsPath}`; - this.socket.onerror = () => { - this.onError?.("WebSocket error"); - }; - } + if ( + !ChatWebSocket.sharedSocket || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING + ) { + ChatWebSocket.sharedSocket = new WebSocket(wsUrl); + } + this.socket = ChatWebSocket.sharedSocket; - sendChat(messages: Message[], config: ProviderConfig) { - this.send({ type: "chat", messages, config }); - } + this.socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as WsResponse; + if (data.type === "token") this.onToken?.(data.content); + if (data.type === "update") this.onUpdate?.(data.messages); + if (data.type === "error") this.onError?.(data.message); + } catch (err) { + this.onError?.(String(err)); + } + }; - cancel() { - this.send({ type: "cancel" }); - } + this.socket.onerror = () => { + this.onError?.("WebSocket error"); + }; + } - close() { - this.socket?.close(); - } + sendChat(messages: Message[], config: ProviderConfig) { + this.send({ type: "chat", messages, config }); + } - private send(payload: WsRequest) { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - this.onError?.("WebSocket is not connected"); - return; - } - this.socket.send(JSON.stringify(payload)); - } + cancel() { + this.send({ type: "cancel" }); + } + + close() { + if (!this.connected) return; + this.connected = false; + ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); + + if (import.meta.env.DEV) { + if (this.closeTimer) { + window.clearTimeout(this.closeTimer); + } + this.closeTimer = window.setTimeout(() => { + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + this.closeTimer = undefined; + }, 250); + return; + } + + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + } + + private send(payload: WsRequest) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + this.onError?.("WebSocket is not connected"); + return; + } + this.socket.send(JSON.stringify(payload)); + } } diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index a436304..78941a6 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -8,899 +8,930 @@ import type { Message, ProviderConfig, ToolCall } from "../types"; const { useCallback, useEffect, useRef, useState } = React; interface ChatProps { - projectPath: string; - onCloseProject: () => void; + projectPath: string; + onCloseProject: () => void; } export function Chat({ projectPath, onCloseProject }: ChatProps) { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - const [model, setModel] = useState("llama3.1"); - const [enableTools, setEnableTools] = useState(true); - const [availableModels, setAvailableModels] = useState([]); - const [claudeModels] = useState([ - "claude-3-5-sonnet-20241022", - "claude-3-5-haiku-20241022", - ]); - const [streamingContent, setStreamingContent] = useState(""); - const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); - const [apiKeyInput, setApiKeyInput] = useState(""); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [model, setModel] = useState("llama3.1"); + const [enableTools, setEnableTools] = useState(true); + const [availableModels, setAvailableModels] = useState([]); + const [claudeModels, setClaudeModels] = useState([]); + const [streamingContent, setStreamingContent] = useState(""); + const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); + const [apiKeyInput, setApiKeyInput] = useState(""); + const [hasAnthropicKey, setHasAnthropicKey] = useState(false); - const wsRef = useRef(null); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const scrollContainerRef = useRef(null); - const shouldAutoScrollRef = useRef(true); - const lastScrollTopRef = useRef(0); - const userScrolledUpRef = useRef(false); - const pendingMessageRef = useRef(""); + const wsRef = useRef(null); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const scrollContainerRef = useRef(null); + const shouldAutoScrollRef = useRef(true); + const lastScrollTopRef = useRef(0); + const userScrolledUpRef = useRef(false); + const pendingMessageRef = useRef(""); - const estimateTokens = (text: string): number => Math.ceil(text.length / 4); + const estimateTokens = (text: string): number => Math.ceil(text.length / 4); - const getContextWindowSize = (modelName: string): number => { - if (modelName.startsWith("claude-")) return 200000; - if (modelName.includes("llama3")) return 8192; - if (modelName.includes("qwen2.5")) return 32768; - if (modelName.includes("deepseek")) return 16384; - return 8192; - }; + const getContextWindowSize = (modelName: string): number => { + if (modelName.startsWith("claude-")) return 200000; + if (modelName.includes("llama3")) return 8192; + if (modelName.includes("qwen2.5")) return 32768; + if (modelName.includes("deepseek")) return 16384; + return 8192; + }; - const calculateContextUsage = (): { - used: number; - total: number; - percentage: number; - } => { - let totalTokens = 0; + const calculateContextUsage = (): { + used: number; + total: number; + percentage: number; + } => { + let totalTokens = 0; - totalTokens += 200; + totalTokens += 200; - for (const msg of messages) { - totalTokens += estimateTokens(msg.content); - if (msg.tool_calls) { - totalTokens += estimateTokens(JSON.stringify(msg.tool_calls)); - } - } + for (const msg of messages) { + totalTokens += estimateTokens(msg.content); + if (msg.tool_calls) { + totalTokens += estimateTokens(JSON.stringify(msg.tool_calls)); + } + } - if (streamingContent) { - totalTokens += estimateTokens(streamingContent); - } + if (streamingContent) { + totalTokens += estimateTokens(streamingContent); + } - const contextWindow = getContextWindowSize(model); - const percentage = Math.round((totalTokens / contextWindow) * 100); + const contextWindow = getContextWindowSize(model); + const percentage = Math.round((totalTokens / contextWindow) * 100); - return { - used: totalTokens, - total: contextWindow, - percentage, - }; - }; + return { + used: totalTokens, + total: contextWindow, + percentage, + }; + }; - const contextUsage = calculateContextUsage(); + const contextUsage = calculateContextUsage(); - const getContextEmoji = (percentage: number): string => { - if (percentage >= 90) return "🔴"; - if (percentage >= 75) return "🟡"; - return "🟢"; - }; + const getContextEmoji = (percentage: number): string => { + if (percentage >= 90) return "🔴"; + if (percentage >= 75) return "🟡"; + return "🟢"; + }; - useEffect(() => { - api - .getOllamaModels() - .then(async (models) => { - if (models.length > 0) { - const sortedModels = models.sort((a, b) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ); - setAvailableModels(sortedModels); + useEffect(() => { + api + .getOllamaModels() + .then(async (models) => { + if (models.length > 0) { + const sortedModels = models.sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + setAvailableModels(sortedModels); - try { - const savedModel = await api.getModelPreference(); - if (savedModel) { - setModel(savedModel); - } else if (sortedModels.length > 0) { - setModel(sortedModels[0]); - } - } catch (e) { - console.error(e); - } - } - }) - .catch((err) => console.error(err)); - }, []); + try { + const savedModel = await api.getModelPreference(); + if (savedModel) { + setModel(savedModel); + } else if (sortedModels.length > 0) { + setModel(sortedModels[0]); + } + } catch (e) { + console.error(e); + } + } + }) + .catch((err) => console.error(err)); - useEffect(() => { - const ws = new ChatWebSocket(); - wsRef.current = ws; + api + .getAnthropicApiKeyExists() + .then((exists) => { + setHasAnthropicKey(exists); + }) + .catch((err) => { + console.error(err); + setHasAnthropicKey(false); + }); - ws.connect({ - onToken: (content) => { - setStreamingContent((prev: string) => prev + content); - }, - onUpdate: (history) => { - setMessages(history); - setStreamingContent(""); - const last = history[history.length - 1]; - if (last?.role === "assistant" && !last.tool_calls) { - setLoading(false); - } - }, - onError: (message) => { - console.error("WebSocket error:", message); - setLoading(false); - }, - }); + api + .getAnthropicModels() + .then((models) => { + if (models.length > 0) { + const sortedModels = models.sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + setClaudeModels(sortedModels); + } else { + setClaudeModels([]); + } + }) + .catch((err) => { + console.error(err); + setClaudeModels([]); + }); + }, []); - return () => { - ws.close(); - wsRef.current = null; - }; - }, []); + useEffect(() => { + const ws = new ChatWebSocket(); + wsRef.current = ws; - const scrollToBottom = useCallback(() => { - const element = scrollContainerRef.current; - if (element) { - element.scrollTop = element.scrollHeight; - lastScrollTopRef.current = element.scrollHeight; - } - }, []); + ws.connect({ + onToken: (content) => { + setStreamingContent((prev: string) => prev + content); + }, + onUpdate: (history) => { + setMessages(history); + setStreamingContent(""); + const last = history[history.length - 1]; + if (last?.role === "assistant" && !last.tool_calls) { + setLoading(false); + } + }, + onError: (message) => { + console.error("WebSocket error:", message); + setLoading(false); + }, + }); - const handleScroll = () => { - const element = scrollContainerRef.current; - if (!element) return; + return () => { + ws.close(); + wsRef.current = null; + }; + }, []); - const currentScrollTop = element.scrollTop; - const isAtBottom = - element.scrollHeight - element.scrollTop - element.clientHeight < 5; + const scrollToBottom = useCallback(() => { + const element = scrollContainerRef.current; + if (element) { + element.scrollTop = element.scrollHeight; + lastScrollTopRef.current = element.scrollHeight; + } + }, []); - if (currentScrollTop < lastScrollTopRef.current) { - userScrolledUpRef.current = true; - shouldAutoScrollRef.current = false; - } + const handleScroll = () => { + const element = scrollContainerRef.current; + if (!element) return; - if (isAtBottom) { - userScrolledUpRef.current = false; - shouldAutoScrollRef.current = true; - } + const currentScrollTop = element.scrollTop; + const isAtBottom = + element.scrollHeight - element.scrollTop - element.clientHeight < 5; - lastScrollTopRef.current = currentScrollTop; - }; + if (currentScrollTop < lastScrollTopRef.current) { + userScrolledUpRef.current = true; + shouldAutoScrollRef.current = false; + } - const autoScrollKey = messages.length + streamingContent.length; + if (isAtBottom) { + userScrolledUpRef.current = false; + shouldAutoScrollRef.current = true; + } - useEffect(() => { - if ( - autoScrollKey >= 0 && - shouldAutoScrollRef.current && - !userScrolledUpRef.current - ) { - scrollToBottom(); - } - }, [autoScrollKey, scrollToBottom]); + lastScrollTopRef.current = currentScrollTop; + }; - useEffect(() => { - inputRef.current?.focus(); - }, []); + const autoScrollKey = messages.length + streamingContent.length; - const cancelGeneration = async () => { - try { - wsRef.current?.cancel(); - await api.cancelChat(); + useEffect(() => { + if ( + autoScrollKey >= 0 && + shouldAutoScrollRef.current && + !userScrolledUpRef.current + ) { + scrollToBottom(); + } + }, [autoScrollKey, scrollToBottom]); - if (streamingContent) { - setMessages((prev: Message[]) => [ - ...prev, - { role: "assistant", content: streamingContent }, - ]); - setStreamingContent(""); - } + useEffect(() => { + inputRef.current?.focus(); + }, []); - setLoading(false); - } catch (e) { - console.error("Failed to cancel chat:", e); - } - }; + const cancelGeneration = async () => { + try { + wsRef.current?.cancel(); + await api.cancelChat(); - const sendMessage = async (messageOverride?: string) => { - const messageToSend = messageOverride ?? input; - if (!messageToSend.trim() || loading) return; + if (streamingContent) { + setMessages((prev: Message[]) => [ + ...prev, + { role: "assistant", content: streamingContent }, + ]); + setStreamingContent(""); + } - if (model.startsWith("claude-")) { - const hasKey = await api.getAnthropicApiKeyExists(); - if (!hasKey) { - pendingMessageRef.current = messageToSend; - setShowApiKeyDialog(true); - return; - } - } + setLoading(false); + } catch (e) { + console.error("Failed to cancel chat:", e); + } + }; - const userMsg: Message = { role: "user", content: messageToSend }; - const newHistory = [...messages, userMsg]; + const sendMessage = async (messageOverride?: string) => { + const messageToSend = messageOverride ?? input; + if (!messageToSend.trim() || loading) return; - setMessages(newHistory); - if (!messageOverride || messageOverride === input) { - setInput(""); - } - setLoading(true); - setStreamingContent(""); + if (model.startsWith("claude-")) { + const hasKey = await api.getAnthropicApiKeyExists(); + if (!hasKey) { + pendingMessageRef.current = messageToSend; + setShowApiKeyDialog(true); + return; + } + } - try { - const config: ProviderConfig = { - provider: model.startsWith("claude-") ? "anthropic" : "ollama", - model, - base_url: "http://localhost:11434", - enable_tools: enableTools, - }; + const userMsg: Message = { role: "user", content: messageToSend }; + const newHistory = [...messages, userMsg]; - wsRef.current?.sendChat(newHistory, config); - } catch (e) { - console.error("Chat error:", e); - const errorMessage = String(e); - if (!errorMessage.includes("Chat cancelled by user")) { - setMessages((prev: Message[]) => [ - ...prev, - { role: "assistant", content: `**Error:** ${e}` }, - ]); - } - setLoading(false); - } - }; + setMessages(newHistory); + if (!messageOverride || messageOverride === input) { + setInput(""); + } + setLoading(true); + setStreamingContent(""); - const handleSaveApiKey = async () => { - if (!apiKeyInput.trim()) return; + try { + const config: ProviderConfig = { + provider: model.startsWith("claude-") ? "anthropic" : "ollama", + model, + base_url: "http://localhost:11434", + enable_tools: enableTools, + }; - try { - await api.setAnthropicApiKey(apiKeyInput); - setShowApiKeyDialog(false); - setApiKeyInput(""); + wsRef.current?.sendChat(newHistory, config); + } catch (e) { + console.error("Chat error:", e); + const errorMessage = String(e); + if (!errorMessage.includes("Chat cancelled by user")) { + setMessages((prev: Message[]) => [ + ...prev, + { role: "assistant", content: `**Error:** ${e}` }, + ]); + } + setLoading(false); + } + }; - const pendingMessage = pendingMessageRef.current; - pendingMessageRef.current = ""; + const handleSaveApiKey = async () => { + if (!apiKeyInput.trim()) return; - if (pendingMessage.trim()) { - sendMessage(pendingMessage); - } - } catch (e) { - console.error("Failed to save API key:", e); - alert(`Failed to save API key: ${e}`); - } - }; + try { + await api.setAnthropicApiKey(apiKeyInput); + setShowApiKeyDialog(false); + setApiKeyInput(""); - const clearSession = async () => { - const confirmed = window.confirm( - "Are you sure? This will clear all messages and reset the conversation context.", - ); + const pendingMessage = pendingMessageRef.current; + pendingMessageRef.current = ""; - if (confirmed) { - try { - await api.cancelChat(); - wsRef.current?.cancel(); - } catch (e) { - console.error("Failed to cancel chat:", e); - } + if (pendingMessage.trim()) { + sendMessage(pendingMessage); + } + } catch (e) { + console.error("Failed to save API key:", e); + alert(`Failed to save API key: ${e}`); + } + }; - setMessages([]); - setStreamingContent(""); - setLoading(false); - } - }; + const clearSession = async () => { + const confirmed = window.confirm( + "Are you sure? This will clear all messages and reset the conversation context.", + ); - return ( -
-
-
-
- {projectPath} -
- -
+ if (confirmed) { + try { + await api.cancelChat(); + wsRef.current?.cancel(); + } catch (e) { + console.error("Failed to cancel chat:", e); + } -
-
- {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage} - % -
+ setMessages([]); + setStreamingContent(""); + setLoading(false); + } + }; - - {availableModels.length > 0 || claudeModels.length > 0 ? ( - - ) : ( - { - const newModel = e.target.value; - setModel(newModel); - api.setModelPreference(newModel).catch(console.error); - }} - placeholder="Model" - style={{ - padding: "6px 12px", - borderRadius: "99px", - border: "none", - fontSize: "0.9em", - background: "#2f2f2f", - color: "#ececec", - outline: "none", - }} - /> - )} - -
-
+ return ( +
+
+
+
+ {projectPath} +
+ +
-
-
- {messages.map((msg: Message, idx: number) => ( -
-
- {msg.role === "user" ? ( - msg.content - ) : msg.role === "tool" ? ( -
- - â–¶ - - Tool Output - {msg.tool_call_id && ` (${msg.tool_call_id})`} - - -
-											{msg.content}
-										
-
- ) : ( -
- { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {msg.content} - -
- )} +
+
+ {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage} + % +
- {msg.tool_calls && ( -
- {msg.tool_calls.map((tc: ToolCall, i: number) => { - let argsSummary = ""; - try { - const args = JSON.parse(tc.function.arguments); - const firstKey = Object.keys(args)[0]; - if (firstKey && args[firstKey]) { - argsSummary = String(args[firstKey]); - if (argsSummary.length > 50) { - argsSummary = `${argsSummary.substring(0, 47)}...`; - } - } - } catch (_e) { - // ignore - } + + {availableModels.length > 0 || claudeModels.length > 0 ? ( + + ) : ( + { + const newModel = e.target.value; + setModel(newModel); + api.setModelPreference(newModel).catch(console.error); + }} + placeholder="Model" + style={{ + padding: "6px 12px", + borderRadius: "99px", + border: "none", + fontSize: "0.9em", + background: "#2f2f2f", + color: "#ececec", + outline: "none", + }} + /> + )} + +
+
- return ( -
- â–¶ - - {tc.function.name} - {argsSummary && `(${argsSummary})`} - -
- ); - })} -
- )} -
-
- ))} - {loading && streamingContent && ( -
-
- { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {streamingContent} - -
-
- )} - {loading && !streamingContent && ( -
- Thinking... -
- )} -
-
-
+
+
+ {messages.map((msg: Message, idx: number) => ( +
+
+ {msg.role === "user" ? ( + msg.content + ) : msg.role === "tool" ? ( +
+ + â–¶ + + Tool Output + {msg.tool_call_id && ` (${msg.tool_call_id})`} + + +
+                      {msg.content}
+                    
+
+ ) : ( +
+ { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {msg.content} + +
+ )} -
-
- setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - sendMessage(); - } - }} - placeholder="Send a message..." - style={{ - flex: 1, - padding: "14px 20px", - borderRadius: "24px", - border: "1px solid #333", - outline: "none", - fontSize: "1rem", - fontWeight: "500", - background: "#2f2f2f", - color: "#ececec", - boxShadow: "0 2px 6px rgba(0,0,0,0.02)", - }} - /> - -
-
+ {msg.tool_calls && ( +
+ {msg.tool_calls.map((tc: ToolCall, i: number) => { + let argsSummary = ""; + try { + const args = JSON.parse(tc.function.arguments); + const firstKey = Object.keys(args)[0]; + if (firstKey && args[firstKey]) { + argsSummary = String(args[firstKey]); + if (argsSummary.length > 50) { + argsSummary = `${argsSummary.substring(0, 47)}...`; + } + } + } catch (_e) { + // ignore + } - {showApiKeyDialog && ( -
-
-

- Enter Anthropic API Key -

-

- To use Claude models, please enter your Anthropic API key. Your - key will be stored server-side and reused across sessions. -

- setApiKeyInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()} - placeholder="sk-ant-..." - style={{ - width: "100%", - padding: "12px", - borderRadius: "8px", - border: "1px solid #555", - backgroundColor: "#1a1a1a", - color: "#ececec", - fontSize: "1em", - marginBottom: "20px", - outline: "none", - }} - /> -
- - -
-
-
- )} -
- ); + return ( +
+ â–¶ + + {tc.function.name} + {argsSummary && `(${argsSummary})`} + +
+ ); + })} +
+ )} +
+
+ ))} + {loading && streamingContent && ( +
+
+ { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {streamingContent} + +
+
+ )} + {loading && !streamingContent && ( +
+ Thinking... +
+ )} +
+
+
+ +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + sendMessage(); + } + }} + placeholder="Send a message..." + style={{ + flex: 1, + padding: "14px 20px", + borderRadius: "24px", + border: "1px solid #333", + outline: "none", + fontSize: "1rem", + fontWeight: "500", + background: "#2f2f2f", + color: "#ececec", + boxShadow: "0 2px 6px rgba(0,0,0,0.02)", + }} + /> + +
+
+ + {showApiKeyDialog && ( +
+
+

+ Enter Anthropic API Key +

+

+ To use Claude models, please enter your Anthropic API key. Your + key will be stored server-side and reused across sessions. +

+ setApiKeyInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()} + placeholder="sk-ant-..." + style={{ + width: "100%", + padding: "12px", + borderRadius: "8px", + border: "1px solid #555", + backgroundColor: "#1a1a1a", + color: "#ececec", + fontSize: "1em", + marginBottom: "20px", + outline: "none", + }} + /> +
+ + +
+
+
+ )} +
+ ); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a90798a..77df5b5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client"; import App from "./App"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , + + + , ); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 863aea4..3cd5c35 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,18 +3,14 @@ import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig(() => ({ - plugins: [react()], - server: { - proxy: { - "/api": "http://localhost:3001", - "/ws": { - target: "ws://localhost:3001", - ws: true, - }, - }, - }, - build: { - outDir: "dist", - emptyOutDir: true, - }, + plugins: [react()], + server: { + proxy: { + "/api": "http://127.0.0.1:3001", + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, })); diff --git a/server/src/http/anthropic.rs b/server/src/http/anthropic.rs index 6f6639f..7123bb5 100644 --- a/server/src/http/anthropic.rs +++ b/server/src/http/anthropic.rs @@ -1,9 +1,42 @@ use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::llm::chat; +use crate::store::StoreOps; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; +use reqwest::header::{HeaderMap, HeaderValue}; use serde::Deserialize; use std::sync::Arc; +const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models"; +const ANTHROPIC_VERSION: &str = "2023-06-01"; +const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key"; + +#[derive(Deserialize)] +struct AnthropicModelsResponse { + data: Vec, +} + +#[derive(Deserialize)] +struct AnthropicModelInfo { + id: String, +} + +fn get_anthropic_api_key(ctx: &AppContext) -> Result { + match ctx.store.get(KEY_ANTHROPIC_API_KEY) { + Some(value) => { + if let Some(key) = value.as_str() { + if key.is_empty() { + Err("Anthropic API key is empty. Please set your API key.".to_string()) + } else { + Ok(key.to_string()) + } + } else { + Err("Stored API key is not a string".to_string()) + } + } + None => Err("Anthropic API key not found. Please set your API key.".to_string()), + } +} + #[derive(Deserialize, Object)] struct ApiKeyPayload { api_key: String, @@ -48,4 +81,46 @@ impl AnthropicApi { .map_err(bad_request)?; Ok(Json(true)) } + + /// List available Anthropic models. + #[oai(path = "/anthropic/models", method = "get")] + async fn list_anthropic_models(&self) -> OpenApiResult>> { + let api_key = get_anthropic_api_key(self.ctx.as_ref()).map_err(bad_request)?; + let client = reqwest::Client::new(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-api-key", + HeaderValue::from_str(&api_key).map_err(|e| bad_request(e.to_string()))?, + ); + headers.insert( + "anthropic-version", + HeaderValue::from_static(ANTHROPIC_VERSION), + ); + + let response = client + .get(ANTHROPIC_MODELS_URL) + .headers(headers) + .send() + .await + .map_err(|e| bad_request(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(bad_request(format!( + "Anthropic API error {status}: {error_text}" + ))); + } + + let body = response + .json::() + .await + .map_err(|e| bad_request(e.to_string()))?; + let models = body.data.into_iter().map(|m| m.id).collect(); + + Ok(Json(models)) + } } diff --git a/server/src/http/project.rs b/server/src/http/project.rs index 2a188ed..5ec575d 100644 --- a/server/src/http/project.rs +++ b/server/src/http/project.rs @@ -47,4 +47,11 @@ impl ProjectApi { fs::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(bad_request)?; Ok(Json(true)) } + + /// List known projects from the store. + #[oai(path = "/projects", method = "get")] + async fn list_known_projects(&self) -> OpenApiResult>> { + let projects = fs::get_known_projects(self.ctx.store.as_ref()).map_err(bad_request)?; + Ok(Json(projects)) + } } diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index 4875c3c..6dac5fa 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -6,6 +6,7 @@ use poem::handler; use poem::web::Data; use poem::web::websocket::{Message as WsMessage, WebSocket}; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use tokio::sync::mpsc; #[derive(Deserialize)] @@ -39,7 +40,7 @@ enum WsResponse { /// WebSocket endpoint for streaming chat responses and cancellation. /// /// Accepts JSON `WsRequest` messages and streams `WsResponse` messages. -pub async fn ws_handler(ws: WebSocket, ctx: Data<&AppContext>) -> impl poem::IntoResponse { +pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc>) -> impl poem::IntoResponse { let ctx = ctx.0.clone(); ws.on_upgrade(move |socket| async move { let (mut sink, mut stream) = socket.split(); diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index 5a060cc..75db80c 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; const KEY_LAST_PROJECT: &str = "last_project_path"; const KEY_SELECTED_MODEL: &str = "selected_model"; +const KEY_KNOWN_PROJECTS: &str = "known_projects"; /// Resolves a relative path against the active project root (pure function for testing). /// Returns error if path attempts traversal (..). @@ -55,6 +56,13 @@ pub async fn open_project( } store.set(KEY_LAST_PROJECT, json!(path)); + + let mut known_projects = get_known_projects(store)?; + + known_projects.retain(|p| p != &path); + known_projects.insert(0, path.clone()); + store.set(KEY_KNOWN_PROJECTS, json!(known_projects)); + store.save()?; Ok(path) @@ -99,6 +107,18 @@ pub fn get_current_project( Ok(None) } +pub fn get_known_projects(store: &dyn StoreOps) -> Result, String> { + let projects = store + .get(KEY_KNOWN_PROJECTS) + .and_then(|val| val.as_array().cloned()) + .unwrap_or_default() + .into_iter() + .filter_map(|val| val.as_str().map(|s| s.to_string())) + .collect(); + + Ok(projects) +} + pub fn get_model_preference(store: &dyn StoreOps) -> Result, String> { if let Some(model) = store .get(KEY_SELECTED_MODEL)