feat: 添加 AI 索引重建功能,优化相关 API 和工作流,增强内存管理配置
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Successful in 4m43s
docker-images / build-and-push (admin) (push) Successful in 42s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has started running
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Successful in 4m43s
docker-images / build-and-push (admin) (push) Successful in 42s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has started running
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminAnalyticsResponse,
|
AdminAnalyticsResponse,
|
||||||
AdminAiImageProviderTestResponse,
|
AdminAiImageProviderTestResponse,
|
||||||
AdminAiReindexResponse,
|
|
||||||
AdminAiProviderTestResponse,
|
AdminAiProviderTestResponse,
|
||||||
AdminImageUploadResponse,
|
AdminImageUploadResponse,
|
||||||
AdminMediaBatchDeleteResponse,
|
AdminMediaBatchDeleteResponse,
|
||||||
@@ -362,7 +361,7 @@ export const adminApi = {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
reindexAi: () =>
|
reindexAi: () =>
|
||||||
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
|
request<WorkerTaskActionResponse>('/api/admin/ai/reindex', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
testAiProvider: (provider: {
|
testAiProvider: (provider: {
|
||||||
|
|||||||
@@ -545,11 +545,6 @@ export interface TaxonomyPayload {
|
|||||||
seoDescription?: string | null
|
seoDescription?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAiReindexResponse {
|
|
||||||
indexed_chunks: number
|
|
||||||
last_indexed_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminAiProviderTestResponse {
|
export interface AdminAiProviderTestResponse {
|
||||||
provider: string
|
provider: string
|
||||||
endpoint: string
|
endpoint: string
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Bot, Check, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'
|
import { Bot, Check, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||||
@@ -19,6 +20,7 @@ import type {
|
|||||||
HumanVerificationMode,
|
HumanVerificationMode,
|
||||||
MusicTrack,
|
MusicTrack,
|
||||||
SiteSettingsPayload,
|
SiteSettingsPayload,
|
||||||
|
WorkerJobRecord,
|
||||||
} from '@/lib/types'
|
} from '@/lib/types'
|
||||||
|
|
||||||
function createEmptyMusicTrack(): MusicTrack {
|
function createEmptyMusicTrack(): MusicTrack {
|
||||||
@@ -237,6 +239,8 @@ export function SiteSettingsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [reindexing, setReindexing] = useState(false)
|
const [reindexing, setReindexing] = useState(false)
|
||||||
|
const [reindexJobId, setReindexJobId] = useState<number | null>(null)
|
||||||
|
const [reindexJobStatus, setReindexJobStatus] = useState<WorkerJobRecord['status'] | null>(null)
|
||||||
const [testingProvider, setTestingProvider] = useState(false)
|
const [testingProvider, setTestingProvider] = useState(false)
|
||||||
const [testingImageProvider, setTestingImageProvider] = useState(false)
|
const [testingImageProvider, setTestingImageProvider] = useState(false)
|
||||||
const [testingR2Storage, setTestingR2Storage] = useState(false)
|
const [testingR2Storage, setTestingR2Storage] = useState(false)
|
||||||
@@ -295,6 +299,74 @@ export function SiteSettingsPage() {
|
|||||||
})
|
})
|
||||||
}, [form?.ai_active_provider_id, form?.ai_providers])
|
}, [form?.ai_active_provider_id, form?.ai_providers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reindexJobId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const scheduleNextPoll = () => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void pollJob()
|
||||||
|
}, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollJob = async () => {
|
||||||
|
try {
|
||||||
|
const job = await adminApi.getWorkerJob(reindexJobId)
|
||||||
|
if (cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setReindexJobStatus(job.status)
|
||||||
|
|
||||||
|
if (job.status === 'succeeded') {
|
||||||
|
setReindexing(false)
|
||||||
|
setReindexJobId(null)
|
||||||
|
setReindexJobStatus(null)
|
||||||
|
const indexedChunks = Number(job.result?.indexed_chunks ?? 0)
|
||||||
|
toast.success(
|
||||||
|
indexedChunks > 0
|
||||||
|
? `AI 索引重建完成,共生成 ${indexedChunks} 个分块。`
|
||||||
|
: 'AI 索引重建完成。',
|
||||||
|
)
|
||||||
|
await loadSettings(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status === 'failed' || job.status === 'cancelled') {
|
||||||
|
setReindexing(false)
|
||||||
|
setReindexJobId(null)
|
||||||
|
setReindexJobStatus(null)
|
||||||
|
toast.error(job.error_text?.trim() || 'AI 重建索引失败。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNextPoll()
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNextPoll()
|
||||||
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void pollJob()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loadSettings, reindexJobId])
|
||||||
|
|
||||||
const updateField = <K extends keyof AdminSiteSettingsResponse>(
|
const updateField = <K extends keyof AdminSiteSettingsResponse>(
|
||||||
key: K,
|
key: K,
|
||||||
value: AdminSiteSettingsResponse[K],
|
value: AdminSiteSettingsResponse[K],
|
||||||
@@ -498,25 +570,38 @@ export function SiteSettingsPage() {
|
|||||||
<RefreshCcw className="h-4 w-4" />
|
<RefreshCcw className="h-4 w-4" />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
|
{reindexJobId ? (
|
||||||
|
<Button variant="outline" asChild data-testid="site-settings-reindex-job">
|
||||||
|
<Link to={`/workers?job=${reindexJobId}`}>查看索引任务</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={reindexing}
|
disabled={reindexing}
|
||||||
data-testid="site-settings-reindex"
|
data-testid="site-settings-reindex"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
let queued = false
|
||||||
try {
|
try {
|
||||||
setReindexing(true)
|
setReindexing(true)
|
||||||
|
setReindexJobStatus('queued')
|
||||||
const result = await adminApi.reindexAi()
|
const result = await adminApi.reindexAi()
|
||||||
toast.success(`AI 索引已重建,共生成 ${result.indexed_chunks} 个分块。`)
|
queued = true
|
||||||
await loadSettings(false)
|
setReindexJobId(result.job.id)
|
||||||
|
setReindexJobStatus(result.job.status)
|
||||||
|
toast.success(`AI 重建任务已入队:#${result.job.id}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setReindexJobId(null)
|
||||||
|
setReindexJobStatus(null)
|
||||||
toast.error(error instanceof ApiError ? error.message : 'AI 重建索引失败。')
|
toast.error(error instanceof ApiError ? error.message : 'AI 重建索引失败。')
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!queued) {
|
||||||
setReindexing(false)
|
setReindexing(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
{reindexing ? '重建中...' : '重建 AI 索引'}
|
{reindexing ? '任务进行中...' : '重建 AI 索引'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -1653,6 +1738,12 @@ export function SiteSettingsPage() {
|
|||||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||||
{form.ai_last_indexed_at ?? '索引尚未建立。'}
|
{form.ai_last_indexed_at ?? '索引尚未建立。'}
|
||||||
</p>
|
</p>
|
||||||
|
{reindexJobId ? (
|
||||||
|
<p className="mt-3 text-xs leading-6 text-muted-foreground">
|
||||||
|
当前后台任务 #{reindexJobId}
|
||||||
|
{reindexJobStatus ? `,状态:${reindexJobStatus}` : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ use crate::{
|
|||||||
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users,
|
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users,
|
||||||
},
|
},
|
||||||
tasks,
|
tasks,
|
||||||
workers::{downloader::DownloadWorker, notification_delivery::NotificationDeliveryWorker},
|
workers::{
|
||||||
|
ai_reindex::AiReindexWorker, downloader::DownloadWorker,
|
||||||
|
notification_delivery::NotificationDeliveryWorker,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct App;
|
pub struct App;
|
||||||
@@ -153,6 +156,7 @@ impl Hooks for App {
|
|||||||
Ok(router.layer(cors))
|
Ok(router.layer(cors))
|
||||||
}
|
}
|
||||||
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||||
|
queue.register(AiReindexWorker::build(ctx)).await?;
|
||||||
queue.register(DownloadWorker::build(ctx)).await?;
|
queue.register(DownloadWorker::build(ctx)).await?;
|
||||||
queue
|
queue
|
||||||
.register(NotificationDeliveryWorker::build(ctx))
|
.register(NotificationDeliveryWorker::build(ctx))
|
||||||
|
|||||||
@@ -230,8 +230,8 @@ pub struct AdminSiteSettingsResponse {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct AdminAiReindexResponse {
|
pub struct AdminAiReindexResponse {
|
||||||
pub indexed_chunks: usize,
|
pub queued: bool,
|
||||||
pub last_indexed_at: Option<String>,
|
pub job: worker_jobs::WorkerJobRecord,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
@@ -1395,15 +1395,30 @@ pub async fn update_site_settings(
|
|||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn reindex_ai(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn reindex_ai(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
check_auth(&headers)?;
|
let actor = check_auth(&headers)?;
|
||||||
let summary = ai::rebuild_index(&ctx).await?;
|
let job = worker_jobs::queue_ai_reindex_job(
|
||||||
|
&ctx,
|
||||||
|
Some(actor.username.clone()),
|
||||||
|
Some(actor.source.clone()),
|
||||||
|
None,
|
||||||
|
Some("manual".to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
admin_audit::log_event(
|
||||||
|
&ctx,
|
||||||
|
Some(&actor),
|
||||||
|
"worker.ai_reindex",
|
||||||
|
"worker_job",
|
||||||
|
Some(job.id.to_string()),
|
||||||
|
Some(job.worker_name.clone()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
format::json(AdminAiReindexResponse {
|
format::json(AdminAiReindexResponse {
|
||||||
indexed_chunks: summary.indexed_chunks,
|
queued: true,
|
||||||
last_indexed_at: format_timestamp(
|
job,
|
||||||
summary.last_indexed_at.map(Into::into),
|
|
||||||
"%Y-%m-%d %H:%M:%S UTC",
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::{admin::check_auth, site_settings},
|
controllers::{admin::check_auth, site_settings},
|
||||||
services::{abuse_guard, ai, analytics},
|
services::{abuse_guard, ai, analytics, worker_jobs},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
@@ -35,8 +35,8 @@ pub struct AskResponse {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct ReindexResponse {
|
pub struct ReindexResponse {
|
||||||
pub indexed_chunks: usize,
|
pub queued: bool,
|
||||||
pub last_indexed_at: Option<String>,
|
pub job: worker_jobs::WorkerJobRecord,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -514,12 +514,19 @@ pub async fn ask_stream(
|
|||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn reindex(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn reindex(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
check_auth(&headers)?;
|
let actor = check_auth(&headers)?;
|
||||||
let summary = ai::rebuild_index(&ctx).await?;
|
let job = worker_jobs::queue_ai_reindex_job(
|
||||||
|
&ctx,
|
||||||
|
Some(actor.username.clone()),
|
||||||
|
Some(actor.source.clone()),
|
||||||
|
None,
|
||||||
|
Some("manual".to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
format::json(ReindexResponse {
|
format::json(ReindexResponse {
|
||||||
indexed_chunks: summary.indexed_chunks,
|
queued: true,
|
||||||
last_indexed_at: format_timestamp(summary.last_indexed_at),
|
job,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use loco_rs::prelude::*;
|
|||||||
use reqwest::{Client, Url, header::CONTENT_TYPE, multipart};
|
use reqwest::{Client, Url, header::CONTENT_TYPE, multipart};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
|
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
|
||||||
PaginatorTrait, QueryOrder, Set, Statement,
|
PaginatorTrait, QueryOrder, Set, Statement, TransactionTrait,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -36,6 +36,7 @@ const DEFAULT_TOP_K: usize = 4;
|
|||||||
const DEFAULT_CHUNK_SIZE: usize = 1200;
|
const DEFAULT_CHUNK_SIZE: usize = 1200;
|
||||||
const DEFAULT_SYSTEM_PROMPT: &str = "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。";
|
const DEFAULT_SYSTEM_PROMPT: &str = "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。";
|
||||||
const EMBEDDING_BATCH_SIZE: usize = 32;
|
const EMBEDDING_BATCH_SIZE: usize = 32;
|
||||||
|
const REINDEX_EMBEDDING_BATCH_SIZE: usize = 4;
|
||||||
const EMBEDDING_DIMENSION: usize = 384;
|
const EMBEDDING_DIMENSION: usize = 384;
|
||||||
const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2";
|
const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2";
|
||||||
const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2";
|
const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2";
|
||||||
@@ -771,6 +772,14 @@ pub fn default_image_model_for_provider(provider: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
|
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
|
||||||
|
embed_texts_locally_with_batch_size(inputs, kind, EMBEDDING_BATCH_SIZE).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn embed_texts_locally_with_batch_size(
|
||||||
|
inputs: Vec<String>,
|
||||||
|
kind: EmbeddingKind,
|
||||||
|
batch_size: usize,
|
||||||
|
) -> Result<Vec<Vec<f64>>> {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let model = local_embedding_engine()?;
|
let model = local_embedding_engine()?;
|
||||||
let prepared = inputs
|
let prepared = inputs
|
||||||
@@ -783,7 +792,7 @@ async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let embeddings = guard
|
let embeddings = guard
|
||||||
.embed(prepared, Some(EMBEDDING_BATCH_SIZE))
|
.embed(prepared, Some(batch_size.max(1)))
|
||||||
.map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;
|
.map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;
|
||||||
|
|
||||||
Ok(embeddings
|
Ok(embeddings
|
||||||
@@ -2555,14 +2564,14 @@ async fn load_runtime_settings(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_indexed_at(
|
async fn update_indexed_at<C: ConnectionTrait>(
|
||||||
ctx: &AppContext,
|
db: &C,
|
||||||
settings: &site_settings::Model,
|
settings: &site_settings::Model,
|
||||||
) -> Result<DateTime<Utc>> {
|
) -> Result<DateTime<Utc>> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let mut model = settings.clone().into_active_model();
|
let mut model = settings.clone().into_active_model();
|
||||||
model.ai_last_indexed_at = Set(Some(now.into()));
|
model.ai_last_indexed_at = Set(Some(now.into()));
|
||||||
let _ = model.update(&ctx.db).await?;
|
let _ = model.update(db).await?;
|
||||||
Ok(now)
|
Ok(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2571,14 +2580,8 @@ async fn retrieve_matches(
|
|||||||
settings: &AiRuntimeSettings,
|
settings: &AiRuntimeSettings,
|
||||||
question: &str,
|
question: &str,
|
||||||
) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> {
|
) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> {
|
||||||
let mut indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
|
let indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
|
||||||
let mut last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
|
let last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
|
||||||
|
|
||||||
if indexed_chunks == 0 {
|
|
||||||
let summary = rebuild_index(ctx).await?;
|
|
||||||
indexed_chunks = summary.indexed_chunks;
|
|
||||||
last_indexed_at = summary.last_indexed_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
if indexed_chunks == 0 {
|
if indexed_chunks == 0 {
|
||||||
return Ok((Vec::new(), 0, last_indexed_at));
|
return Ok((Vec::new(), 0, last_indexed_at));
|
||||||
@@ -2645,27 +2648,26 @@ pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
|
|||||||
let posts = content::load_markdown_posts_from_store(ctx).await?;
|
let posts = content::load_markdown_posts_from_store(ctx).await?;
|
||||||
let mut chunk_drafts = build_chunks(&posts, settings.chunk_size);
|
let mut chunk_drafts = build_chunks(&posts, settings.chunk_size);
|
||||||
chunk_drafts.extend(build_profile_chunks(&settings.raw, settings.chunk_size));
|
chunk_drafts.extend(build_profile_chunks(&settings.raw, settings.chunk_size));
|
||||||
let embeddings = if chunk_drafts.is_empty() {
|
let txn = ctx.db.begin().await?;
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
embed_texts_locally(
|
|
||||||
chunk_drafts
|
|
||||||
.iter()
|
|
||||||
.map(|chunk| chunk.content.clone())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
EmbeddingKind::Passage,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.db
|
txn.execute(Statement::from_string(
|
||||||
.execute(Statement::from_string(
|
|
||||||
DbBackend::Postgres,
|
DbBackend::Postgres,
|
||||||
"TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
|
"TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for (draft, embedding) in chunk_drafts.iter().zip(embeddings.into_iter()) {
|
for chunk_batch in chunk_drafts.chunks(REINDEX_EMBEDDING_BATCH_SIZE.max(1)) {
|
||||||
|
let embeddings = embed_texts_locally_with_batch_size(
|
||||||
|
chunk_batch
|
||||||
|
.iter()
|
||||||
|
.map(|chunk| chunk.content.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
EmbeddingKind::Passage,
|
||||||
|
REINDEX_EMBEDDING_BATCH_SIZE,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (draft, embedding) in chunk_batch.iter().zip(embeddings.into_iter()) {
|
||||||
let embedding_literal = vector_literal(&embedding)?;
|
let embedding_literal = vector_literal(&embedding)?;
|
||||||
let statement = Statement::from_sql_and_values(
|
let statement = Statement::from_sql_and_values(
|
||||||
DbBackend::Postgres,
|
DbBackend::Postgres,
|
||||||
@@ -2696,10 +2698,12 @@ pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
|
|||||||
draft.word_count.into(),
|
draft.word_count.into(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
ctx.db.execute(statement).await?;
|
txn.execute(statement).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let last_indexed_at = update_indexed_at(ctx, &settings.raw).await?;
|
let last_indexed_at = update_indexed_at(&txn, &settings.raw).await?;
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
Ok(AiIndexSummary {
|
Ok(AiIndexSummary {
|
||||||
indexed_chunks: chunk_drafts.len(),
|
indexed_chunks: chunk_drafts.len(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::{
|
|||||||
models::_entities::{notification_deliveries, worker_jobs},
|
models::_entities::{notification_deliveries, worker_jobs},
|
||||||
services::subscriptions,
|
services::subscriptions,
|
||||||
workers::{
|
workers::{
|
||||||
|
ai_reindex::{AiReindexWorker, AiReindexWorkerArgs},
|
||||||
downloader::{DownloadWorker, DownloadWorkerArgs},
|
downloader::{DownloadWorker, DownloadWorkerArgs},
|
||||||
notification_delivery::{NotificationDeliveryWorker, NotificationDeliveryWorkerArgs},
|
notification_delivery::{NotificationDeliveryWorker, NotificationDeliveryWorkerArgs},
|
||||||
},
|
},
|
||||||
@@ -27,6 +28,7 @@ pub const JOB_STATUS_CANCELLED: &str = "cancelled";
|
|||||||
|
|
||||||
pub const WORKER_DOWNLOAD_MEDIA: &str = "worker.download_media";
|
pub const WORKER_DOWNLOAD_MEDIA: &str = "worker.download_media";
|
||||||
pub const WORKER_NOTIFICATION_DELIVERY: &str = "worker.notification_delivery";
|
pub const WORKER_NOTIFICATION_DELIVERY: &str = "worker.notification_delivery";
|
||||||
|
pub const WORKER_AI_REINDEX: &str = "worker.ai_reindex";
|
||||||
pub const TASK_RETRY_DELIVERIES: &str = "task.retry_deliveries";
|
pub const TASK_RETRY_DELIVERIES: &str = "task.retry_deliveries";
|
||||||
pub const TASK_SEND_WEEKLY_DIGEST: &str = "task.send_weekly_digest";
|
pub const TASK_SEND_WEEKLY_DIGEST: &str = "task.send_weekly_digest";
|
||||||
pub const TASK_SEND_MONTHLY_DIGEST: &str = "task.send_monthly_digest";
|
pub const TASK_SEND_MONTHLY_DIGEST: &str = "task.send_monthly_digest";
|
||||||
@@ -164,6 +166,7 @@ fn trim_to_option(value: Option<String>) -> Option<String> {
|
|||||||
|
|
||||||
fn queue_name_for(worker_name: &str) -> Option<String> {
|
fn queue_name_for(worker_name: &str) -> Option<String> {
|
||||||
match worker_name {
|
match worker_name {
|
||||||
|
WORKER_AI_REINDEX => Some("ai".to_string()),
|
||||||
WORKER_DOWNLOAD_MEDIA => Some("media".to_string()),
|
WORKER_DOWNLOAD_MEDIA => Some("media".to_string()),
|
||||||
WORKER_NOTIFICATION_DELIVERY => Some("notifications".to_string()),
|
WORKER_NOTIFICATION_DELIVERY => Some("notifications".to_string()),
|
||||||
TASK_RETRY_DELIVERIES => Some("maintenance".to_string()),
|
TASK_RETRY_DELIVERIES => Some("maintenance".to_string()),
|
||||||
@@ -174,6 +177,7 @@ fn queue_name_for(worker_name: &str) -> Option<String> {
|
|||||||
|
|
||||||
fn label_for(worker_name: &str) -> String {
|
fn label_for(worker_name: &str) -> String {
|
||||||
match worker_name {
|
match worker_name {
|
||||||
|
WORKER_AI_REINDEX => "AI 索引重建".to_string(),
|
||||||
WORKER_DOWNLOAD_MEDIA => "远程媒体下载".to_string(),
|
WORKER_DOWNLOAD_MEDIA => "远程媒体下载".to_string(),
|
||||||
WORKER_NOTIFICATION_DELIVERY => "通知投递".to_string(),
|
WORKER_NOTIFICATION_DELIVERY => "通知投递".to_string(),
|
||||||
TASK_RETRY_DELIVERIES => "重试待投递通知".to_string(),
|
TASK_RETRY_DELIVERIES => "重试待投递通知".to_string(),
|
||||||
@@ -185,6 +189,8 @@ fn label_for(worker_name: &str) -> String {
|
|||||||
|
|
||||||
fn description_for(worker_name: &str) -> String {
|
fn description_for(worker_name: &str) -> String {
|
||||||
match worker_name {
|
match worker_name {
|
||||||
|
WORKER_AI_REINDEX => "按当前站点内容重新生成 AI 检索索引,并分批写入向量数据。"
|
||||||
|
.to_string(),
|
||||||
WORKER_DOWNLOAD_MEDIA => "抓取远程图片 / PDF 到媒体库,并回写媒体元数据。".to_string(),
|
WORKER_DOWNLOAD_MEDIA => "抓取远程图片 / PDF 到媒体库,并回写媒体元数据。".to_string(),
|
||||||
WORKER_NOTIFICATION_DELIVERY => "执行订阅通知、测试通知与 digest 投递。".to_string(),
|
WORKER_NOTIFICATION_DELIVERY => "执行订阅通知、测试通知与 digest 投递。".to_string(),
|
||||||
TASK_RETRY_DELIVERIES => "扫描 retry_pending 的通知记录并重新入队。".to_string(),
|
TASK_RETRY_DELIVERIES => "扫描 retry_pending 的通知记录并重新入队。".to_string(),
|
||||||
@@ -196,6 +202,7 @@ fn description_for(worker_name: &str) -> String {
|
|||||||
|
|
||||||
fn tags_for(worker_name: &str) -> Value {
|
fn tags_for(worker_name: &str) -> Value {
|
||||||
match worker_name {
|
match worker_name {
|
||||||
|
WORKER_AI_REINDEX => json!(["ai", "reindex"]),
|
||||||
WORKER_DOWNLOAD_MEDIA => json!(["media", "download"]),
|
WORKER_DOWNLOAD_MEDIA => json!(["media", "download"]),
|
||||||
WORKER_NOTIFICATION_DELIVERY => json!(["notifications", "delivery"]),
|
WORKER_NOTIFICATION_DELIVERY => json!(["notifications", "delivery"]),
|
||||||
TASK_RETRY_DELIVERIES => json!(["maintenance", "retry"]),
|
TASK_RETRY_DELIVERIES => json!(["maintenance", "retry"]),
|
||||||
@@ -249,6 +256,7 @@ fn to_job_record(item: worker_jobs::Model) -> WorkerJobRecord {
|
|||||||
|
|
||||||
fn catalog_entries() -> Vec<WorkerCatalogEntry> {
|
fn catalog_entries() -> Vec<WorkerCatalogEntry> {
|
||||||
[
|
[
|
||||||
|
(WORKER_AI_REINDEX, JOB_KIND_WORKER, true, true),
|
||||||
(WORKER_DOWNLOAD_MEDIA, JOB_KIND_WORKER, true, true),
|
(WORKER_DOWNLOAD_MEDIA, JOB_KIND_WORKER, true, true),
|
||||||
(WORKER_NOTIFICATION_DELIVERY, JOB_KIND_WORKER, true, true),
|
(WORKER_NOTIFICATION_DELIVERY, JOB_KIND_WORKER, true, true),
|
||||||
(TASK_RETRY_DELIVERIES, JOB_KIND_TASK, true, true),
|
(TASK_RETRY_DELIVERIES, JOB_KIND_TASK, true, true),
|
||||||
@@ -313,6 +321,13 @@ async fn dispatch_download(args_ctx: AppContext, args: DownloadWorkerArgs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn dispatch_ai_reindex(args_ctx: AppContext, args: AiReindexWorkerArgs) {
|
||||||
|
let worker = AiReindexWorker::build(&args_ctx);
|
||||||
|
if let Err(error) = worker.perform(args).await {
|
||||||
|
tracing::warn!("ai reindex worker execution failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn dispatch_notification_delivery(
|
async fn dispatch_notification_delivery(
|
||||||
args_ctx: AppContext,
|
args_ctx: AppContext,
|
||||||
args: NotificationDeliveryWorkerArgs,
|
args: NotificationDeliveryWorkerArgs,
|
||||||
@@ -340,6 +355,21 @@ async fn enqueue_download_worker(ctx: &AppContext, args: DownloadWorkerArgs) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn enqueue_ai_reindex_worker(ctx: &AppContext, args: AiReindexWorkerArgs) -> Result<()> {
|
||||||
|
match AiReindexWorker::perform_later(ctx, args.clone()).await {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(Error::QueueProviderMissing) => {
|
||||||
|
tokio::spawn(dispatch_ai_reindex(ctx.clone(), args));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("ai reindex worker queue unavailable, falling back to local task: {error}");
|
||||||
|
tokio::spawn(dispatch_ai_reindex(ctx.clone(), args));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn enqueue_notification_worker(
|
async fn enqueue_notification_worker(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
args: NotificationDeliveryWorkerArgs,
|
args: NotificationDeliveryWorkerArgs,
|
||||||
@@ -717,6 +747,46 @@ pub async fn queue_notification_delivery_job(
|
|||||||
get_job_record(ctx, job.id).await
|
get_job_record(ctx, job.id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn queue_ai_reindex_job(
|
||||||
|
ctx: &AppContext,
|
||||||
|
requested_by: Option<String>,
|
||||||
|
requested_source: Option<String>,
|
||||||
|
parent_job_id: Option<i32>,
|
||||||
|
trigger_mode: Option<String>,
|
||||||
|
) -> Result<WorkerJobRecord> {
|
||||||
|
let base_args = AiReindexWorkerArgs { job_id: None };
|
||||||
|
let payload = serde_json::to_value(&base_args)?;
|
||||||
|
|
||||||
|
let job = create_job(
|
||||||
|
ctx,
|
||||||
|
CreateWorkerJobInput {
|
||||||
|
parent_job_id,
|
||||||
|
job_kind: JOB_KIND_WORKER.to_string(),
|
||||||
|
worker_name: WORKER_AI_REINDEX.to_string(),
|
||||||
|
display_name: Some("重建 AI 索引".to_string()),
|
||||||
|
queue_name: queue_name_for(WORKER_AI_REINDEX),
|
||||||
|
requested_by,
|
||||||
|
requested_source,
|
||||||
|
trigger_mode,
|
||||||
|
payload: Some(payload),
|
||||||
|
tags: Some(tags_for(WORKER_AI_REINDEX)),
|
||||||
|
related_entity_type: Some("ai_index".to_string()),
|
||||||
|
related_entity_id: Some("site".to_string()),
|
||||||
|
max_attempts: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
enqueue_ai_reindex_worker(
|
||||||
|
ctx,
|
||||||
|
AiReindexWorkerArgs {
|
||||||
|
job_id: Some(job.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
get_job_record(ctx, job.id).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn spawn_retry_deliveries_task(
|
pub async fn spawn_retry_deliveries_task(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
limit: Option<u64>,
|
limit: Option<u64>,
|
||||||
@@ -810,6 +880,17 @@ pub async fn retry_job(
|
|||||||
let payload = item.payload.clone().unwrap_or(Value::Null);
|
let payload = item.payload.clone().unwrap_or(Value::Null);
|
||||||
|
|
||||||
match item.worker_name.as_str() {
|
match item.worker_name.as_str() {
|
||||||
|
WORKER_AI_REINDEX => {
|
||||||
|
let _ = serde_json::from_value::<AiReindexWorkerArgs>(payload)?;
|
||||||
|
queue_ai_reindex_job(
|
||||||
|
ctx,
|
||||||
|
requested_by,
|
||||||
|
requested_source,
|
||||||
|
Some(item.id),
|
||||||
|
Some("retry".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
WORKER_DOWNLOAD_MEDIA => {
|
WORKER_DOWNLOAD_MEDIA => {
|
||||||
let args = serde_json::from_value::<DownloadWorkerArgs>(payload)?;
|
let args = serde_json::from_value::<DownloadWorkerArgs>(payload)?;
|
||||||
queue_download_job(
|
queue_download_job(
|
||||||
|
|||||||
55
backend/src/workers/ai_reindex.rs
Normal file
55
backend/src/workers/ai_reindex.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::services::{ai, worker_jobs};
|
||||||
|
|
||||||
|
pub struct AiReindexWorker {
|
||||||
|
pub ctx: AppContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct AiReindexWorkerArgs {
|
||||||
|
#[serde(default)]
|
||||||
|
pub job_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackgroundWorker<AiReindexWorkerArgs> for AiReindexWorker {
|
||||||
|
fn build(ctx: &AppContext) -> Self {
|
||||||
|
Self { ctx: ctx.clone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags() -> Vec<String> {
|
||||||
|
vec!["ai".to_string(), "reindex".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn perform(&self, args: AiReindexWorkerArgs) -> Result<()> {
|
||||||
|
if let Some(job_id) = args.job_id {
|
||||||
|
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match ai::rebuild_index(&self.ctx).await {
|
||||||
|
Ok(summary) => {
|
||||||
|
worker_jobs::mark_job_succeeded(
|
||||||
|
&self.ctx,
|
||||||
|
job_id,
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"indexed_chunks": summary.indexed_chunks,
|
||||||
|
"last_indexed_at": summary.last_indexed_at.map(|value| value.to_rfc3339()),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ai::rebuild_index(&self.ctx).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
pub mod ai_reindex;
|
||||||
pub mod downloader;
|
pub mod downloader;
|
||||||
pub mod notification_delivery;
|
pub mod notification_delivery;
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ BACKEND_PORT=5150
|
|||||||
FRONTEND_PORT=4321
|
FRONTEND_PORT=4321
|
||||||
ADMIN_PORT=4322
|
ADMIN_PORT=4322
|
||||||
|
|
||||||
|
# 建议在小内存主机上给每个服务设置明确上限,避免 backend 在 AI 重建索引时
|
||||||
|
# 把整台主机拖进 swap 抖动。默认值与 compose.package.yml 保持一致。
|
||||||
|
BACKEND_MEMORY_LIMIT=768m
|
||||||
|
BACKEND_MEMORY_SWAP_LIMIT=768m
|
||||||
|
BACKEND_WORKER_MEMORY_LIMIT=512m
|
||||||
|
BACKEND_WORKER_MEMORY_SWAP_LIMIT=512m
|
||||||
|
FRONTEND_MEMORY_LIMIT=256m
|
||||||
|
FRONTEND_MEMORY_SWAP_LIMIT=256m
|
||||||
|
ADMIN_MEMORY_LIMIT=128m
|
||||||
|
ADMIN_MEMORY_SWAP_LIMIT=128m
|
||||||
|
|
||||||
# frontend SSR 服务端访问 backend 用这个内部地址(compose 默认可直接使用)
|
# frontend SSR 服务端访问 backend 用这个内部地址(compose 默认可直接使用)
|
||||||
INTERNAL_API_BASE_URL=http://backend:5150/api
|
INTERNAL_API_BASE_URL=http://backend:5150/api
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ python deploy/scripts/render_compose_env.py \
|
|||||||
|
|
||||||
建议在 `config.yaml -> compose_env` 下同时检查这些运行时变量:
|
建议在 `config.yaml -> compose_env` 下同时检查这些运行时变量:
|
||||||
|
|
||||||
|
- `BACKEND_MEMORY_LIMIT / BACKEND_MEMORY_SWAP_LIMIT`:backend 容器内存 / swap 上限;对小内存主机建议显式设置
|
||||||
|
- `BACKEND_WORKER_MEMORY_LIMIT / BACKEND_WORKER_MEMORY_SWAP_LIMIT`:worker 容器内存 / swap 上限
|
||||||
|
- `FRONTEND_MEMORY_LIMIT / FRONTEND_MEMORY_SWAP_LIMIT`:frontend 容器内存 / swap 上限
|
||||||
|
- `ADMIN_MEMORY_LIMIT / ADMIN_MEMORY_SWAP_LIMIT`:admin 容器内存 / swap 上限
|
||||||
- `INTERNAL_API_BASE_URL`:frontend SSR 容器访问 backend 用,compose 默认推荐 `http://backend:5150/api`
|
- `INTERNAL_API_BASE_URL`:frontend SSR 容器访问 backend 用,compose 默认推荐 `http://backend:5150/api`
|
||||||
- `PUBLIC_API_BASE_URL`:浏览器访问 backend API 用;留空时前台会回退到“当前主机 + `:5150/api`”
|
- `PUBLIC_API_BASE_URL`:浏览器访问 backend API 用;留空时前台会回退到“当前主机 + `:5150/api`”
|
||||||
- `PUBLIC_COMMENT_TURNSTILE_SITE_KEY`:前台评论 / 订阅表单使用的 Cloudflare Turnstile site key
|
- `PUBLIC_COMMENT_TURNSTILE_SITE_KEY`:前台评论 / 订阅表单使用的 Cloudflare Turnstile site key
|
||||||
@@ -62,6 +66,14 @@ python deploy/scripts/render_compose_env.py \
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
compose_env:
|
compose_env:
|
||||||
|
BACKEND_MEMORY_LIMIT: 768m
|
||||||
|
BACKEND_MEMORY_SWAP_LIMIT: 768m
|
||||||
|
BACKEND_WORKER_MEMORY_LIMIT: 512m
|
||||||
|
BACKEND_WORKER_MEMORY_SWAP_LIMIT: 512m
|
||||||
|
FRONTEND_MEMORY_LIMIT: 256m
|
||||||
|
FRONTEND_MEMORY_SWAP_LIMIT: 256m
|
||||||
|
ADMIN_MEMORY_LIMIT: 128m
|
||||||
|
ADMIN_MEMORY_SWAP_LIMIT: 128m
|
||||||
PUBLIC_API_BASE_URL: https://api.blog.init.cool
|
PUBLIC_API_BASE_URL: https://api.blog.init.cool
|
||||||
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||||
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: replace-with-web-push-vapid-public-key
|
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: replace-with-web-push-vapid-public-key
|
||||||
@@ -178,6 +190,7 @@ A:
|
|||||||
A:
|
A:
|
||||||
- `backend` 镜像启动时会先执行 `db migrate`
|
- `backend` 镜像启动时会先执行 `db migrate`
|
||||||
- `backend` 提供 `/healthz`
|
- `backend` 提供 `/healthz`
|
||||||
|
- `backend-worker` 不提供 HTTP `/healthz`;compose 会覆盖镜像默认 healthcheck,改为检查主进程是否仍以 `--worker` 模式运行
|
||||||
- `frontend` 提供 `/healthz`
|
- `frontend` 提供 `/healthz`
|
||||||
- `admin` 继续由 Nginx 提供 `/healthz`
|
- `admin` 继续由 Nginx 提供 `/healthz`
|
||||||
- compose 现在使用 `depends_on.condition: service_healthy`
|
- compose 现在使用 `depends_on.condition: service_healthy`
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ services:
|
|||||||
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
|
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# 对 tohka 这类小内存主机,建议给服务设置明确上限,
|
||||||
|
# 避免 AI 重建索引时把整机拖进 swap 抖动 / OOM。
|
||||||
|
mem_limit: ${BACKEND_MEMORY_LIMIT:-768m}
|
||||||
|
memswap_limit: ${BACKEND_MEMORY_SWAP_LIMIT:-768m}
|
||||||
environment:
|
environment:
|
||||||
PORT: 5150
|
PORT: 5150
|
||||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5150}
|
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5150}
|
||||||
@@ -30,6 +34,8 @@ services:
|
|||||||
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
|
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: ${BACKEND_WORKER_MEMORY_LIMIT:-512m}
|
||||||
|
memswap_limit: ${BACKEND_WORKER_MEMORY_SWAP_LIMIT:-512m}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -48,11 +54,22 @@ services:
|
|||||||
TERMI_WEB_PUSH_VAPID_SUBJECT: ${TERMI_WEB_PUSH_VAPID_SUBJECT:-}
|
TERMI_WEB_PUSH_VAPID_SUBJECT: ${TERMI_WEB_PUSH_VAPID_SUBJECT:-}
|
||||||
RUST_LOG: ${RUST_LOG:-info}
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
TERMI_SKIP_MIGRATIONS: 'true'
|
TERMI_SKIP_MIGRATIONS: 'true'
|
||||||
|
# backend 镜像默认 healthcheck 会探测 HTTP /healthz,
|
||||||
|
# 但 worker 模式不监听 5150,所以这里改成“主进程仍然是 --worker”检查。
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
['CMD-SHELL', "test -r /proc/1/cmdline && tr '\\000' ' ' </proc/1/cmdline | grep -q -- '--worker'"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 15s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: ${FRONTEND_IMAGE:-git.init.cool/cool/termi-astro-frontend:latest}
|
image: ${FRONTEND_IMAGE:-git.init.cool/cool/termi-astro-frontend:latest}
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: ${FRONTEND_MEMORY_LIMIT:-256m}
|
||||||
|
memswap_limit: ${FRONTEND_MEMORY_SWAP_LIMIT:-256m}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -78,6 +95,8 @@ services:
|
|||||||
image: ${ADMIN_IMAGE:-git.init.cool/cool/termi-astro-admin:latest}
|
image: ${ADMIN_IMAGE:-git.init.cool/cool/termi-astro-admin:latest}
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: ${ADMIN_MEMORY_LIMIT:-128m}
|
||||||
|
memswap_limit: ${ADMIN_MEMORY_SWAP_LIMIT:-128m}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ compose_env:
|
|||||||
BACKEND_PORT: 5150
|
BACKEND_PORT: 5150
|
||||||
FRONTEND_PORT: 4321
|
FRONTEND_PORT: 4321
|
||||||
ADMIN_PORT: 4322
|
ADMIN_PORT: 4322
|
||||||
|
BACKEND_MEMORY_LIMIT: 768m
|
||||||
|
BACKEND_MEMORY_SWAP_LIMIT: 768m
|
||||||
|
BACKEND_WORKER_MEMORY_LIMIT: 512m
|
||||||
|
BACKEND_WORKER_MEMORY_SWAP_LIMIT: 512m
|
||||||
|
FRONTEND_MEMORY_LIMIT: 256m
|
||||||
|
FRONTEND_MEMORY_SWAP_LIMIT: 256m
|
||||||
|
ADMIN_MEMORY_LIMIT: 128m
|
||||||
|
ADMIN_MEMORY_SWAP_LIMIT: 128m
|
||||||
|
|
||||||
APP_BASE_URL: https://admin.blog.init.cool
|
APP_BASE_URL: https://admin.blog.init.cool
|
||||||
INTERNAL_API_BASE_URL: http://backend:5150/api
|
INTERNAL_API_BASE_URL: http://backend:5150/api
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ function getServer() {
|
|||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const data = await requestBackend('POST', '/ai/reindex');
|
const data = await requestBackend('POST', '/ai/reindex');
|
||||||
return createToolResult('AI index rebuilt', data);
|
return createToolResult('AI index rebuild job queued', data);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user