Put in a recent project picker
This commit is contained in:
@@ -8,6 +8,14 @@ function App() {
|
||||
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
||||
const [pathInput, setPathInput] = React.useState("");
|
||||
const [isOpening, setIsOpening] = React.useState(false);
|
||||
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
api
|
||||
.getKnownProjects()
|
||||
.then((projects) => setKnownProjects(projects))
|
||||
.catch((error) => console.error(error));
|
||||
}, []);
|
||||
|
||||
async function openProject(path: string) {
|
||||
if (!path.trim()) {
|
||||
@@ -59,6 +67,36 @@ function App() {
|
||||
>
|
||||
<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}
|
||||
|
||||
@@ -86,6 +86,9 @@ export const api = {
|
||||
getCurrentProject(baseUrl?: string) {
|
||||
return requestJson<string | null>("/project", {}, baseUrl);
|
||||
},
|
||||
getKnownProjects(baseUrl?: string) {
|
||||
return requestJson<string[]>("/projects", {}, baseUrl);
|
||||
},
|
||||
openProject(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/project",
|
||||
@@ -119,6 +122,9 @@ export const api = {
|
||||
getAnthropicApiKeyExists(baseUrl?: string) {
|
||||
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
||||
},
|
||||
getAnthropicModels(baseUrl?: string) {
|
||||
return requestJson<string[]>("/anthropic/models", {}, baseUrl);
|
||||
},
|
||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/anthropic/key",
|
||||
@@ -167,10 +173,14 @@ export const api = {
|
||||
};
|
||||
|
||||
export class ChatWebSocket {
|
||||
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: {
|
||||
@@ -184,9 +194,26 @@ export class ChatWebSocket {
|
||||
this.onUpdate = handlers.onUpdate;
|
||||
this.onError = handlers.onError;
|
||||
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
this.connected = true;
|
||||
ChatWebSocket.refCount += 1;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const wsUrl = `${protocol}://${window.location.host}${wsPath}`;
|
||||
this.socket = new WebSocket(wsUrl);
|
||||
const wsHost = import.meta.env.DEV
|
||||
? "127.0.0.1:3001"
|
||||
: window.location.host;
|
||||
const wsUrl = `${protocol}://${wsHost}${wsPath}`;
|
||||
|
||||
if (
|
||||
!ChatWebSocket.sharedSocket ||
|
||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED ||
|
||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket;
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
@@ -213,7 +240,30 @@ export class ChatWebSocket {
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket?.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) {
|
||||
|
||||
@@ -19,13 +19,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
const [model, setModel] = useState("llama3.1");
|
||||
const [enableTools, setEnableTools] = useState(true);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [claudeModels] = useState<string[]>([
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-20241022",
|
||||
]);
|
||||
const [claudeModels, setClaudeModels] = useState<string[]>([]);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
|
||||
|
||||
const wsRef = useRef<ChatWebSocket | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -107,6 +105,33 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
|
||||
api
|
||||
.getAnthropicApiKeyExists()
|
||||
.then((exists) => {
|
||||
setHasAnthropicKey(exists);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setHasAnthropicKey(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([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -435,13 +460,19 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
backgroundSize: "10px",
|
||||
}}
|
||||
>
|
||||
{claudeModels.length > 0 && (
|
||||
{(claudeModels.length > 0 || !hasAnthropicKey) && (
|
||||
<optgroup label="Anthropic">
|
||||
{claudeModels.map((m: string) => (
|
||||
{claudeModels.length > 0 ? (
|
||||
claudeModels.map((m: string) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<option value="" disabled>
|
||||
Add Anthropic API key to load models
|
||||
</option>
|
||||
)}
|
||||
</optgroup>
|
||||
)}
|
||||
{availableModels.length > 0 && (
|
||||
|
||||
@@ -6,11 +6,7 @@ export default defineConfig(() => ({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:3001",
|
||||
"/ws": {
|
||||
target: "ws://localhost:3001",
|
||||
ws: true,
|
||||
},
|
||||
"/api": "http://127.0.0.1:3001",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -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<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)]
|
||||
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<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)?;
|
||||
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::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<AppContext>>) -> impl poem::IntoResponse {
|
||||
let ctx = ctx.0.clone();
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
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_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<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> {
|
||||
if let Some(model) = store
|
||||
.get(KEY_SELECTED_MODEL)
|
||||
|
||||
Reference in New Issue
Block a user