feat: thinking_content column + first-project budget logic
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

- Add thinking_content column to room_message table
- Migration for thinking_content column
- ws-protocol update with streaming chunk types
- Billing: first project gets $10, first workspace gets $30
- Subsequent projects/workspaces get $0 budget
This commit is contained in:
ZhenYi 2026-04-26 13:11:06 +08:00
parent 0939aa240b
commit 07e74c230c
10 changed files with 113 additions and 8 deletions

View File

@ -60,7 +60,7 @@ pub async fn admin_workspace_add_credit(
} }
let ws = service.utils_find_workspace_by_slug(slug.clone()).await?; let ws = service.utils_find_workspace_by_slug(slug.clone()).await?;
let billing = service.ensure_workspace_billing(ws.id).await?; let billing = service.ensure_workspace_billing(ws.id, None).await?;
let now_utc = Utc::now(); let now_utc = Utc::now();
let new_balance = rust_decimal::Decimal::from_f64_retain( let new_balance = rust_decimal::Decimal::from_f64_retain(

View File

@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*;
mod m20260420_000003_add_model_id_to_room_message; mod m20260420_000003_add_model_id_to_room_message;
pub mod m20260421_000001_add_agent_type_to_room_ai; pub mod m20260421_000001_add_agent_type_to_room_ai;
pub mod m20260426_000001_add_thinking_content_to_room_message;
pub async fn execute_sql(manager: &SchemaManager<'_>, sql: &str) -> Result<(), DbErr> { pub async fn execute_sql(manager: &SchemaManager<'_>, sql: &str) -> Result<(), DbErr> {
for stmt in split_sql_statements(sql) { for stmt in split_sql_statements(sql) {
@ -89,7 +90,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260420_000002_add_push_subscription::Migration), Box::new(m20260420_000002_add_push_subscription::Migration),
Box::new(m20260420_000003_add_model_id_to_room_message::Migration), Box::new(m20260420_000003_add_model_id_to_room_message::Migration),
Box::new(m20260421_000001_add_agent_type_to_room_ai::Migration), Box::new(m20260421_000001_add_agent_type_to_room_ai::Migration),
Box::new(m20260420_000003_add_model_id_to_room_message::Migration), Box::new(m20260426_000001_add_thinking_content_to_room_message::Migration),
// Repo tables // Repo tables
Box::new(m20250628_000028_create_repo::Migration), Box::new(m20250628_000028_create_repo::Migration),
Box::new(m20250628_000029_create_repo_branch::Migration), Box::new(m20250628_000029_create_repo_branch::Migration),

View File

@ -0,0 +1,30 @@
//! SeaORM migration: add thinking_content column to room_message
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20260426_000001_add_thinking_content_to_room_message"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = include_str!("sql/m20260426_000001_add_thinking_content_to_room_message.sql");
super::execute_sql(manager, sql).await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_raw(sea_orm::Statement::from_string(
sea_orm::DbBackend::Postgres,
"ALTER TABLE room_message DROP COLUMN IF EXISTS thinking_content;",
))
.await?;
Ok(())
}
}

View File

@ -0,0 +1 @@
ALTER TABLE room_message ADD COLUMN IF NOT EXISTS thinking_content TEXT;

View File

@ -19,6 +19,8 @@ pub struct Model {
pub in_reply_to: Option<MessageId>, pub in_reply_to: Option<MessageId>,
pub content: String, pub content: String,
pub content_type: MessageContentType, pub content_type: MessageContentType,
/// Accumulated AI reasoning/thinking text.
pub thinking_content: Option<String>,
pub edited_at: Option<DateTimeUtc>, pub edited_at: Option<DateTimeUtc>,
pub send_at: DateTimeUtc, pub send_at: DateTimeUtc,
pub revoked: Option<DateTimeUtc>, pub revoked: Option<DateTimeUtc>,

View File

@ -149,9 +149,24 @@ impl AppService {
} }
let now_utc = Utc::now(); let now_utc = Utc::now();
// Only first project per user gets initial budget ($10)
let initial_balance = if let Some(uid) = user_uid {
let existing_projects = models::projects::project::Entity::find()
.filter(models::projects::project::Column::CreatedBy.eq(uid))
.all(&self.db)
.await?;
if existing_projects.is_empty() {
Decimal::from_f64_retain(DEFAULT_PROJECT_MONTHLY_CREDIT).unwrap_or(Decimal::ZERO)
} else {
Decimal::ZERO
}
} else {
Decimal::ZERO
};
let created = project_billing::ActiveModel { let created = project_billing::ActiveModel {
project: Set(project_uid), project: Set(project_uid),
balance: Set(Decimal::from(DEFAULT_PROJECT_MONTHLY_CREDIT as i64)), balance: Set(initial_balance),
currency: Set("USD".to_string()), currency: Set("USD".to_string()),
user: Set(user_uid), user: Set(user_uid),
updated_at: Set(now_utc), updated_at: Set(now_utc),

View File

@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize};
use session::Session; use session::Session;
use uuid::Uuid; use uuid::Uuid;
const DEFAULT_PROJECT_INITIAL_BALANCE: f64 = 10.0;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct ProjectInitParams { pub struct ProjectInitParams {
pub name: String, pub name: String,
@ -94,9 +96,20 @@ impl AppService {
}; };
project_member.insert(&txn).await?; project_member.insert(&txn).await?;
// Only first project per user gets initial budget
let existing_projects = project::Entity::find()
.filter(project::Column::CreatedBy.eq(user.uid))
.all(&self.db)
.await?;
let initial_balance = if existing_projects.is_empty() {
Decimal::from_f64_retain(DEFAULT_PROJECT_INITIAL_BALANCE).unwrap_or(Decimal::ZERO)
} else {
Decimal::ZERO
};
let billing = project_billing::ActiveModel { let billing = project_billing::ActiveModel {
project: Set(_project.id), project: Set(_project.id),
balance: Set(Decimal::from(200i64)), balance: Set(initial_balance),
currency: Set("USD".to_string()), currency: Set("USD".to_string()),
user: Set(Some(user.uid)), user: Set(Some(user.uid)),
updated_at: Set(Utc::now()), updated_at: Set(Utc::now()),

View File

@ -78,7 +78,7 @@ impl AppService {
.await? .await?
.ok_or(AppError::NotWorkspaceMember)?; .ok_or(AppError::NotWorkspaceMember)?;
let billing = self.ensure_workspace_billing(ws.id).await?; let billing = self.ensure_workspace_billing(ws.id, Some(user_uid)).await?;
let now_utc = Utc::now(); let now_utc = Utc::now();
let (month_start, next_month_start) = utc_month_bounds(now_utc)?; let (month_start, next_month_start) = utc_month_bounds(now_utc)?;
@ -132,7 +132,7 @@ impl AppService {
let page = std::cmp::max(query.page.unwrap_or(1), 1); let page = std::cmp::max(query.page.unwrap_or(1), 1);
let per_page = query.per_page.unwrap_or(20).clamp(1, 200); let per_page = query.per_page.unwrap_or(20).clamp(1, 200);
self.ensure_workspace_billing(ws.id).await?; self.ensure_workspace_billing(ws.id, Some(user_uid)).await?;
let paginator = workspace_billing_history::Entity::find() let paginator = workspace_billing_history::Entity::find()
.filter(workspace_billing_history::Column::WorkspaceId.eq(ws.id)) .filter(workspace_billing_history::Column::WorkspaceId.eq(ws.id))
@ -186,7 +186,7 @@ impl AppService {
return Err(AppError::BadRequest("Amount must be positive".to_string())); return Err(AppError::BadRequest("Amount must be positive".to_string()));
} }
let billing = self.ensure_workspace_billing(ws.id).await?; let billing = self.ensure_workspace_billing(ws.id, Some(user_uid)).await?;
let now_utc = Utc::now(); let now_utc = Utc::now();
let new_balance = let new_balance =
Decimal::from_f64_retain(billing.balance.to_f64().unwrap_or_default() + params.amount) Decimal::from_f64_retain(billing.balance.to_f64().unwrap_or_default() + params.amount)
@ -221,6 +221,7 @@ impl AppService {
pub async fn ensure_workspace_billing( pub async fn ensure_workspace_billing(
&self, &self,
workspace_id: Uuid, workspace_id: Uuid,
user_uid: Option<Uuid>,
) -> Result<workspace_billing::Model, AppError> { ) -> Result<workspace_billing::Model, AppError> {
if let Some(billing) = workspace_billing::Entity::find_by_id(workspace_id) if let Some(billing) = workspace_billing::Entity::find_by_id(workspace_id)
.one(&self.db) .one(&self.db)
@ -230,9 +231,25 @@ impl AppService {
} }
let now_utc = Utc::now(); let now_utc = Utc::now();
// Only first workspace per user gets initial budget ($30)
let initial_balance = if let Some(uid) = user_uid {
let existing_workspaces = workspace_membership::Entity::find()
.filter(workspace_membership::Column::UserId.eq(uid))
.filter(workspace_membership::Column::Status.eq("active"))
.all(&self.db)
.await?;
if existing_workspaces.len() <= 1 {
Decimal::from_f64_retain(30.0).unwrap_or(Decimal::ZERO)
} else {
Decimal::ZERO
}
} else {
Decimal::ZERO
};
let created = workspace_billing::ActiveModel { let created = workspace_billing::ActiveModel {
workspace_id: Set(workspace_id), workspace_id: Set(workspace_id),
balance: Set(Decimal::ZERO), balance: Set(initial_balance),
currency: Set("USD".to_string()), currency: Set("USD".to_string()),
monthly_quota: Set( monthly_quota: Set(
Decimal::from_f64_retain(DEFAULT_MONTHLY_QUOTA).unwrap_or(Decimal::ZERO) Decimal::from_f64_retain(DEFAULT_MONTHLY_QUOTA).unwrap_or(Decimal::ZERO)

View File

@ -1,8 +1,10 @@
use crate::AppService; use crate::AppService;
use crate::error::AppError; use crate::error::AppError;
use chrono::Utc; use chrono::Utc;
use models::Decimal;
use models::WorkspaceRole; use models::WorkspaceRole;
use models::workspaces::workspace; use models::workspaces::workspace;
use models::workspaces::workspace_billing;
use models::workspaces::workspace_membership; use models::workspaces::workspace_membership;
use sea_orm::*; use sea_orm::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -89,6 +91,28 @@ impl AppService {
}; };
membership.insert(&txn).await?; membership.insert(&txn).await?;
// Create billing record — only first workspace gets $30 initial balance
let existing_workspaces = workspace_membership::Entity::find()
.filter(workspace_membership::Column::UserId.eq(user.uid))
.filter(workspace_membership::Column::Status.eq("active"))
.all(&self.db)
.await?;
let initial_balance = if existing_workspaces.len() <= 1 {
Decimal::from_f64_retain(30.0).unwrap_or(Decimal::ZERO)
} else {
Decimal::ZERO
};
let billing = workspace_billing::ActiveModel {
workspace_id: Set(ws.id),
balance: Set(initial_balance),
currency: Set("USD".to_string()),
monthly_quota: Set(Decimal::from_f64_retain(100.0).unwrap_or(Decimal::ZERO)),
total_spent: Set(Decimal::ZERO),
updated_at: Set(Utc::now()),
created_at: Set(Utc::now()),
};
billing.insert(&txn).await?;
txn.commit().await?; txn.commit().await?;
Ok(ws) Ok(ws)
} }

View File

@ -193,6 +193,8 @@ export interface RoomMessagePayload {
thread_id?: string; thread_id?: string;
content: string; content: string;
content_type: string; content_type: string;
/** Accumulated AI reasoning/thinking text. */
thinking_content?: string;
send_at: string; send_at: string;
seq: number; seq: number;
display_name?: string; display_name?: string;