浏览代码

Add WebSocket support for LLM queries and query management interface

master
Jared Bell 1周前
父节点
当前提交
961c5e2083
共有 28 个文件被更改,包括 2077 次插入56 次删除
  1. +848
    -15
      Cargo.lock
  2. +1
    -1
      Cargo.toml
  3. +2
    -0
      apps/server/Cargo.toml
  4. +12
    -0
      apps/server/migrations/0002_queries.sql
  5. +1
    -1
      apps/server/src/config.rs
  6. +6
    -0
      apps/server/src/error.rs
  7. +198
    -0
      apps/server/src/handlers/llm.rs
  8. +2
    -0
      apps/server/src/handlers/mod.rs
  9. +61
    -0
      apps/server/src/handlers/queries.rs
  10. +10
    -3
      apps/server/src/main.rs
  11. +107
    -0
      apps/server/src/models/llm.rs
  12. +1
    -0
      apps/server/src/models/mod.rs
  13. +7
    -1
      apps/server/src/routes/mod.rs
  14. +4
    -3
      apps/server/src/state.rs
  15. +110
    -1
      apps/web/package-lock.json
  16. +6
    -1
      apps/web/package.json
  17. +23
    -22
      apps/web/src/App.vue
  18. +48
    -0
      apps/web/src/api/generated.ts
  19. +84
    -0
      apps/web/src/components/QueryBox.vue
  20. +28
    -0
      apps/web/src/components/QueryList.vue
  21. +11
    -2
      apps/web/src/main.ts
  22. +8
    -0
      apps/web/src/routes/index.ts
  23. +18
    -0
      apps/web/src/stores/prompt.ts
  24. +38
    -0
      apps/web/src/views/HomeView.vue
  25. +273
    -0
      apps/web/src/views/ResponseView.vue
  26. +1
    -1
      apps/web/vite.config.ts
  27. +162
    -0
      packages/contracts/openapi.json
  28. +7
    -5
      scripts/generate-api.mjs

+ 848
- 15
Cargo.lock
文件差异内容过多而无法显示
查看文件


+ 1
- 1
Cargo.toml 查看文件

@@ -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"] }


+ 2
- 0
apps/server/Cargo.toml 查看文件

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


+ 12
- 0
apps/server/migrations/0002_queries.sql 查看文件

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

+ 1
- 1
apps/server/src/config.rs 查看文件

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


+ 6
- 0
apps/server/src/error.rs 查看文件

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


+ 198
- 0
apps/server/src/handlers/llm.rs 查看文件

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

+ 2
- 0
apps/server/src/handlers/mod.rs 查看文件

@@ -1,2 +1,4 @@
pub mod health;
pub mod items;
pub mod llm;
pub mod queries;

+ 61
- 0
apps/server/src/handlers/queries.rs 查看文件

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

+ 10
- 3
apps/server/src/main.rs 查看文件

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


+ 107
- 0
apps/server/src/models/llm.rs 查看文件

@@ -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
- 0
apps/server/src/models/mod.rs 查看文件

@@ -1,3 +1,4 @@
pub mod health;
pub mod item;
pub mod pagination;
pub mod llm;

+ 7
- 1
apps/server/src/routes/mod.rs 查看文件

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


+ 4
- 3
apps/server/src/state.rs 查看文件

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


+ 110
- 1
apps/web/package-lock.json 查看文件

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


+ 6
- 1
apps/web/package.json 查看文件

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


+ 23
- 22
apps/web/src/App.vue 查看文件

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

+ 48
- 0
apps/web/src/api/generated.ts 查看文件

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


+ 84
- 0
apps/web/src/components/QueryBox.vue 查看文件

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

+ 28
- 0
apps/web/src/components/QueryList.vue 查看文件

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

+ 11
- 2
apps/web/src/main.ts 查看文件

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

+ 8
- 0
apps/web/src/routes/index.ts 查看文件

@@ -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 },
]

+ 18
- 0
apps/web/src/stores/prompt.ts 查看文件

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

+ 38
- 0
apps/web/src/views/HomeView.vue 查看文件

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

+ 273
- 0
apps/web/src/views/ResponseView.vue 查看文件

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

+ 1
- 1
apps/web/vite.config.ts 查看文件

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


+ 162
- 0
packages/contracts/openapi.json 查看文件

@@ -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": {


+ 7
- 5
scripts/generate-api.mjs 查看文件

@@ -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()}",`,


正在加载...
取消
保存