Put in a recent project picker

This commit is contained in:
Dave
2026-02-16 18:57:39 +00:00
parent 539cbba409
commit ffab287d16
9 changed files with 1334 additions and 1116 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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>,
); );

View File

@@ -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,
},
})); }));

View File

@@ -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))
}
} }

View File

@@ -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))
}
} }

View File

@@ -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();

View File

@@ -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)