| @@ -10,7 +10,7 @@ repository = "https://ikibani.com/kashiro/rust-template" | |||
| [workspace.dependencies] | |||
| anyhow = "1" | |||
| axum = { version = "0.8", features = ["json", "macros", "tokio"] } | |||
| axum = { version = "0.8", features = ["json", "macros", "tokio", "ws"] } | |||
| chrono = { version = "0.4", features = ["serde"] } | |||
| dotenvy = "0.15" | |||
| figment = { version = "0.10", features = ["toml", "env"] } | |||
| @@ -21,6 +21,7 @@ chrono.workspace = true | |||
| dotenvy.workspace = true | |||
| figment.workspace = true | |||
| http.workspace = true | |||
| reqwest = { version = "0.13.4", features = ["json"] } | |||
| serde.workspace = true | |||
| serde_json.workspace = true | |||
| sqlx.workspace = true | |||
| @@ -35,6 +36,7 @@ utoipa-axum.workspace = true | |||
| utoipa-swagger-ui.workspace = true | |||
| uuid.workspace = true | |||
| validator.workspace = true | |||
| log = "0.4.33" | |||
| [dev-dependencies] | |||
| http-body-util = "0.1" | |||
| @@ -0,0 +1,12 @@ | |||
| CREATE TABLE IF NOT EXISTS queries ( | |||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |||
| title TEXT NOT NULL, | |||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |||
| ); | |||
| CREATE TABLE IF NOT EXISTS query_messages ( | |||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |||
| query_id UUID REFERENCES queries(id), | |||
| message TEXT NOT NULL, | |||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |||
| ); | |||
| @@ -74,7 +74,7 @@ impl Default for AppConfig { | |||
| fn default() -> Self { | |||
| Self { | |||
| app: AppSection { | |||
| name: "rust-template".into(), | |||
| name: "Lyra".into(), | |||
| description: "Axum + Vue + PostgreSQL fullstack app".into(), | |||
| }, | |||
| server: ServerSection { | |||
| @@ -35,6 +35,12 @@ pub enum ApiError { | |||
| Internal(#[from] anyhow::Error), | |||
| } | |||
| impl From<reqwest::Error> for ApiError { | |||
| fn from(err: reqwest::Error) -> Self { | |||
| ApiError::Internal(anyhow::anyhow!(err)) | |||
| } | |||
| } | |||
| impl ApiError { | |||
| pub fn status(&self) -> StatusCode { | |||
| match self { | |||
| @@ -0,0 +1,198 @@ | |||
| use crate::models::llm::{LlmQuery, LlmRequest, LlmWsEvent, SavedQueriesResponse}; | |||
| use crate::AppState; | |||
| use axum::extract::State; | |||
| use axum::{extract::{ | |||
| ws::{Message, WebSocket}, | |||
| WebSocketUpgrade, | |||
| }, response::IntoResponse}; | |||
| use sqlx::Row; | |||
| use uuid::Uuid; | |||
| use crate::error::ApiError; | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/llm/ws", | |||
| tag = "llm", | |||
| description = "WebSocket upgrade. After 101, send LlmRequest as one text frame. Server sends LlmWsEvent frames.", | |||
| responses((status = 101, description = "Switching Protocols")) | |||
| )] | |||
| pub async fn send( | |||
| ws: WebSocketUpgrade, | |||
| State(state): State<AppState>, | |||
| ) -> impl IntoResponse { | |||
| ws.on_upgrade(move |socket| handle_send(socket, state)) | |||
| } | |||
| async fn handle_send(mut socket: WebSocket, state: AppState) { | |||
| let mut full_body = String::new(); | |||
| let Some(Ok(Message::Text(text))) = socket.recv().await else { | |||
| return; | |||
| }; | |||
| let mut body: LlmRequest = match serde_json::from_str(&text) { | |||
| Ok(v) => v, | |||
| Err(e) => { | |||
| let _ = send_event( | |||
| &mut socket, | |||
| &LlmWsEvent::Error { | |||
| message: e.to_string(), | |||
| }, | |||
| ) | |||
| .await; | |||
| return; | |||
| } | |||
| }; | |||
| body.stream = Some(true); | |||
| let mut resp = match state | |||
| .0 | |||
| .client | |||
| .post("http://127.0.0.1:11434/api/chat") | |||
| .json(&body) | |||
| .send() | |||
| .await | |||
| { | |||
| Ok(r) => r, | |||
| Err(e) => { | |||
| let _ = send_event( | |||
| &mut socket, | |||
| &LlmWsEvent::Error { | |||
| message: e.to_string(), | |||
| }, | |||
| ) | |||
| .await; | |||
| return; | |||
| } | |||
| }; | |||
| if !resp.status().is_success() { | |||
| let status = resp.status(); | |||
| let text = resp.text().await.unwrap_or_default(); | |||
| let _ = send_event( | |||
| &mut socket, | |||
| &LlmWsEvent::Error { | |||
| message: format!("{status}: {text}"), | |||
| }, | |||
| ) | |||
| .await; | |||
| return; | |||
| } | |||
| let mut buf = String::new(); | |||
| loop { | |||
| let chunk = match resp.chunk().await { | |||
| Ok(Some(bytes)) => bytes, | |||
| Ok(None) => break, | |||
| Err(e) => { | |||
| let _ = send_event( | |||
| &mut socket, | |||
| &LlmWsEvent::Error { | |||
| message: e.to_string(), | |||
| }, | |||
| ) | |||
| .await; | |||
| return; | |||
| } | |||
| }; | |||
| buf.push_str(&String::from_utf8_lossy(&chunk)); | |||
| while let Some(i) = buf.find('\n') { | |||
| let line = buf[..i].trim().to_string(); | |||
| buf.drain(..=i); | |||
| if line.is_empty() { | |||
| continue; | |||
| } | |||
| let event: serde_json::Value = match serde_json::from_str(&line) { | |||
| Ok(v) => v, | |||
| Err(_) => continue, | |||
| }; | |||
| if let Some(err) = event.get("error").and_then(|v| v.as_str()) { | |||
| let _ = send_event( | |||
| &mut socket, | |||
| &LlmWsEvent::Error { | |||
| message: err.to_string(), | |||
| }, | |||
| ) | |||
| .await; | |||
| return; | |||
| } | |||
| if let Some(piece) = event | |||
| .pointer("/message/content") | |||
| .and_then(|v| v.as_str()) | |||
| .filter(|s| !s.is_empty()) | |||
| { | |||
| full_body.push_str(piece); | |||
| if send_event( | |||
| &mut socket, | |||
| &LlmWsEvent::Delta { | |||
| content: piece.to_string(), | |||
| }, | |||
| ) | |||
| .await | |||
| .is_err() | |||
| { | |||
| return; | |||
| } | |||
| } | |||
| if event.get("done").and_then(|v| v.as_bool()) == Some(true) { | |||
| let reason = event | |||
| .get("done_reason") | |||
| .and_then(|v| v.as_str()) | |||
| .unwrap_or("stop") | |||
| .to_string(); | |||
| let title = body | |||
| .messages | |||
| .last() | |||
| .map(|m| m.content.as_str()) | |||
| .unwrap_or("New Query"); | |||
| if let Err(err) = save_query(&state, title, &full_body).await { | |||
| let _ = send_event( | |||
| &mut socket, | |||
| &LlmWsEvent::Error { message: err.to_string() }, | |||
| ) | |||
| .await; | |||
| return; | |||
| } | |||
| let _ = send_event(&mut socket, &LlmWsEvent::Done { reason }).await; | |||
| return; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| async fn send_event( | |||
| socket: &mut WebSocket, | |||
| event: &LlmWsEvent, | |||
| ) -> Result<(), axum::Error> { | |||
| socket | |||
| .send(Message::text(serde_json::to_string(event).unwrap())) | |||
| .await | |||
| } | |||
| async fn save_query(state: &AppState, title: &str, body: &str) -> Result<Uuid, ApiError> { | |||
| let result = sqlx::query("insert into queries (title) values ($1) returning id") | |||
| .bind(title) | |||
| .fetch_one(&state.db) | |||
| .await | |||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to save query")))?; | |||
| let query_id: Uuid = result.get("id"); | |||
| sqlx::query("insert into query_messages (query_id, message) values ($1, $2)") | |||
| .bind(query_id) | |||
| .bind(body) | |||
| .execute(&state.db) | |||
| .await | |||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to save query")))?; | |||
| Ok(query_id) | |||
| } | |||
| @@ -1,2 +1,4 @@ | |||
| pub mod health; | |||
| pub mod items; | |||
| pub mod llm; | |||
| pub mod queries; | |||
| @@ -0,0 +1,61 @@ | |||
| use axum::extract::{Path, Query, State}; | |||
| use axum::Json; | |||
| use uuid::Uuid; | |||
| use crate::AppState; | |||
| use crate::error::{ApiError, ApiResult}; | |||
| use crate::models::llm::{LlmQuery, LlmQueryDetail, SavedQueriesRequest, SavedQueriesResponse}; | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/queries/list", | |||
| tag = "queries", | |||
| description = "Get all saved queries", | |||
| params( | |||
| ("limit" = Option<i64>, Query, description = "Maximum number of queries to return") | |||
| ), | |||
| responses( | |||
| (status = 200, description = "Fetched LLM queries", body = SavedQueriesResponse), | |||
| (status = 400, description = "Validation error", body = crate::error::ErrorBody) | |||
| ) | |||
| )] | |||
| pub async fn get_queries(State(state): State<AppState>, Query(params): Query<SavedQueriesRequest>) -> ApiResult<Json<SavedQueriesResponse>> { | |||
| let limit = params.limit.unwrap_or(50); | |||
| let result = sqlx::query_as::<_, LlmQuery>("select id, title from queries order by id desc limit $1") | |||
| .bind(limit) | |||
| .fetch_all(&state.db) | |||
| .await | |||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to get queries")))?; | |||
| Ok(Json(SavedQueriesResponse { queries: result })) | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/queries/{id}", | |||
| tag = "queries", | |||
| description = "Get a single saved query by ID", | |||
| params(("id" = Uuid,)), | |||
| responses( | |||
| (status = 200, description = "Fetched LLM query", body = LlmQueryDetail), | |||
| (status = 404, description = "Query not found", body = crate::error::ErrorBody), | |||
| (status = 400, description = "Validation error", body = crate::error::ErrorBody) | |||
| ) | |||
| )] | |||
| pub async fn get_query(State(state): State<AppState>, Path(id): Path<Uuid>) -> ApiResult<Json<LlmQueryDetail>> { | |||
| let query = sqlx::query!("select id, title from queries where id = $1", id) | |||
| .fetch_optional(&state.db) | |||
| .await | |||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to fetch query")))? | |||
| .ok_or_else(|| ApiError::NotFound(format!("Query {} not found", id)))?; | |||
| let messages = sqlx::query!("select message from query_messages where query_id = $1 order by id asc", id) | |||
| .fetch_all(&state.db) | |||
| .await | |||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to fetch query messages")))?; | |||
| Ok(Json(LlmQueryDetail { | |||
| id: query.id, | |||
| title: query.title, | |||
| messages: messages.into_iter().map(|m| m.message).collect(), | |||
| })) | |||
| } | |||
| @@ -29,11 +29,18 @@ async fn main() -> anyhow::Result<()> { | |||
| tracing::info!("migrations applied"); | |||
| } | |||
| // 4. Router | |||
| // 4. HTTP client | |||
| let client = reqwest::Client::builder() | |||
| .timeout(std::time::Duration::from_secs(480)) | |||
| .build()?; | |||
| let state = AppState::new(config.clone(), pool, client); | |||
| // 5. Router | |||
| let addr = config.socket_addr()?; | |||
| let app = routes::router(AppState::new(config, pool)); | |||
| let app = routes::router(state); | |||
| // 5. Serve | |||
| // 6. Serve | |||
| let listener = tokio::net::TcpListener::bind(addr).await?; | |||
| tracing::info!(%addr, "listening"); | |||
| axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).await?; | |||
| @@ -0,0 +1,107 @@ | |||
| use serde::{Deserialize, Serialize}; | |||
| use sqlx::FromRow; | |||
| use uuid::Uuid; | |||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct LlmResponse { | |||
| pub model: String, | |||
| #[serde(rename = "created_at")] | |||
| pub created_at: String, | |||
| pub message: ResponseMessage, | |||
| pub done: bool, | |||
| #[serde(rename = "done_reason")] | |||
| pub done_reason: String, | |||
| #[serde(rename = "total_duration")] | |||
| pub total_duration: i64, | |||
| #[serde(rename = "load_duration")] | |||
| pub load_duration: i64, | |||
| #[serde(rename = "prompt_eval_count")] | |||
| pub prompt_eval_count: i64, | |||
| #[serde(rename = "prompt_eval_duration")] | |||
| pub prompt_eval_duration: i64, | |||
| #[serde(rename = "eval_count")] | |||
| pub eval_count: i64, | |||
| #[serde(rename = "eval_duration")] | |||
| pub eval_duration: i64, | |||
| } | |||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ResponseMessage { | |||
| pub role: String, | |||
| pub content: String, | |||
| pub thinking: String, | |||
| } | |||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct LlmRequest { | |||
| pub model: String, | |||
| pub messages: Vec<RequestMessage>, | |||
| pub stream: Option<bool>, | |||
| pub options: Options, | |||
| } | |||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct RequestMessage { | |||
| pub role: String, | |||
| pub content: String, | |||
| } | |||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct Options { | |||
| pub temperature: f64, | |||
| #[serde(rename = "num_predict")] | |||
| pub num_predict: Option<i64>, | |||
| } | |||
| #[derive(serde::Serialize, utoipa::ToSchema)] | |||
| #[serde(tag = "type", rename_all = "snake_case")] | |||
| pub enum LlmWsEvent { | |||
| Delta { content: String }, | |||
| Done { reason: String }, | |||
| Error { message: String }, | |||
| } | |||
| #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(tag = "type", rename_all = "snake_case")] | |||
| pub struct SaveQueryRequest { | |||
| pub title: String, | |||
| pub body: String, | |||
| } | |||
| #[derive(Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(tag = "type", rename_all = "snake_case")] | |||
| pub struct SaveQueryResponse { | |||
| pub id: Uuid, | |||
| } | |||
| #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(tag = "type", rename_all = "snake_case")] | |||
| pub struct SavedQueriesRequest { | |||
| pub limit: Option<i64>, | |||
| } | |||
| #[derive(Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(tag = "type", rename_all = "snake_case")] | |||
| pub struct SavedQueriesResponse { | |||
| pub queries: Vec<LlmQuery>, | |||
| } | |||
| #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema, FromRow)] | |||
| #[serde(tag = "type", rename_all = "snake_case")] | |||
| pub struct LlmQuery { | |||
| pub id: Uuid, | |||
| pub title: String, | |||
| } | |||
| #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] | |||
| #[serde(tag = "type", rename_all = "snake_case")] | |||
| pub struct LlmQueryDetail { | |||
| pub id: Uuid, | |||
| pub title: String, | |||
| pub messages: Vec<String>, | |||
| } | |||
| @@ -1,3 +1,4 @@ | |||
| pub mod health; | |||
| pub mod item; | |||
| pub mod pagination; | |||
| pub mod llm; | |||
| @@ -1,6 +1,8 @@ | |||
| use crate::routes::llm::__path_send; | |||
| use axum::Router; | |||
| use axum::http::{HeaderValue, Method, header}; | |||
| use axum::middleware; | |||
| use axum::routing::get; | |||
| use tower::ServiceBuilder; | |||
| use tower_http::compression::CompressionLayer; | |||
| use tower_http::cors::{AllowOrigin, CorsLayer}; | |||
| @@ -12,7 +14,8 @@ use utoipa_axum::routes; | |||
| use utoipa_swagger_ui::SwaggerUi; | |||
| use crate::config::AppConfig; | |||
| use crate::handlers::{health, items}; | |||
| use crate::handlers::{health, items, llm, queries}; | |||
| use crate::handlers::llm::send; | |||
| use crate::middleware::request_log::{REQUEST_ID_HEADER, request_logging}; | |||
| use crate::state::AppState; | |||
| @@ -47,6 +50,9 @@ pub fn openapi_router() -> OpenApiRouter<AppState> { | |||
| .routes(routes!(items::create_item)) | |||
| .routes(routes!(items::update_item)) | |||
| .routes(routes!(items::delete_item)) | |||
| .routes(routes!(send)) | |||
| .routes(routes!(queries::get_queries)) | |||
| .routes(routes!(queries::get_query)) | |||
| } | |||
| pub fn openapi_spec() -> utoipa::openapi::OpenApi { | |||
| @@ -6,16 +6,17 @@ use sqlx::PgPool; | |||
| use crate::config::AppConfig; | |||
| #[derive(Clone)] | |||
| pub struct AppState(Arc<InnerState>); | |||
| pub struct AppState(pub(crate) Arc<InnerState>); | |||
| pub struct InnerState { | |||
| pub config: AppConfig, | |||
| pub db: PgPool, | |||
| pub client: reqwest::Client, | |||
| } | |||
| impl AppState { | |||
| pub fn new(config: AppConfig, db: PgPool) -> Self { | |||
| Self(Arc::new(InnerState { config, db })) | |||
| pub fn new(config: AppConfig, db: PgPool, client: reqwest::Client) -> Self { | |||
| Self(Arc::new(InnerState { config, db, client })) | |||
| } | |||
| pub fn db(&self) -> &PgPool { | |||
| @@ -8,10 +8,15 @@ | |||
| "name": "web", | |||
| "version": "0.1.0", | |||
| "dependencies": { | |||
| "vue": "^3.5.18" | |||
| "dompurify": "^3.0.1", | |||
| "marked": "^4.3.0", | |||
| "pinia": "^2.1.5", | |||
| "vue": "^3.5.18", | |||
| "vue-router": "^4.2.2" | |||
| }, | |||
| "devDependencies": { | |||
| "@tailwindcss/vite": "^4.1.11", | |||
| "@types/marked": "^4.3.0", | |||
| "@types/node": "^22.17.0", | |||
| "@vitejs/plugin-vue": "^6.0.1", | |||
| "smol-toml": "^1.4.2", | |||
| @@ -1211,6 +1216,13 @@ | |||
| "dev": true, | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/@types/marked": { | |||
| "version": "4.3.2", | |||
| "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz", | |||
| "integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==", | |||
| "dev": true, | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/@types/node": { | |||
| "version": "22.20.1", | |||
| "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", | |||
| @@ -1221,6 +1233,13 @@ | |||
| "undici-types": "~6.21.0" | |||
| } | |||
| }, | |||
| "node_modules/@types/trusted-types": { | |||
| "version": "2.0.7", | |||
| "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", | |||
| "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", | |||
| "license": "MIT", | |||
| "optional": true | |||
| }, | |||
| "node_modules/@vitejs/plugin-vue": { | |||
| "version": "6.0.8", | |||
| "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", | |||
| @@ -1317,6 +1336,12 @@ | |||
| "@vue/shared": "3.5.41" | |||
| } | |||
| }, | |||
| "node_modules/@vue/devtools-api": { | |||
| "version": "6.6.4", | |||
| "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", | |||
| "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/@vue/language-core": { | |||
| "version": "3.3.10", | |||
| "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.10.tgz", | |||
| @@ -1404,6 +1429,15 @@ | |||
| "node": ">=8" | |||
| } | |||
| }, | |||
| "node_modules/dompurify": { | |||
| "version": "3.4.13", | |||
| "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", | |||
| "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", | |||
| "license": "(MPL-2.0 OR Apache-2.0)", | |||
| "optionalDependencies": { | |||
| "@types/trusted-types": "^2.0.7" | |||
| } | |||
| }, | |||
| "node_modules/enhanced-resolve": { | |||
| "version": "5.24.5", | |||
| "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", | |||
| @@ -1798,6 +1832,18 @@ | |||
| "@jridgewell/sourcemap-codec": "^1.5.5" | |||
| } | |||
| }, | |||
| "node_modules/marked": { | |||
| "version": "4.3.0", | |||
| "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", | |||
| "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", | |||
| "license": "MIT", | |||
| "bin": { | |||
| "marked": "bin/marked.js" | |||
| }, | |||
| "engines": { | |||
| "node": ">= 12" | |||
| } | |||
| }, | |||
| "node_modules/muggle-string": { | |||
| "version": "0.4.1", | |||
| "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", | |||
| @@ -1849,6 +1895,28 @@ | |||
| "url": "https://github.com/sponsors/jonschlinkert" | |||
| } | |||
| }, | |||
| "node_modules/pinia": { | |||
| "version": "2.3.1", | |||
| "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", | |||
| "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@vue/devtools-api": "^6.6.3", | |||
| "vue-demi": "^0.14.10" | |||
| }, | |||
| "funding": { | |||
| "url": "https://github.com/sponsors/posva" | |||
| }, | |||
| "peerDependencies": { | |||
| "typescript": ">=4.4.4", | |||
| "vue": "^2.7.0 || ^3.5.11" | |||
| }, | |||
| "peerDependenciesMeta": { | |||
| "typescript": { | |||
| "optional": true | |||
| } | |||
| } | |||
| }, | |||
| "node_modules/postcss": { | |||
| "version": "8.5.26", | |||
| "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", | |||
| @@ -2107,6 +2175,47 @@ | |||
| } | |||
| } | |||
| }, | |||
| "node_modules/vue-demi": { | |||
| "version": "0.14.10", | |||
| "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", | |||
| "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", | |||
| "hasInstallScript": true, | |||
| "license": "MIT", | |||
| "bin": { | |||
| "vue-demi-fix": "bin/vue-demi-fix.js", | |||
| "vue-demi-switch": "bin/vue-demi-switch.js" | |||
| }, | |||
| "engines": { | |||
| "node": ">=12" | |||
| }, | |||
| "funding": { | |||
| "url": "https://github.com/sponsors/antfu" | |||
| }, | |||
| "peerDependencies": { | |||
| "@vue/composition-api": "^1.0.0-rc.1", | |||
| "vue": "^3.0.0-0 || ^2.6.0" | |||
| }, | |||
| "peerDependenciesMeta": { | |||
| "@vue/composition-api": { | |||
| "optional": true | |||
| } | |||
| } | |||
| }, | |||
| "node_modules/vue-router": { | |||
| "version": "4.6.4", | |||
| "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", | |||
| "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@vue/devtools-api": "^6.6.4" | |||
| }, | |||
| "funding": { | |||
| "url": "https://github.com/sponsors/posva" | |||
| }, | |||
| "peerDependencies": { | |||
| "vue": "^3.5.0" | |||
| } | |||
| }, | |||
| "node_modules/vue-tsc": { | |||
| "version": "3.3.10", | |||
| "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.10.tgz", | |||
| @@ -11,10 +11,15 @@ | |||
| "generate:api": "node ../../scripts/generate-api.mjs" | |||
| }, | |||
| "dependencies": { | |||
| "vue": "^3.5.18" | |||
| "vue": "^3.5.18", | |||
| "vue-router": "^4.2.2", | |||
| "pinia": "^2.1.5", | |||
| "marked": "^4.3.0", | |||
| "dompurify": "^3.0.1" | |||
| }, | |||
| "devDependencies": { | |||
| "@tailwindcss/vite": "^4.1.11", | |||
| "@types/marked": "^4.3.0", | |||
| "@types/node": "^22.17.0", | |||
| "@vitejs/plugin-vue": "^6.0.1", | |||
| "smol-toml": "^1.4.2", | |||
| @@ -1,30 +1,31 @@ | |||
| <script setup lang="ts"> | |||
| import HealthBadge from "@/components/HealthBadge.vue"; | |||
| import ItemBoard from "@/components/ItemBoard.vue"; | |||
| import QueryList from "@/components/QueryList.vue"; | |||
| </script> | |||
| <template> | |||
| <div class="mx-auto flex min-h-screen max-w-5xl flex-col px-6 py-10"> | |||
| <header class="flex flex-wrap items-end justify-between gap-4 border-b border-line pb-8"> | |||
| <div> | |||
| <p class="text-xs uppercase tracking-[0.22em] text-accent">Axum · Vue · sqlx</p> | |||
| <h1 class="mt-2 text-3xl font-semibold tracking-tight">rust-template</h1> | |||
| <p class="mt-2 max-w-xl text-sm leading-6 text-muted"> | |||
| Backend routes annotated with utoipa become OpenAPI, then a typed Vue | |||
| client. Vite reads the same TOML the server loads and reverse-proxies | |||
| <code>/api</code> and <code>/health</code> to 127.0.0.1. | |||
| </p> | |||
| <div class="flex min-h-screen"> | |||
| <aside class="w-64 border-r border-line p-6 overflow-y-auto"> | |||
| <QueryList /> | |||
| </aside> | |||
| <div class="flex w-full flex-col px-6 py-10"> | |||
| <div class="mx-auto flex w-full max-w-5xl flex-1 flex-col"> | |||
| <header class="flex flex-wrap items-end justify-between gap-4 border-b border-line pb-8"> | |||
| <h1 class="text-3xl font-bold"><RouterLink to="/">Lyra</RouterLink></h1> | |||
| </header> | |||
| <RouterView /> | |||
| <footer class="flex flex-row justify-between border-t border-line pt-6 text-xs text-muted mt-auto"> | |||
| <span> | |||
| Docs live at | |||
| <a class="text-accent hover:underline" href="/api/docs" target="_blank" rel="noreferrer"> | |||
| /api/docs | |||
| </a> | |||
| </span> | |||
| <span> | |||
| <HealthBadge /> | |||
| </span> | |||
| </footer> | |||
| </div> | |||
| <HealthBadge /> | |||
| </header> | |||
| <main class="flex-1 py-8"> | |||
| <ItemBoard /> | |||
| </main> | |||
| <footer class="border-t border-line pt-6 text-xs text-muted"> | |||
| Docs live at | |||
| <a class="text-accent hover:underline" href="/api/docs" target="_blank" rel="noreferrer"> | |||
| /api/docs | |||
| </a> | |||
| </footer> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| @@ -38,11 +38,26 @@ export type ItemResponse = { | |||
| updated_at: string; | |||
| }; | |||
| export type LlmQuery = { | |||
| id: string; | |||
| title: string; | |||
| }; | |||
| export type LlmQueryDetail = { | |||
| id: string; | |||
| messages: Array<string>; | |||
| title: string; | |||
| }; | |||
| export type ReadyResponse = { | |||
| database: string; | |||
| status: string; | |||
| }; | |||
| export type SavedQueriesResponse = { | |||
| queries: Array<LlmQuery>; | |||
| }; | |||
| export type UpdateItemRequest = { | |||
| description?: string | null; | |||
| name?: string | null; | |||
| @@ -111,6 +126,39 @@ export function updateItem(args: { | |||
| }); | |||
| } | |||
| export function send(): Promise<void> { | |||
| return request<void>({ | |||
| method: "GET", | |||
| path: "/api/v1/llm/ws", | |||
| expectedStatus: 200, | |||
| }); | |||
| } | |||
| export function getQueries(args: { | |||
| query?: { | |||
| limit?: number; | |||
| } | |||
| }): Promise<SavedQueriesResponse> { | |||
| return request<SavedQueriesResponse>({ | |||
| method: "GET", | |||
| path: "/api/v1/queries/list", | |||
| query: args.query, | |||
| expectedStatus: 200, | |||
| }); | |||
| } | |||
| export function getQuery(args: { | |||
| path: { | |||
| id: string; | |||
| } | |||
| }): Promise<LlmQueryDetail> { | |||
| return request<LlmQueryDetail>({ | |||
| method: "GET", | |||
| path: "/api/v1/queries/" + encodeURIComponent(String(args.path.id)), | |||
| expectedStatus: 200, | |||
| }); | |||
| } | |||
| export function live(): Promise<HealthResponse> { | |||
| return request<HealthResponse>({ | |||
| method: "GET", | |||
| @@ -0,0 +1,84 @@ | |||
| <script setup lang="ts"> | |||
| import { onMounted, onUnmounted, ref } from 'vue' | |||
| const query = defineModel<string>({ default: '' }) | |||
| let emit = defineEmits(['onChange', 'submit']); | |||
| const inputRef = ref<HTMLInputElement | null>(null) | |||
| const triggerChange = () => { | |||
| emit('onChange', query.value) | |||
| } | |||
| const submit = () => { | |||
| const value = query.value.trim() | |||
| if (!value) return | |||
| emit('submit', value) | |||
| } | |||
| const onGlobalKey = (event: KeyboardEvent) => { | |||
| if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return | |||
| const target = event.target as HTMLElement | null | |||
| const tag = target?.tagName | |||
| if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return | |||
| event.preventDefault() | |||
| inputRef.value?.focus() | |||
| } | |||
| onMounted(() => window.addEventListener('keydown', onGlobalKey)) | |||
| onUnmounted(() => window.removeEventListener('keydown', onGlobalKey)) | |||
| </script> | |||
| <template> | |||
| <form class="mx-auto w-full max-w-2xl" @submit.prevent="submit"> | |||
| <label class="sr-only" for="query-box">Query</label> | |||
| <div | |||
| class="group flex h-14 items-center gap-3 rounded-2xl border border-line bg-white/[0.035] px-3.5 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04)] transition-[border-color,box-shadow,background-color] duration-200 focus-within:border-accent/55 focus-within:bg-white/[0.05] focus-within:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06),0_0_0_3px_color-mix(in_oklab,var(--color-accent,oklch(0.82_0.1_180))_18%,transparent)]" | |||
| > | |||
| <span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-line text-muted"> | |||
| <svg | |||
| class="h-3.5 w-3.5" | |||
| viewBox="0 0 16 16" | |||
| fill="none" | |||
| stroke="currentColor" | |||
| stroke-width="1.5" | |||
| aria-hidden="true" | |||
| > | |||
| <circle cx="7" cy="7" r="4.25" /> | |||
| <path d="M10.4 10.4 14 14" stroke-linecap="round" /> | |||
| </svg> | |||
| </span> | |||
| <input | |||
| id="query-box" | |||
| ref="inputRef" | |||
| v-model="query" | |||
| type="text" | |||
| autocomplete="off" | |||
| spellcheck="false" | |||
| placeholder="Ask something…" | |||
| class="h-full min-w-0 flex-1 bg-transparent text-[15px] leading-none text-white caret-accent outline-none placeholder:text-muted/70" | |||
| @input="triggerChange" | |||
| /> | |||
| <kbd | |||
| class="hidden h-6 shrink-0 items-center rounded-md border border-line px-1.5 font-mono text-[10px] tracking-wide text-muted sm:inline-flex" | |||
| > | |||
| / | |||
| </kbd> | |||
| <button | |||
| type="submit" | |||
| class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-xl bg-accent/15 px-3 text-xs font-medium text-accent transition-colors hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40" | |||
| :disabled="!query.trim()" | |||
| > | |||
| Send | |||
| <svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"> | |||
| <path d="M2 6h8M7 3l3 3-3 3" stroke-linecap="round" stroke-linejoin="round" /> | |||
| </svg> | |||
| </button> | |||
| </div> | |||
| </form> | |||
| </template> | |||
| @@ -0,0 +1,28 @@ | |||
| <script setup lang="ts"> | |||
| import {onMounted, ref} from "vue"; | |||
| import {getQueries, LlmQuery} from "@/api/generated.ts"; | |||
| const savedQueries = ref([] as LlmQuery[]); | |||
| onMounted(async () => { | |||
| const resp = await getQueries({query: { limit: 10 }}); | |||
| savedQueries.value = resp.queries; | |||
| }) | |||
| </script> | |||
| <template> | |||
| <div class="flex flex-col gap-4"> | |||
| <h2 class="text-sm font-semibold uppercase tracking-wider text-muted/50">Recent Queries</h2> | |||
| <ul class="flex flex-col gap-2"> | |||
| <li v-for="query in savedQueries" :key="query.id" class="truncate text-sm text-ink hover:text-accent cursor-pointer"> | |||
| <RouterLink :to="`/response/${query.id}`"> | |||
| {{ query.title || 'Untitled Query' }} | |||
| </RouterLink> | |||
| </li> | |||
| </ul> | |||
| </div> | |||
| </template> | |||
| <style scoped> | |||
| </style> | |||
| @@ -1,6 +1,15 @@ | |||
| import { createApp } from "vue"; | |||
| import { createMemoryHistory, createRouter } from 'vue-router'; | |||
| import {routes} from './routes'; | |||
| import App from "./App.vue"; | |||
| import "./style.css"; | |||
| import { createPinia } from 'pinia'; | |||
| import App from "./App.vue"; | |||
| const pinia = createPinia(); | |||
| export const router = createRouter({ | |||
| history: createMemoryHistory(), | |||
| routes, | |||
| }) | |||
| createApp(App).mount("#app"); | |||
| createApp(App).use(router).use(pinia).mount("#app"); | |||
| @@ -0,0 +1,8 @@ | |||
| import HomeView from "@/views/HomeView.vue"; | |||
| import ResponseView from "@/views/ResponseView.vue"; | |||
| export const routes = [ | |||
| { path: '/', component: HomeView }, | |||
| { path: '/response', component: ResponseView }, | |||
| { path: '/response/:id', component: ResponseView }, | |||
| ] | |||
| @@ -0,0 +1,18 @@ | |||
| import { defineStore } from 'pinia'; | |||
| export const usePromptStore = defineStore('prompt', { | |||
| state: () => ({ | |||
| prompt: '' | |||
| }), | |||
| actions: { | |||
| setPrompt(prompt: string) { | |||
| this.prompt = prompt; | |||
| }, | |||
| getPrompt() { | |||
| return this.prompt; | |||
| }, | |||
| clearPrompt() { | |||
| this.prompt = ''; | |||
| } | |||
| } | |||
| }); | |||
| @@ -0,0 +1,38 @@ | |||
| <script setup lang="ts"> | |||
| import QueryBox from "@/components/QueryBox.vue"; | |||
| import {ref} from "vue"; | |||
| import {usePromptStore} from "@/stores/prompt.ts"; | |||
| import {useRouter} from "vue-router"; | |||
| const promptStore = usePromptStore(); | |||
| const router = useRouter(); | |||
| const query = ref(''); | |||
| const submitQuery = async () => { | |||
| promptStore.setPrompt(query.value); | |||
| await router.push('/response'); | |||
| } | |||
| const updateQuery = (v: string) => { | |||
| query.value = v; | |||
| } | |||
| </script> | |||
| <template> | |||
| <main class="relative flex flex-1"> | |||
| <div class="absolute inset-x-0 top-[42%] -translate-y-1/2 px-1"> | |||
| <QueryBox @on-change="updateQuery" @submit="submitQuery" /> | |||
| <p class="mt-3 text-center text-[11px] text-muted"> | |||
| Enter to send | |||
| <span class="mx-1.5 text-muted/50">·</span> | |||
| <kbd class="rounded border border-line px-1 py-px font-mono text-[10px]">/</kbd> | |||
| to focus | |||
| </p> | |||
| </div> | |||
| </main> | |||
| </template> | |||
| <style scoped> | |||
| </style> | |||
| @@ -0,0 +1,273 @@ | |||
| <script setup lang="ts"> | |||
| import {usePromptStore} from "@/stores/prompt.ts"; | |||
| import {computed, onBeforeMount, ref} from "vue"; | |||
| import {marked} from 'marked'; | |||
| import DOMPurify from 'dompurify'; | |||
| import {useRoute, useRouter} from "vue-router"; | |||
| import {getQuery} from "@/api/generated.ts"; | |||
| const promptStore = usePromptStore(); | |||
| const route = useRoute(); | |||
| const prompt = ref(''); | |||
| const response = ref(''); | |||
| const router = useRouter(); | |||
| let ws: WebSocket | null = null; | |||
| const initWebSocket = () => { | |||
| const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |||
| ws = new WebSocket(`${proto}//${location.host}/api/v1/llm/ws`); | |||
| ws.onopen = () => { | |||
| if (prompt.value === '') return; | |||
| ws?.send(JSON.stringify({ | |||
| model: 'qwen2.5:14b', | |||
| messages: [{role: 'user', content: prompt.value}], | |||
| stream: true, | |||
| options: { | |||
| temperature: 0.7, | |||
| } | |||
| })) | |||
| } | |||
| ws.onmessage = (e) => { | |||
| const ev = JSON.parse(e.data); | |||
| if (ev.type === 'delta') appendToken(ev.content); | |||
| if (ev.type === 'done') finish(); | |||
| if (ev.type === 'error') showError(ev.message); | |||
| } | |||
| } | |||
| const appendToken = (token: string) => { | |||
| response.value += token; | |||
| } | |||
| const finish = async () => { | |||
| ws?.close(); | |||
| } | |||
| const showError = (msg: string) => { | |||
| console.error(msg); | |||
| } | |||
| onBeforeMount(async () => { | |||
| const queryId = route.params.id as string; | |||
| if (queryId) { | |||
| try { | |||
| const query = await getQuery({path: {id: queryId}}); | |||
| prompt.value = query.title; | |||
| response.value = query.messages.join('\n'); | |||
| } catch (e) { | |||
| console.error("Failed to fetch query", e); | |||
| router.push('/'); | |||
| } | |||
| return; | |||
| } | |||
| prompt.value = promptStore.getPrompt(); | |||
| promptStore.clearPrompt(); | |||
| if (prompt.value === '') { | |||
| await router.push('/'); | |||
| return; | |||
| } | |||
| initWebSocket(); | |||
| }) | |||
| const renderedResponse = computed(() => { | |||
| return DOMPurify.sanitize(marked.parse(response.value, {sanitize: true})); | |||
| }) | |||
| </script> | |||
| <template> | |||
| <div class="py-2"> | |||
| <h2 class="text-xl py-1 font-bold">{{ prompt }}</h2> | |||
| <div class="markdown"> | |||
| <div v-html="renderedResponse"></div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <style scoped> | |||
| .markdown { | |||
| --text: #d9e1ec; | |||
| --muted: #94a3b8; | |||
| --heading: #f1f5f9; | |||
| --border: #273449; | |||
| --border-strong: #34445d; | |||
| --surface: #151c27; | |||
| --surface-raised: #1a2331; | |||
| --code-bg: #112130; | |||
| --accent: #5eead4; | |||
| --link: #7dd3fc; | |||
| color: var(--text); | |||
| font-size: 0.95rem; | |||
| line-height: 1.7; | |||
| overflow-wrap: anywhere; | |||
| } | |||
| .markdown :deep(p) { | |||
| margin: 0.65rem 0; | |||
| } | |||
| .markdown :deep(h1), | |||
| .markdown :deep(h2), | |||
| .markdown :deep(h3), | |||
| .markdown :deep(h4) { | |||
| color: var(--heading); | |||
| font-weight: 650; | |||
| letter-spacing: -0.015em; | |||
| line-height: 1.25; | |||
| } | |||
| .markdown :deep(h1) { | |||
| font-size: 1.5rem; | |||
| margin: 1.5rem 0 0.7rem; | |||
| } | |||
| .markdown :deep(h2) { | |||
| font-size: 1.25rem; | |||
| margin: 1.3rem 0 0.55rem; | |||
| padding-bottom: 0.35rem; | |||
| border-bottom: 1px solid var(--border); | |||
| } | |||
| .markdown :deep(h3) { | |||
| font-size: 1.06rem; | |||
| margin: 1rem 0 0.35rem; | |||
| } | |||
| .markdown :deep(h4) { | |||
| color: #cbd5e1; | |||
| font-size: 0.94rem; | |||
| margin: 0.85rem 0 0.25rem; | |||
| } | |||
| .markdown :deep(strong) { | |||
| color: #f8fafc; | |||
| font-weight: 700; | |||
| } | |||
| .markdown :deep(em) { | |||
| color: #cbd5e1; | |||
| } | |||
| .markdown :deep(a) { | |||
| color: var(--link); | |||
| text-decoration: none; | |||
| text-underline-offset: 0.18em; | |||
| } | |||
| .markdown :deep(a:hover) { | |||
| color: #bae6fd; | |||
| text-decoration: underline; | |||
| } | |||
| .markdown :deep(p > code), | |||
| .markdown :deep(li > code), | |||
| .markdown :deep(td > code) { | |||
| color: #b8f5e8; | |||
| font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, | |||
| Menlo, Monaco, Consolas, monospace; | |||
| font-size: 0.84em; | |||
| line-height: 1; | |||
| background: var(--code-bg); | |||
| border: 1px solid #24506a; | |||
| padding: 0.12rem 0.32rem; | |||
| border-radius: 0.3rem; | |||
| white-space: break-spaces; | |||
| } | |||
| .markdown :deep(pre) { | |||
| margin: 0.9rem 0; | |||
| padding: 0.85rem 1rem; | |||
| overflow-x: auto; | |||
| background: #0e1621; | |||
| border: 1px solid var(--border-strong); | |||
| border-radius: 0.55rem; | |||
| box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.025); | |||
| } | |||
| .markdown :deep(pre code) { | |||
| display: block; | |||
| color: #dbeafe; | |||
| font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, | |||
| Menlo, Monaco, Consolas, monospace; | |||
| font-size: 0.8rem; | |||
| line-height: 1.65; | |||
| background: transparent; | |||
| border: 0; | |||
| padding: 0; | |||
| } | |||
| .markdown :deep(ul), | |||
| .markdown :deep(ol) { | |||
| margin: 0.65rem 0; | |||
| padding-left: 1.35rem; | |||
| } | |||
| .markdown :deep(li) { | |||
| margin: 0.25rem 0; | |||
| padding-left: 0.15rem; | |||
| } | |||
| .markdown :deep(li::marker) { | |||
| color: var(--accent); | |||
| } | |||
| .markdown :deep(blockquote) { | |||
| margin: 0.85rem 0; | |||
| padding: 0.25rem 0 0.25rem 0.9rem; | |||
| color: #b7c4d6; | |||
| border-left: 3px solid #2dd4bf; | |||
| background: linear-gradient(90deg, rgb(45 212 191 / 0.08), transparent); | |||
| } | |||
| .markdown :deep(hr) { | |||
| height: 1px; | |||
| margin: 1.25rem 0; | |||
| border: 0; | |||
| background: var(--border); | |||
| } | |||
| .markdown :deep(table) { | |||
| display: block; | |||
| width: 100%; | |||
| margin: 0.85rem 0; | |||
| overflow-x: auto; | |||
| border: 1px solid var(--border); | |||
| border-radius: 0.45rem; | |||
| border-spacing: 0; | |||
| border-collapse: separate; | |||
| } | |||
| .markdown :deep(th), | |||
| .markdown :deep(td) { | |||
| padding: 0.55rem 0.7rem; | |||
| text-align: left; | |||
| border-bottom: 1px solid var(--border); | |||
| } | |||
| .markdown :deep(th) { | |||
| color: #dbeafe; | |||
| font-size: 0.82rem; | |||
| font-weight: 650; | |||
| background: var(--surface-raised); | |||
| } | |||
| .markdown :deep(tr:last-child td) { | |||
| border-bottom: 0; | |||
| } | |||
| .markdown :deep(img) { | |||
| display: block; | |||
| max-width: 100%; | |||
| margin: 0.85rem 0; | |||
| border: 1px solid var(--border); | |||
| border-radius: 0.55rem; | |||
| } | |||
| </style> | |||
| @@ -110,7 +110,7 @@ export default defineConfig({ | |||
| port: frontendPort, | |||
| strictPort: true, | |||
| proxy: { | |||
| "/api": { target: backendOrigin, changeOrigin: true }, | |||
| "/api": { target: backendOrigin, changeOrigin: true, ws: true }, | |||
| "/health": { target: backendOrigin, changeOrigin: true }, | |||
| }, | |||
| }, | |||
| @@ -222,6 +222,115 @@ | |||
| } | |||
| } | |||
| }, | |||
| "/api/v1/llm/ws": { | |||
| "get": { | |||
| "tags": [ | |||
| "llm" | |||
| ], | |||
| "description": "WebSocket upgrade. After 101, send LlmRequest as one text frame. Server sends LlmWsEvent frames.", | |||
| "operationId": "send", | |||
| "responses": { | |||
| "101": { | |||
| "description": "Switching Protocols" | |||
| } | |||
| } | |||
| } | |||
| }, | |||
| "/api/v1/queries/list": { | |||
| "get": { | |||
| "tags": [ | |||
| "queries" | |||
| ], | |||
| "description": "Get all saved queries", | |||
| "operationId": "get_queries", | |||
| "parameters": [ | |||
| { | |||
| "name": "limit", | |||
| "in": "query", | |||
| "description": "Maximum number of queries to return", | |||
| "required": false, | |||
| "schema": { | |||
| "type": "integer", | |||
| "format": "int64" | |||
| } | |||
| } | |||
| ], | |||
| "responses": { | |||
| "200": { | |||
| "description": "Fetched LLM queries", | |||
| "content": { | |||
| "application/json": { | |||
| "schema": { | |||
| "$ref": "#/components/schemas/SavedQueriesResponse" | |||
| } | |||
| } | |||
| } | |||
| }, | |||
| "400": { | |||
| "description": "Validation error", | |||
| "content": { | |||
| "application/json": { | |||
| "schema": { | |||
| "$ref": "#/components/schemas/ErrorBody" | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| }, | |||
| "/api/v1/queries/{id}": { | |||
| "get": { | |||
| "tags": [ | |||
| "queries" | |||
| ], | |||
| "description": "Get a single saved query by ID", | |||
| "operationId": "get_query", | |||
| "parameters": [ | |||
| { | |||
| "name": "id", | |||
| "in": "path", | |||
| "required": true, | |||
| "schema": { | |||
| "type": "string", | |||
| "format": "uuid" | |||
| } | |||
| } | |||
| ], | |||
| "responses": { | |||
| "200": { | |||
| "description": "Fetched LLM query", | |||
| "content": { | |||
| "application/json": { | |||
| "schema": { | |||
| "$ref": "#/components/schemas/LlmQueryDetail" | |||
| } | |||
| } | |||
| } | |||
| }, | |||
| "400": { | |||
| "description": "Validation error", | |||
| "content": { | |||
| "application/json": { | |||
| "schema": { | |||
| "$ref": "#/components/schemas/ErrorBody" | |||
| } | |||
| } | |||
| } | |||
| }, | |||
| "404": { | |||
| "description": "Query not found", | |||
| "content": { | |||
| "application/json": { | |||
| "schema": { | |||
| "$ref": "#/components/schemas/ErrorBody" | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| }, | |||
| "/health/live": { | |||
| "get": { | |||
| "tags": [ | |||
| @@ -411,6 +520,45 @@ | |||
| } | |||
| } | |||
| }, | |||
| "LlmQuery": { | |||
| "type": "object", | |||
| "required": [ | |||
| "id", | |||
| "title" | |||
| ], | |||
| "properties": { | |||
| "id": { | |||
| "type": "string", | |||
| "format": "uuid" | |||
| }, | |||
| "title": { | |||
| "type": "string" | |||
| } | |||
| } | |||
| }, | |||
| "LlmQueryDetail": { | |||
| "type": "object", | |||
| "required": [ | |||
| "id", | |||
| "title", | |||
| "messages" | |||
| ], | |||
| "properties": { | |||
| "id": { | |||
| "type": "string", | |||
| "format": "uuid" | |||
| }, | |||
| "messages": { | |||
| "type": "array", | |||
| "items": { | |||
| "type": "string" | |||
| } | |||
| }, | |||
| "title": { | |||
| "type": "string" | |||
| } | |||
| } | |||
| }, | |||
| "ReadyResponse": { | |||
| "type": "object", | |||
| "required": [ | |||
| @@ -426,6 +574,20 @@ | |||
| } | |||
| } | |||
| }, | |||
| "SavedQueriesResponse": { | |||
| "type": "object", | |||
| "required": [ | |||
| "queries" | |||
| ], | |||
| "properties": { | |||
| "queries": { | |||
| "type": "array", | |||
| "items": { | |||
| "$ref": "#/components/schemas/LlmQuery" | |||
| } | |||
| } | |||
| } | |||
| }, | |||
| "UpdateItemRequest": { | |||
| "type": "object", | |||
| "properties": { | |||
| @@ -131,10 +131,10 @@ function operationName(method, pathName, operation, used) { | |||
| return uniqueName(camel(`${method}_${slug}`), used); | |||
| } | |||
| function pathToTemplate(pathName, paramNames) { | |||
| function pathToTemplate(pathName, paramNames, argPrefix) { | |||
| let template = JSON.stringify(pathName); | |||
| for (const name of paramNames) { | |||
| template = template.replace(`{${name}}`, `" + encodeURIComponent(String(args.path.${name})) + "`); | |||
| template = template.replace(`{${name}}`, `" + encodeURIComponent(String(${argPrefix}.path.${name})) + "`); | |||
| } | |||
| return template.replace(/ \+ ""/g, ""); | |||
| } | |||
| @@ -191,14 +191,16 @@ for (const [pathName, item] of Object.entries(paths)) { | |||
| args.push(`body: ${tsType(bodySchema, spec)}`); | |||
| } | |||
| const argList = args.length ? `args: {\n ${args.join(";\n ")}\n}` : ""; | |||
| const argPrefix = (pathParams.length || queryParams.length || bodySchema) ? "args" : "_args"; | |||
| const argList = args.length ? `${argPrefix}: {\n ${args.join(";\n ")}\n}` : ""; | |||
| const returnType = responseSchema ? tsType(responseSchema, spec) : "void"; | |||
| const pathExpr = pathToTemplate( | |||
| pathName, | |||
| pathParams.map((param) => param.name), | |||
| argPrefix, | |||
| ); | |||
| const queryLine = queryParams.length ? " query: args.query," : ""; | |||
| const bodyLine = bodySchema ? " body: args.body," : ""; | |||
| const queryLine = queryParams.length ? ` query: ${argPrefix}.query,` : ""; | |||
| const bodyLine = bodySchema ? ` body: ${argPrefix}.body,` : ""; | |||
| const requestFields = [ | |||
| ` method: "${method.toUpperCase()}",`, | |||