| @@ -10,7 +10,7 @@ repository = "https://ikibani.com/kashiro/rust-template" | |||||
| [workspace.dependencies] | [workspace.dependencies] | ||||
| anyhow = "1" | 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"] } | chrono = { version = "0.4", features = ["serde"] } | ||||
| dotenvy = "0.15" | dotenvy = "0.15" | ||||
| figment = { version = "0.10", features = ["toml", "env"] } | figment = { version = "0.10", features = ["toml", "env"] } | ||||
| @@ -21,6 +21,7 @@ chrono.workspace = true | |||||
| dotenvy.workspace = true | dotenvy.workspace = true | ||||
| figment.workspace = true | figment.workspace = true | ||||
| http.workspace = true | http.workspace = true | ||||
| reqwest = { version = "0.13.4", features = ["json"] } | |||||
| serde.workspace = true | serde.workspace = true | ||||
| serde_json.workspace = true | serde_json.workspace = true | ||||
| sqlx.workspace = true | sqlx.workspace = true | ||||
| @@ -35,6 +36,7 @@ utoipa-axum.workspace = true | |||||
| utoipa-swagger-ui.workspace = true | utoipa-swagger-ui.workspace = true | ||||
| uuid.workspace = true | uuid.workspace = true | ||||
| validator.workspace = true | validator.workspace = true | ||||
| log = "0.4.33" | |||||
| [dev-dependencies] | [dev-dependencies] | ||||
| http-body-util = "0.1" | 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 { | fn default() -> Self { | ||||
| Self { | Self { | ||||
| app: AppSection { | app: AppSection { | ||||
| name: "rust-template".into(), | |||||
| name: "Lyra".into(), | |||||
| description: "Axum + Vue + PostgreSQL fullstack app".into(), | description: "Axum + Vue + PostgreSQL fullstack app".into(), | ||||
| }, | }, | ||||
| server: ServerSection { | server: ServerSection { | ||||
| @@ -35,6 +35,12 @@ pub enum ApiError { | |||||
| Internal(#[from] anyhow::Error), | Internal(#[from] anyhow::Error), | ||||
| } | } | ||||
| impl From<reqwest::Error> for ApiError { | |||||
| fn from(err: reqwest::Error) -> Self { | |||||
| ApiError::Internal(anyhow::anyhow!(err)) | |||||
| } | |||||
| } | |||||
| impl ApiError { | impl ApiError { | ||||
| pub fn status(&self) -> StatusCode { | pub fn status(&self) -> StatusCode { | ||||
| match self { | 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 health; | ||||
| pub mod items; | 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"); | 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 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?; | let listener = tokio::net::TcpListener::bind(addr).await?; | ||||
| tracing::info!(%addr, "listening"); | tracing::info!(%addr, "listening"); | ||||
| axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).await?; | 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 health; | ||||
| pub mod item; | pub mod item; | ||||
| pub mod pagination; | pub mod pagination; | ||||
| pub mod llm; | |||||
| @@ -1,6 +1,8 @@ | |||||
| use crate::routes::llm::__path_send; | |||||
| use axum::Router; | use axum::Router; | ||||
| use axum::http::{HeaderValue, Method, header}; | use axum::http::{HeaderValue, Method, header}; | ||||
| use axum::middleware; | use axum::middleware; | ||||
| use axum::routing::get; | |||||
| use tower::ServiceBuilder; | use tower::ServiceBuilder; | ||||
| use tower_http::compression::CompressionLayer; | use tower_http::compression::CompressionLayer; | ||||
| use tower_http::cors::{AllowOrigin, CorsLayer}; | use tower_http::cors::{AllowOrigin, CorsLayer}; | ||||
| @@ -12,7 +14,8 @@ use utoipa_axum::routes; | |||||
| use utoipa_swagger_ui::SwaggerUi; | use utoipa_swagger_ui::SwaggerUi; | ||||
| use crate::config::AppConfig; | 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::middleware::request_log::{REQUEST_ID_HEADER, request_logging}; | ||||
| use crate::state::AppState; | use crate::state::AppState; | ||||
| @@ -47,6 +50,9 @@ pub fn openapi_router() -> OpenApiRouter<AppState> { | |||||
| .routes(routes!(items::create_item)) | .routes(routes!(items::create_item)) | ||||
| .routes(routes!(items::update_item)) | .routes(routes!(items::update_item)) | ||||
| .routes(routes!(items::delete_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 { | pub fn openapi_spec() -> utoipa::openapi::OpenApi { | ||||
| @@ -6,16 +6,17 @@ use sqlx::PgPool; | |||||
| use crate::config::AppConfig; | use crate::config::AppConfig; | ||||
| #[derive(Clone)] | #[derive(Clone)] | ||||
| pub struct AppState(Arc<InnerState>); | |||||
| pub struct AppState(pub(crate) Arc<InnerState>); | |||||
| pub struct InnerState { | pub struct InnerState { | ||||
| pub config: AppConfig, | pub config: AppConfig, | ||||
| pub db: PgPool, | pub db: PgPool, | ||||
| pub client: reqwest::Client, | |||||
| } | } | ||||
| impl AppState { | 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 { | pub fn db(&self) -> &PgPool { | ||||
| @@ -8,10 +8,15 @@ | |||||
| "name": "web", | "name": "web", | ||||
| "version": "0.1.0", | "version": "0.1.0", | ||||
| "dependencies": { | "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": { | "devDependencies": { | ||||
| "@tailwindcss/vite": "^4.1.11", | "@tailwindcss/vite": "^4.1.11", | ||||
| "@types/marked": "^4.3.0", | |||||
| "@types/node": "^22.17.0", | "@types/node": "^22.17.0", | ||||
| "@vitejs/plugin-vue": "^6.0.1", | "@vitejs/plugin-vue": "^6.0.1", | ||||
| "smol-toml": "^1.4.2", | "smol-toml": "^1.4.2", | ||||
| @@ -1211,6 +1216,13 @@ | |||||
| "dev": true, | "dev": true, | ||||
| "license": "MIT" | "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": { | "node_modules/@types/node": { | ||||
| "version": "22.20.1", | "version": "22.20.1", | ||||
| "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", | ||||
| @@ -1221,6 +1233,13 @@ | |||||
| "undici-types": "~6.21.0" | "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": { | "node_modules/@vitejs/plugin-vue": { | ||||
| "version": "6.0.8", | "version": "6.0.8", | ||||
| "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", | "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", | ||||
| @@ -1317,6 +1336,12 @@ | |||||
| "@vue/shared": "3.5.41" | "@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": { | "node_modules/@vue/language-core": { | ||||
| "version": "3.3.10", | "version": "3.3.10", | ||||
| "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.10.tgz", | "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.10.tgz", | ||||
| @@ -1404,6 +1429,15 @@ | |||||
| "node": ">=8" | "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": { | "node_modules/enhanced-resolve": { | ||||
| "version": "5.24.5", | "version": "5.24.5", | ||||
| "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", | "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", | ||||
| @@ -1798,6 +1832,18 @@ | |||||
| "@jridgewell/sourcemap-codec": "^1.5.5" | "@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": { | "node_modules/muggle-string": { | ||||
| "version": "0.4.1", | "version": "0.4.1", | ||||
| "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", | "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", | ||||
| @@ -1849,6 +1895,28 @@ | |||||
| "url": "https://github.com/sponsors/jonschlinkert" | "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": { | "node_modules/postcss": { | ||||
| "version": "8.5.26", | "version": "8.5.26", | ||||
| "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", | "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": { | "node_modules/vue-tsc": { | ||||
| "version": "3.3.10", | "version": "3.3.10", | ||||
| "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.10.tgz", | "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.10.tgz", | ||||
| @@ -11,10 +11,15 @@ | |||||
| "generate:api": "node ../../scripts/generate-api.mjs" | "generate:api": "node ../../scripts/generate-api.mjs" | ||||
| }, | }, | ||||
| "dependencies": { | "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": { | "devDependencies": { | ||||
| "@tailwindcss/vite": "^4.1.11", | "@tailwindcss/vite": "^4.1.11", | ||||
| "@types/marked": "^4.3.0", | |||||
| "@types/node": "^22.17.0", | "@types/node": "^22.17.0", | ||||
| "@vitejs/plugin-vue": "^6.0.1", | "@vitejs/plugin-vue": "^6.0.1", | ||||
| "smol-toml": "^1.4.2", | "smol-toml": "^1.4.2", | ||||
| @@ -1,30 +1,31 @@ | |||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import HealthBadge from "@/components/HealthBadge.vue"; | import HealthBadge from "@/components/HealthBadge.vue"; | ||||
| import ItemBoard from "@/components/ItemBoard.vue"; | |||||
| import QueryList from "@/components/QueryList.vue"; | |||||
| </script> | </script> | ||||
| <template> | <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> | </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> | </div> | ||||
| </template> | </template> | ||||
| @@ -38,11 +38,26 @@ export type ItemResponse = { | |||||
| updated_at: string; | updated_at: string; | ||||
| }; | }; | ||||
| export type LlmQuery = { | |||||
| id: string; | |||||
| title: string; | |||||
| }; | |||||
| export type LlmQueryDetail = { | |||||
| id: string; | |||||
| messages: Array<string>; | |||||
| title: string; | |||||
| }; | |||||
| export type ReadyResponse = { | export type ReadyResponse = { | ||||
| database: string; | database: string; | ||||
| status: string; | status: string; | ||||
| }; | }; | ||||
| export type SavedQueriesResponse = { | |||||
| queries: Array<LlmQuery>; | |||||
| }; | |||||
| export type UpdateItemRequest = { | export type UpdateItemRequest = { | ||||
| description?: string | null; | description?: string | null; | ||||
| name?: 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> { | export function live(): Promise<HealthResponse> { | ||||
| return request<HealthResponse>({ | return request<HealthResponse>({ | ||||
| method: "GET", | 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 { createApp } from "vue"; | ||||
| import { createMemoryHistory, createRouter } from 'vue-router'; | |||||
| import {routes} from './routes'; | |||||
| import App from "./App.vue"; | |||||
| import "./style.css"; | 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, | port: frontendPort, | ||||
| strictPort: true, | strictPort: true, | ||||
| proxy: { | proxy: { | ||||
| "/api": { target: backendOrigin, changeOrigin: true }, | |||||
| "/api": { target: backendOrigin, changeOrigin: true, ws: true }, | |||||
| "/health": { target: backendOrigin, changeOrigin: 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": { | "/health/live": { | ||||
| "get": { | "get": { | ||||
| "tags": [ | "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": { | "ReadyResponse": { | ||||
| "type": "object", | "type": "object", | ||||
| "required": [ | "required": [ | ||||
| @@ -426,6 +574,20 @@ | |||||
| } | } | ||||
| } | } | ||||
| }, | }, | ||||
| "SavedQueriesResponse": { | |||||
| "type": "object", | |||||
| "required": [ | |||||
| "queries" | |||||
| ], | |||||
| "properties": { | |||||
| "queries": { | |||||
| "type": "array", | |||||
| "items": { | |||||
| "$ref": "#/components/schemas/LlmQuery" | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "UpdateItemRequest": { | "UpdateItemRequest": { | ||||
| "type": "object", | "type": "object", | ||||
| "properties": { | "properties": { | ||||
| @@ -131,10 +131,10 @@ function operationName(method, pathName, operation, used) { | |||||
| return uniqueName(camel(`${method}_${slug}`), used); | return uniqueName(camel(`${method}_${slug}`), used); | ||||
| } | } | ||||
| function pathToTemplate(pathName, paramNames) { | |||||
| function pathToTemplate(pathName, paramNames, argPrefix) { | |||||
| let template = JSON.stringify(pathName); | let template = JSON.stringify(pathName); | ||||
| for (const name of paramNames) { | 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, ""); | return template.replace(/ \+ ""/g, ""); | ||||
| } | } | ||||
| @@ -191,14 +191,16 @@ for (const [pathName, item] of Object.entries(paths)) { | |||||
| args.push(`body: ${tsType(bodySchema, spec)}`); | 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 returnType = responseSchema ? tsType(responseSchema, spec) : "void"; | ||||
| const pathExpr = pathToTemplate( | const pathExpr = pathToTemplate( | ||||
| pathName, | pathName, | ||||
| pathParams.map((param) => param.name), | 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 = [ | const requestFields = [ | ||||
| ` method: "${method.toUpperCase()}",`, | ` method: "${method.toUpperCase()}",`, | ||||