Put in a recent project picker
This commit is contained in:
@@ -4,90 +4,128 @@ import { Chat } from "./components/Chat";
|
|||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
||||||
const [pathInput, setPathInput] = React.useState("");
|
const [pathInput, setPathInput] = React.useState("");
|
||||||
const [isOpening, setIsOpening] = React.useState(false);
|
const [isOpening, setIsOpening] = React.useState(false);
|
||||||
|
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
||||||
|
|
||||||
async function openProject(path: string) {
|
React.useEffect(() => {
|
||||||
if (!path.trim()) {
|
api
|
||||||
setErrorMsg("Please enter a project path.");
|
.getKnownProjects()
|
||||||
return;
|
.then((projects) => setKnownProjects(projects))
|
||||||
}
|
.catch((error) => console.error(error));
|
||||||
|
}, []);
|
||||||
|
|
||||||
try {
|
async function openProject(path: string) {
|
||||||
setErrorMsg(null);
|
if (!path.trim()) {
|
||||||
setIsOpening(true);
|
setErrorMsg("Please enter a project path.");
|
||||||
const confirmedPath = await api.openProject(path.trim());
|
return;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpen() {
|
try {
|
||||||
void openProject(pathInput);
|
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() {
|
function handleOpen() {
|
||||||
try {
|
void openProject(pathInput);
|
||||||
await api.closeProject();
|
}
|
||||||
setProjectPath(null);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
async function closeProject() {
|
||||||
<main
|
try {
|
||||||
className="container"
|
await api.closeProject();
|
||||||
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
|
setProjectPath(null);
|
||||||
>
|
} catch (e) {
|
||||||
{!projectPath ? (
|
console.error(e);
|
||||||
<div
|
}
|
||||||
className="selection-screen"
|
}
|
||||||
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
|
|
||||||
>
|
|
||||||
<h1>AI Code Assistant</h1>
|
|
||||||
<p>Paste a project path to start the Story-Driven Spec Workflow.</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={pathInput}
|
|
||||||
placeholder="/path/to/project"
|
|
||||||
onChange={(event) => setPathInput(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
handleOpen();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ width: "100%", padding: "10px", marginTop: "12px" }}
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={handleOpen} disabled={isOpening}>
|
|
||||||
{isOpening ? "Opening..." : "Open Project"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="workspace" style={{ height: "100%" }}>
|
|
||||||
<Chat projectPath={projectPath} onCloseProject={closeProject} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{errorMsg && (
|
return (
|
||||||
<div className="error-message" style={{ marginTop: "20px" }}>
|
<main
|
||||||
<p style={{ color: "red" }}>Error: {errorMsg}</p>
|
className="container"
|
||||||
</div>
|
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
|
||||||
)}
|
>
|
||||||
</main>
|
{!projectPath ? (
|
||||||
);
|
<div
|
||||||
|
className="selection-screen"
|
||||||
|
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
|
||||||
|
>
|
||||||
|
<h1>AI Code Assistant</h1>
|
||||||
|
<p>Paste a project path to start the Story-Driven Spec Workflow.</p>
|
||||||
|
{knownProjects.length > 0 && (
|
||||||
|
<div style={{ marginTop: "12px" }}>
|
||||||
|
<div style={{ fontSize: "0.9em", color: "#666" }}>
|
||||||
|
Recent projects
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}>
|
||||||
|
{knownProjects.map((project) => (
|
||||||
|
<li key={project} style={{ marginBottom: "6px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openProject(project)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
background: "#f7f7f7",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pathInput}
|
||||||
|
placeholder="/path/to/project"
|
||||||
|
onChange={(event) => setPathInput(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
handleOpen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: "100%", padding: "10px", marginTop: "12px" }}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleOpen} disabled={isOpening}>
|
||||||
|
{isOpening ? "Opening..." : "Open Project"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="workspace" style={{ height: "100%" }}>
|
||||||
|
<Chat projectPath={projectPath} onCloseProject={closeProject} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="error-message" style={{ marginTop: "20px" }}>
|
||||||
|
<p style={{ color: "red" }}>Error: {errorMsg}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,226 +1,276 @@
|
|||||||
export type WsRequest =
|
export type WsRequest =
|
||||||
| {
|
| {
|
||||||
type: "chat";
|
type: "chat";
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
config: ProviderConfig;
|
config: ProviderConfig;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "cancel";
|
type: "cancel";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WsResponse =
|
export type WsResponse =
|
||||||
| { type: "token"; content: string }
|
| { type: "token"; content: string }
|
||||||
| { type: "update"; messages: Message[] }
|
| { type: "update"; messages: Message[] }
|
||||||
| { type: "error"; message: string };
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
base_url?: string;
|
base_url?: string;
|
||||||
enable_tools?: boolean;
|
enable_tools?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Role = "system" | "user" | "assistant" | "tool";
|
export type Role = "system" | "user" | "assistant" | "tool";
|
||||||
|
|
||||||
export interface ToolCall {
|
export interface ToolCall {
|
||||||
id?: string;
|
id?: string;
|
||||||
type: string;
|
type: string;
|
||||||
function: {
|
function: {
|
||||||
name: string;
|
name: string;
|
||||||
arguments: string;
|
arguments: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
role: Role;
|
role: Role;
|
||||||
content: string;
|
content: string;
|
||||||
tool_calls?: ToolCall[];
|
tool_calls?: ToolCall[];
|
||||||
tool_call_id?: string;
|
tool_call_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
name: string;
|
name: string;
|
||||||
kind: "file" | "dir";
|
kind: "file" | "dir";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
path: string;
|
path: string;
|
||||||
matches: number;
|
matches: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandOutput {
|
export interface CommandOutput {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
exit_code: number;
|
exit_code: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_API_BASE = "/api";
|
const DEFAULT_API_BASE = "/api";
|
||||||
const DEFAULT_WS_PATH = "/ws";
|
const DEFAULT_WS_PATH = "/ws";
|
||||||
|
|
||||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||||
return `${baseUrl}${path}`;
|
return `${baseUrl}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson<T>(
|
async function requestJson<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
baseUrl = DEFAULT_API_BASE,
|
baseUrl = DEFAULT_API_BASE,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetch(buildApiUrl(path, baseUrl), {
|
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(options.headers ?? {}),
|
...(options.headers ?? {}),
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(text || `Request failed (${res.status})`);
|
throw new Error(text || `Request failed (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getCurrentProject(baseUrl?: string) {
|
getCurrentProject(baseUrl?: string) {
|
||||||
return requestJson<string | null>("/project", {}, baseUrl);
|
return requestJson<string | null>("/project", {}, baseUrl);
|
||||||
},
|
},
|
||||||
openProject(path: string, baseUrl?: string) {
|
getKnownProjects(baseUrl?: string) {
|
||||||
return requestJson<string>(
|
return requestJson<string[]>("/projects", {}, baseUrl);
|
||||||
"/project",
|
},
|
||||||
{ method: "POST", body: JSON.stringify({ path }) },
|
openProject(path: string, baseUrl?: string) {
|
||||||
baseUrl,
|
return requestJson<string>(
|
||||||
);
|
"/project",
|
||||||
},
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
closeProject(baseUrl?: string) {
|
baseUrl,
|
||||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
);
|
||||||
},
|
},
|
||||||
getModelPreference(baseUrl?: string) {
|
closeProject(baseUrl?: string) {
|
||||||
return requestJson<string | null>("/model", {}, baseUrl);
|
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||||
},
|
},
|
||||||
setModelPreference(model: string, baseUrl?: string) {
|
getModelPreference(baseUrl?: string) {
|
||||||
return requestJson<boolean>(
|
return requestJson<string | null>("/model", {}, baseUrl);
|
||||||
"/model",
|
},
|
||||||
{ method: "POST", body: JSON.stringify({ model }) },
|
setModelPreference(model: string, baseUrl?: string) {
|
||||||
baseUrl,
|
return requestJson<boolean>(
|
||||||
);
|
"/model",
|
||||||
},
|
{ method: "POST", body: JSON.stringify({ model }) },
|
||||||
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
|
baseUrl,
|
||||||
const url = new URL(
|
);
|
||||||
buildApiUrl("/ollama/models", baseUrl),
|
},
|
||||||
window.location.origin,
|
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
|
||||||
);
|
const url = new URL(
|
||||||
if (baseUrlParam) {
|
buildApiUrl("/ollama/models", baseUrl),
|
||||||
url.searchParams.set("base_url", baseUrlParam);
|
window.location.origin,
|
||||||
}
|
);
|
||||||
return requestJson<string[]>(url.pathname + url.search, {}, "");
|
if (baseUrlParam) {
|
||||||
},
|
url.searchParams.set("base_url", baseUrlParam);
|
||||||
getAnthropicApiKeyExists(baseUrl?: string) {
|
}
|
||||||
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
return requestJson<string[]>(url.pathname + url.search, {}, "");
|
||||||
},
|
},
|
||||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
getAnthropicApiKeyExists(baseUrl?: string) {
|
||||||
return requestJson<boolean>(
|
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
||||||
"/anthropic/key",
|
},
|
||||||
{ method: "POST", body: JSON.stringify({ api_key }) },
|
getAnthropicModels(baseUrl?: string) {
|
||||||
baseUrl,
|
return requestJson<string[]>("/anthropic/models", {}, baseUrl);
|
||||||
);
|
},
|
||||||
},
|
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||||
readFile(path: string, baseUrl?: string) {
|
return requestJson<boolean>(
|
||||||
return requestJson<string>(
|
"/anthropic/key",
|
||||||
"/fs/read",
|
{ method: "POST", body: JSON.stringify({ api_key }) },
|
||||||
{ method: "POST", body: JSON.stringify({ path }) },
|
baseUrl,
|
||||||
baseUrl,
|
);
|
||||||
);
|
},
|
||||||
},
|
readFile(path: string, baseUrl?: string) {
|
||||||
writeFile(path: string, content: string, baseUrl?: string) {
|
return requestJson<string>(
|
||||||
return requestJson<boolean>(
|
"/fs/read",
|
||||||
"/fs/write",
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
{ method: "POST", body: JSON.stringify({ path, content }) },
|
baseUrl,
|
||||||
baseUrl,
|
);
|
||||||
);
|
},
|
||||||
},
|
writeFile(path: string, content: string, baseUrl?: string) {
|
||||||
listDirectory(path: string, baseUrl?: string) {
|
return requestJson<boolean>(
|
||||||
return requestJson<FileEntry[]>(
|
"/fs/write",
|
||||||
"/fs/list",
|
{ method: "POST", body: JSON.stringify({ path, content }) },
|
||||||
{ method: "POST", body: JSON.stringify({ path }) },
|
baseUrl,
|
||||||
baseUrl,
|
);
|
||||||
);
|
},
|
||||||
},
|
listDirectory(path: string, baseUrl?: string) {
|
||||||
searchFiles(query: string, baseUrl?: string) {
|
return requestJson<FileEntry[]>(
|
||||||
return requestJson<SearchResult[]>(
|
"/fs/list",
|
||||||
"/fs/search",
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
{ method: "POST", body: JSON.stringify({ query }) },
|
baseUrl,
|
||||||
baseUrl,
|
);
|
||||||
);
|
},
|
||||||
},
|
searchFiles(query: string, baseUrl?: string) {
|
||||||
execShell(command: string, args: string[], baseUrl?: string) {
|
return requestJson<SearchResult[]>(
|
||||||
return requestJson<CommandOutput>(
|
"/fs/search",
|
||||||
"/shell/exec",
|
{ method: "POST", body: JSON.stringify({ query }) },
|
||||||
{ method: "POST", body: JSON.stringify({ command, args }) },
|
baseUrl,
|
||||||
baseUrl,
|
);
|
||||||
);
|
},
|
||||||
},
|
execShell(command: string, args: string[], baseUrl?: string) {
|
||||||
cancelChat(baseUrl?: string) {
|
return requestJson<CommandOutput>(
|
||||||
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
"/shell/exec",
|
||||||
},
|
{ method: "POST", body: JSON.stringify({ command, args }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cancelChat(baseUrl?: string) {
|
||||||
|
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ChatWebSocket {
|
export class ChatWebSocket {
|
||||||
private socket?: WebSocket;
|
private static sharedSocket: WebSocket | null = null;
|
||||||
private onToken?: (content: string) => void;
|
private static refCount = 0;
|
||||||
private onUpdate?: (messages: Message[]) => void;
|
private socket?: WebSocket;
|
||||||
private onError?: (message: string) => void;
|
private onToken?: (content: string) => void;
|
||||||
|
private onUpdate?: (messages: Message[]) => void;
|
||||||
|
private onError?: (message: string) => void;
|
||||||
|
private connected = false;
|
||||||
|
private closeTimer?: number;
|
||||||
|
|
||||||
connect(
|
connect(
|
||||||
handlers: {
|
handlers: {
|
||||||
onToken?: (content: string) => void;
|
onToken?: (content: string) => void;
|
||||||
onUpdate?: (messages: Message[]) => void;
|
onUpdate?: (messages: Message[]) => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
},
|
},
|
||||||
wsPath = DEFAULT_WS_PATH,
|
wsPath = DEFAULT_WS_PATH,
|
||||||
) {
|
) {
|
||||||
this.onToken = handlers.onToken;
|
this.onToken = handlers.onToken;
|
||||||
this.onUpdate = handlers.onUpdate;
|
this.onUpdate = handlers.onUpdate;
|
||||||
this.onError = handlers.onError;
|
this.onError = handlers.onError;
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
if (this.connected) {
|
||||||
const wsUrl = `${protocol}://${window.location.host}${wsPath}`;
|
return;
|
||||||
this.socket = new WebSocket(wsUrl);
|
}
|
||||||
|
this.connected = true;
|
||||||
|
ChatWebSocket.refCount += 1;
|
||||||
|
|
||||||
this.socket.onmessage = (event) => {
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
try {
|
const wsHost = import.meta.env.DEV
|
||||||
const data = JSON.parse(event.data) as WsResponse;
|
? "127.0.0.1:3001"
|
||||||
if (data.type === "token") this.onToken?.(data.content);
|
: window.location.host;
|
||||||
if (data.type === "update") this.onUpdate?.(data.messages);
|
const wsUrl = `${protocol}://${wsHost}${wsPath}`;
|
||||||
if (data.type === "error") this.onError?.(data.message);
|
|
||||||
} catch (err) {
|
|
||||||
this.onError?.(String(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.socket.onerror = () => {
|
if (
|
||||||
this.onError?.("WebSocket error");
|
!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.socket.onmessage = (event) => {
|
||||||
this.send({ type: "chat", messages, config });
|
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.socket.onerror = () => {
|
||||||
this.send({ type: "cancel" });
|
this.onError?.("WebSocket error");
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
sendChat(messages: Message[], config: ProviderConfig) {
|
||||||
this.socket?.close();
|
this.send({ type: "chat", messages, config });
|
||||||
}
|
}
|
||||||
|
|
||||||
private send(payload: WsRequest) {
|
cancel() {
|
||||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
this.send({ type: "cancel" });
|
||||||
this.onError?.("WebSocket is not connected");
|
}
|
||||||
return;
|
|
||||||
}
|
close() {
|
||||||
this.socket.send(JSON.stringify(payload));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,18 +3,14 @@ import { defineConfig } from "vite";
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(() => ({
|
export default defineConfig(() => ({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:3001",
|
"/api": "http://127.0.0.1:3001",
|
||||||
"/ws": {
|
},
|
||||||
target: "ws://localhost:3001",
|
},
|
||||||
ws: true,
|
build: {
|
||||||
},
|
outDir: "dist",
|
||||||
},
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
build: {
|
|
||||||
outDir: "dist",
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,9 +1,42 @@
|
|||||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||||
use crate::llm::chat;
|
use crate::llm::chat;
|
||||||
|
use crate::store::StoreOps;
|
||||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
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<AnthropicModelInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnthropicModelInfo {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_anthropic_api_key(ctx: &AppContext) -> Result<String, String> {
|
||||||
|
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)]
|
#[derive(Deserialize, Object)]
|
||||||
struct ApiKeyPayload {
|
struct ApiKeyPayload {
|
||||||
api_key: String,
|
api_key: String,
|
||||||
@@ -48,4 +81,46 @@ impl AnthropicApi {
|
|||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
Ok(Json(true))
|
Ok(Json(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List available Anthropic models.
|
||||||
|
#[oai(path = "/anthropic/models", method = "get")]
|
||||||
|
async fn list_anthropic_models(&self) -> OpenApiResult<Json<Vec<String>>> {
|
||||||
|
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::<AnthropicModelsResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
|
let models = body.data.into_iter().map(|m| m.id).collect();
|
||||||
|
|
||||||
|
Ok(Json(models))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,11 @@ impl ProjectApi {
|
|||||||
fs::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(bad_request)?;
|
fs::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(bad_request)?;
|
||||||
Ok(Json(true))
|
Ok(Json(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List known projects from the store.
|
||||||
|
#[oai(path = "/projects", method = "get")]
|
||||||
|
async fn list_known_projects(&self) -> OpenApiResult<Json<Vec<String>>> {
|
||||||
|
let projects = fs::get_known_projects(self.ctx.store.as_ref()).map_err(bad_request)?;
|
||||||
|
Ok(Json(projects))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use poem::handler;
|
|||||||
use poem::web::Data;
|
use poem::web::Data;
|
||||||
use poem::web::websocket::{Message as WsMessage, WebSocket};
|
use poem::web::websocket::{Message as WsMessage, WebSocket};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -39,7 +40,7 @@ enum WsResponse {
|
|||||||
/// WebSocket endpoint for streaming chat responses and cancellation.
|
/// WebSocket endpoint for streaming chat responses and cancellation.
|
||||||
///
|
///
|
||||||
/// Accepts JSON `WsRequest` messages and streams `WsResponse` messages.
|
/// 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<AppContext>>) -> impl poem::IntoResponse {
|
||||||
let ctx = ctx.0.clone();
|
let ctx = ctx.0.clone();
|
||||||
ws.on_upgrade(move |socket| async move {
|
ws.on_upgrade(move |socket| async move {
|
||||||
let (mut sink, mut stream) = socket.split();
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
const KEY_LAST_PROJECT: &str = "last_project_path";
|
const KEY_LAST_PROJECT: &str = "last_project_path";
|
||||||
const KEY_SELECTED_MODEL: &str = "selected_model";
|
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).
|
/// Resolves a relative path against the active project root (pure function for testing).
|
||||||
/// Returns error if path attempts traversal (..).
|
/// Returns error if path attempts traversal (..).
|
||||||
@@ -55,6 +56,13 @@ pub async fn open_project(
|
|||||||
}
|
}
|
||||||
|
|
||||||
store.set(KEY_LAST_PROJECT, json!(path));
|
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()?;
|
store.save()?;
|
||||||
|
|
||||||
Ok(path)
|
Ok(path)
|
||||||
@@ -99,6 +107,18 @@ pub fn get_current_project(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, 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<Option<String>, String> {
|
pub fn get_model_preference(store: &dyn StoreOps) -> Result<Option<String>, String> {
|
||||||
if let Some(model) = store
|
if let Some(model) = store
|
||||||
.get(KEY_SELECTED_MODEL)
|
.get(KEY_SELECTED_MODEL)
|
||||||
|
|||||||
Reference in New Issue
Block a user