Bläddra i källkod

Create the streaming for creating new turns

Lee Morgan 3 veckor sedan
förälder
incheckning
0669f92d59
5 ändrade filer med 229 tillägg och 35 borttagningar
  1. 138 1
      Cargo.lock
  2. 3 1
      Cargo.toml
  3. 5 4
      src/controllers/game/session/create.rs
  4. 23 16
      src/controllers/game/session/turn/create.rs
  5. 60 13
      src/logic/prompt.rs

+ 138 - 1
Cargo.lock

@@ -169,7 +169,7 @@ dependencies = [
  "foldhash",
  "futures-core",
  "futures-util",
- "impl-more",
+ "impl-more 0.1.9",
  "itoa",
  "language-tags",
  "log",
@@ -200,12 +200,73 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "actix-web-lab"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a89606b86fd2f8c0ddd72ef0be171e58c919ee0b6914aa7b52354063e5ee361"
+dependencies = [
+ "actix-http",
+ "actix-router",
+ "actix-service",
+ "actix-utils",
+ "actix-web",
+ "actix-web-lab-derive",
+ "ahash",
+ "arc-swap",
+ "bytes",
+ "bytestring",
+ "csv",
+ "derive_more",
+ "form_urlencoded",
+ "futures-core",
+ "futures-util",
+ "http 0.2.12",
+ "impl-more 0.3.1",
+ "itertools",
+ "local-channel",
+ "mime",
+ "pin-project-lite",
+ "regex",
+ "serde",
+ "serde_html_form",
+ "serde_json",
+ "serde_path_to_error",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "actix-web-lab-derive"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ac4d6e04e97fe707286509b4f338e99c5fb7249c770e1da074af5e27faa96b3"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "adler2"
 version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
 
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
 [[package]]
 name = "aho-corasick"
 version = "1.1.4"
@@ -251,6 +312,15 @@ version = "1.0.102"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
 
+[[package]]
+name = "arc-swap"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
+dependencies = [
+ "rustversion",
+]
+
 [[package]]
 name = "argon2"
 version = "0.5.3"
@@ -485,10 +555,12 @@ version = "0.1.0"
 dependencies = [
  "actix-cors",
  "actix-web",
+ "actix-web-lab",
  "argon2",
  "async-stripe",
  "chrono",
  "dotenvy",
+ "futures-util",
  "jsonwebtoken",
  "reqwest",
  "serde",
@@ -676,6 +748,27 @@ dependencies = [
  "hybrid-array",
 ]
 
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "darling"
 version = "0.20.11"
@@ -1549,6 +1642,12 @@ version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
 
+[[package]]
+name = "impl-more"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
+
 [[package]]
 name = "indexmap"
 version = "2.14.0"
@@ -1582,6 +1681,15 @@ version = "2.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
 
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itoa"
 version = "1.0.18"
@@ -2377,6 +2485,7 @@ dependencies = [
  "bytes",
  "encoding_rs",
  "futures-core",
+ "futures-util",
  "h2 0.4.14",
  "http 1.4.1",
  "http-body 1.0.1",
@@ -2399,12 +2508,14 @@ dependencies = [
  "sync_wrapper",
  "tokio",
  "tokio-rustls",
+ "tokio-util",
  "tower",
  "tower-http",
  "tower-service",
  "url",
  "wasm-bindgen",
  "wasm-bindgen-futures",
+ "wasm-streams",
  "web-sys",
 ]
 
@@ -2640,6 +2751,19 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "serde_html_form"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0946d52b4b7e28823148aebbeceb901012c595ad737920d504fa8634bb099e6f"
+dependencies = [
+ "form_urlencoded",
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
 [[package]]
 name = "serde_json"
 version = "1.0.150"
@@ -3689,6 +3813,19 @@ dependencies = [
  "wasmparser",
 ]
 
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
 [[package]]
 name = "wasmparser"
 version = "0.244.0"

+ 3 - 1
Cargo.toml

@@ -6,12 +6,14 @@ edition = "2024"
 [dependencies]
 actix-cors = "0.7.1"
 actix-web = "4.13.0"
+actix-web-lab = "0.26.0"
 argon2 = "0.5.3"
 async-stripe = { version = "0.41.0", features = ["runtime-tokio-hyper", "webhook-events"] }
 chrono = { version = "0.4.44", features = ["serde"]}
 dotenvy = "0.15.7"
+futures-util = "0.3.32"
 jsonwebtoken = "9"
-reqwest = { version = "0.13.4", features = ["json", "form"] }
+reqwest = { version = "0.13.4", features = ["json", "form", "stream"] }
 serde = { version = "1.0.228", features = ["derive"]}
 serde_json = "1.0.150"
 sqlx = { version = "0.8.6", features = [

+ 5 - 4
src/controllers/game/session/create.rs

@@ -1,4 +1,4 @@
-use actix_web::{HttpResponse, HttpRequest, web, post};
+use actix_web::{HttpResponse, HttpRequest, Responder, web, post};
 use sqlx::PgPool;
 use uuid::Uuid;
 use serde::Serialize;
@@ -26,12 +26,12 @@ pub async fn route(
     db: web::Data<PgPool>,
     path: web::Path<String>,
     req: HttpRequest
-) -> Result<HttpResponse, HttpError> {
+) -> Result<impl Responder, HttpError> {
     let user = user_auth(&db, &req, true).await?;
     let game_id = Uuid::parse_str(&path.into_inner())?;
     let (game, mut characters, sessions) = Game::get_full_game(&db, game_id.clone(), Some(user.id)).await?;
     let session = Session::new(game_id);
-    let response = prompt(game, characters.remove(0), sessions, vec![], None, true).await?;
+    /*let response = prompt(game, characters.remove(0), sessions, vec![], None, true).await?;
     let turn = Turn::new(
         session.id,
         response.input_tokens,
@@ -48,5 +48,6 @@ pub async fn route(
         session: session,
         turn: turn,
         tokens_remaining: tokens_remaining
-    }))
+    }))*/
+    Ok(HttpResponse::Ok().body("ok"))
 }

+ 23 - 16
src/controllers/game/session/turn/create.rs

@@ -1,7 +1,8 @@
-use actix_web::{HttpResponse, HttpRequest, web, post};
+use actix_web::{HttpResponse, HttpRequest, Responder, web, post};
 use serde::{Serialize, Deserialize};
 use sqlx::PgPool;
 use uuid::Uuid;
+use tokio::sync::oneshot::Receiver;
 use crate::{
     http_error::HttpError,
     auth::user_auth,
@@ -35,7 +36,7 @@ pub async fn route(
     path: web::Path<Path>,
     web::Json(body): web::Json<Body>,
     req: HttpRequest
-) -> Result<HttpResponse, HttpError> {
+) -> Result<impl Responder, HttpError> {
     let user = user_auth(&db, &req, true).await?;
     let game_id = Uuid::parse_str(&path.game_id)?;
 
@@ -53,19 +54,25 @@ pub async fn route(
         false
     ).await?;
 
-    let new_turn = Turn::new(
-        recent_session_id,
-        response.input_tokens,
-        response.output_tokens,
-        body.content,
-        response.message
-    );
-
-    User::add_tokens(&db, user.id, -new_turn.tokens_used).await?;
+    update_db(response.message, recent_session_id, body.content, user.id, db);
+    Ok(response.sse)
+}
 
-    let tokens_remaining = user.tokens - new_turn.tokens_used;
-    Ok(HttpResponse::Ok().json(Response {
-        turn: new_turn,
-        tokens_remaining: tokens_remaining
-    }))
+fn update_db(
+    message: Receiver<(String, i32, i32)>,
+    session_id: Uuid,
+    user_text: String,
+    user_id: Uuid,
+    db: web::Data<PgPool>
+) {
+    tokio::spawn(async move {
+        if let Ok((m, it, ot)) = message.await {
+            let new_turn = Turn::new(session_id, it, ot, user_text, m);
+            new_turn.insert(&db).await;
+            User::add_tokens(&db, user_id, -new_turn.tokens_used).await;
+            Ok::<(), HttpError>(())
+        } else {
+            Ok::<(), HttpError>(())
+        };
+    });
 }

+ 60 - 13
src/logic/prompt.rs

@@ -1,6 +1,15 @@
+use actix_web::{HttpResponse, web::Bytes};
+use actix_web_lab::sse;
 use reqwest::Client;
 use serde::Serialize;
-use serde_json::{json, Value};
+use serde_json::{Value, json, from_str};
+use futures_util::{StreamExt, TryStreamExt, Stream, stream};
+use tokio::sync::oneshot;
+use std::{
+    convert::Infallible,
+    sync::{Arc, Mutex},
+    pin::Pin
+};
 use crate::{
     http_error::HttpError,
     models::{
@@ -27,9 +36,8 @@ enum PromptRole {
 }
 
 pub struct PromptResponse {
-    pub message: String,
-    pub input_tokens: i32,
-    pub output_tokens: i32
+    pub message: oneshot::Receiver<(String, i32, i32)>,
+    pub sse: HttpResponse
 }
 
 pub async fn prompt(
@@ -49,7 +57,7 @@ pub async fn prompt(
             content: p
         });
     }
-    Ok(send_message(prompt).await?)
+    send_message(prompt).await
 }
 
 fn create_system_message(
@@ -112,7 +120,10 @@ async fn send_message(prompt: Vec<PromptMessage>) -> Result<PromptResponse, Http
     let body = json!({
         "model": "Qwen/Qwen3-32B",
         "messages": prompt,
-        "reasoning_effort": "none"
+        "reasoning_effort": "none",
+        "temperature": 1.2,
+        "stream": true,
+        "stream_options": {"include_usage": true}
     });
 
     let response = client
@@ -124,14 +135,50 @@ async fn send_message(prompt: Vec<PromptMessage>) -> Result<PromptResponse, Http
         .await
         .map_err(|e| HttpError::InternalError(e.to_string()))?;
 
-    let data: Value = response
-        .json()
-        .await
-        .map_err(|e| HttpError::InternalError(e.to_string()))?;
+    let (tx, rx) = oneshot::channel::<(String, i32, i32)>();
+    let full_text = Arc::new(Mutex::new(String::new()));
+    let acc = full_text.clone();
+
+    let stream = response.bytes_stream().map(move |chunk| -> Result<Bytes, HttpError> {
+        match chunk {
+            Ok(bytes) => {
+                acc.lock().unwrap().push_str(&String::from_utf8_lossy(&bytes));
+                Ok(bytes)
+            },
+            Err(e) => Err(HttpError::InternalError(e.to_string()))
+        }
+    }).chain(stream::once(async move {
+        let raw = full_text.lock().unwrap().clone();
+
+        let content = raw.lines()
+            .filter_map(|l| l.strip_prefix("data: "))
+            .filter(|d| *d != "[DONE]")
+            .filter_map(|d| from_str::<Value>(d).ok())
+            .filter_map(|v| v["choices"][0]["delta"]["content"].as_str().map(str::to_string))
+            .collect::<String>();
+
+        let (input_tokens, output_tokens) = raw.lines()
+            .filter_map(|l| l.strip_prefix("data: "))
+            .filter_map(|d| from_str::<Value>(d).ok())
+            .find_map(|v| {
+                let usage = v.get("usage")?;
+                if usage.is_null() { return None; }
+                Some(usage.clone())
+            })
+            .map(|u| (
+                u["prompt_tokens"].as_i64().unwrap_or(0) as i32,
+                u["completion_tokens"].as_i64().unwrap_or(0) as i32
+            ))
+            .unwrap_or((0, 0));
+
+        let _ = tx.send((content, input_tokens, output_tokens));
+        Ok::<Bytes, HttpError>(Bytes::new())
+    }));
 
     Ok(PromptResponse {
-        message: (&data["choices"][0]["message"]["content"]).to_string(),
-        input_tokens: data["usage"]["prompt_tokens"].as_i64().unwrap_or(0) as i32,
-        output_tokens: data["usage"]["completion_tokens"].as_i64().unwrap_or(0) as i32
+        message: rx,
+        sse: HttpResponse::Ok()
+            .content_type("text/event-stream")
+            .streaming(stream)
     })
 }