Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ docker compose --profile app up -d

Open http://localhost:1516 and register. The first account automatically becomes the administrator, and a public knowledge base readable by everyone is created at the same time. Before extracting business documents, configure the model endpoints (chat and embedding) under Administration → Models.

To read scans and images with Ark, see the [OCR setup, input limits and recovery guide](docs/design/ark-ocr.md).

The database password (`UTOPIA_DB_PASSWORD` in `.env`, default `utopia`) is applied when the data volume is first initialised. To change it on a running deployment, change it in the database as well — `docker compose exec db psql -U utopia -c "ALTER USER utopia PASSWORD '<new password>'"` — or start over with `docker compose --profile app down -v`, which deletes all data.

Or build from source:
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ docker compose --profile app up -d

打开 http://localhost:1516 注册 —— 第一个账户自动成为管理员,同时系统会创建所有人可读的公共知识库。抽取业务文档前,请先在「管理 → 模型」里配置模型端点(chat 与 embedding)。

使用方舟识别扫描件与图片,请参阅 [OCR 配置、输入限制和重试说明](docs/design/ark-ocr.md)。

数据库口令(`.env` 里的 `UTOPIA_DB_PASSWORD`,默认 `utopia`)在数据卷首次初始化时写入。已经跑起来的部署要改口令,得同时改库里那份——`docker compose exec db psql -U utopia -c "ALTER USER utopia PASSWORD '<新口令>'"`——或者 `docker compose --profile app down -v` 从头来过(会删掉全部数据)。

或者从源码构建:
Expand Down
2 changes: 1 addition & 1 deletion crates/utopia-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ struct ManifestDataDir {
/// not a side effect of a code change.
// 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current`
// 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71
const CURRENT_SCHEMA_VERSION: u32 = 91;
const CURRENT_SCHEMA_VERSION: u32 = 92;

fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
Expand Down
11 changes: 11 additions & 0 deletions crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,9 @@ pub struct LlmSettings {
#[serde(skip_serializing)]
pub ocr_api_key: Option<String>,
pub ocr_backend: Option<String>,
/// 协议由供应商决定,不能拿模型名猜;预留值只有在读取器实现后才就绪(0065)。
pub ocr_provider: String,
pub ocr_model: Option<String>,
/// 会标说话人的转写模型(OpenAI `/audio/transcriptions` + `diarized_json`,0040)
pub transcribe_base_url: Option<String>,
#[serde(skip_serializing)]
Expand All @@ -481,6 +484,14 @@ impl LlmSettings {
}
pub fn ocr_ready(&self) -> bool {
self.ocr_base_url.is_some()
&& match self.ocr_provider.as_str() {
"mineru" => true,
"ark" => self
.ocr_model
.as_deref()
.is_some_and(|model| !model.trim().is_empty()),
_ => false,
}
}
pub fn transcribe_ready(&self) -> bool {
self.transcribe_base_url.is_some() && self.transcribe_model.is_some()
Expand Down
2 changes: 1 addition & 1 deletion crates/utopia-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ tracing.workspace = true
tracing-subscriber.workspace = true
flate2.workspace = true
reqwest.workspace = true
tempfile.workspace = true

feed-rs.workspace = true
async-trait = "0.1.92"
Expand All @@ -55,4 +56,3 @@ rig-core = { version = "0.42", default-features = false }
wiremock = "0.6.5"
# 静态托管那一段的单测:不连库地把请求推进 Router
tower = { version = "0.5", features = ["util"] }
tempfile.workspace = true
167 changes: 126 additions & 41 deletions crates/utopia-server/src/api/settings_routes.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use axum::extract::{Path, State};
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::json;
Expand Down Expand Up @@ -32,6 +32,8 @@ pub async fn get(
"has_embed_key": s.embed_api_key.as_deref().is_some_and(|k| !k.is_empty()),
"ocr_base_url": s.ocr_base_url,
"ocr_backend": s.ocr_backend,
"ocr_provider": s.ocr_provider,
"ocr_model": s.ocr_model,
"has_ocr_key": s.ocr_api_key.as_deref().is_some_and(|k| !k.is_empty()),
"transcribe_base_url": s.transcribe_base_url,
"transcribe_model": s.transcribe_model,
Expand Down Expand Up @@ -126,6 +128,10 @@ pub struct PutOcrReq {
/// None 或空串 = 保留旧密钥
pub api_key: Option<String>,
pub backend: Option<String>,
/// 缺席 = 保留当前协议;只接受已实现的供应商。
pub provider: Option<String>,
/// 缺席 = 保留;空串 = 清空;切换协议时不保留原模型。
pub model: Option<String>,
}

/// 版面识别服务(0040 第二刀)。单独一个接口:管理页上它是自己的一张卡片,存它不该把
Expand All @@ -147,16 +153,44 @@ pub async fn put_ocr(
.map(String::from)
};
let base_url = nonempty(&req.base_url);
utopia_store::settings::upsert_ocr(
&state.pool,
let provider = match req.provider.as_deref().map(str::trim) {
Some("ark") => Some("ark"),
_ => implemented_provider(req.provider.as_deref(), "mineru")?,
};
let mut tx = state.pool.begin().await?;
let saved = utopia_store::settings::upsert_ocr_tx(
&mut tx,
workspace_id,
base_url.as_deref(),
nonempty(&req.api_key).as_deref(),
nonempty(&req.backend).as_deref(),
provider,
req.model.as_deref().map(str::trim),
)
.await?;
// 省略 provider 的旧请求也校验实际保存的协议。事务内决定、校验、提交,
// 不先读供应商再写,避免并发保存之间换了协议仍套用旧规则。
if saved.ocr_provider == "ark" {
crate::readers::ark_ocr::ArkOcr::new(
saved
.ocr_base_url
.as_deref()
.unwrap_or("https://ocr-disabled.invalid"),
saved.ocr_api_key.as_deref(),
saved
.ocr_model
.as_deref()
.filter(|model| !model.is_empty())
.unwrap_or("unconfigured"),
)
.validate()
.map_err(|e| {
utopia_core::AppError::invalid("bad_ocr_config", e.root_cause().to_string())
})?;
}
tx.commit().await?;
let mut requeued = 0usize;
if base_url.is_some() {
if saved.ocr_ready() {
let docs =
utopia_store::documents::requeue_waiting_for_reader(&state.pool, workspace_id, "ocr")
.await?;
Expand Down Expand Up @@ -217,61 +251,108 @@ pub async fn put_transcribe(
Ok(Json(json!({ "ok": true, "requeued": requeued })))
}

fn implemented_provider<'a>(
requested: Option<&'a str>,
implemented: &str,
) -> utopia_core::AppResult<Option<&'a str>> {
let provider = requested.map(str::trim);
if provider.is_some_and(|provider| provider != implemented) {
return Err(utopia_core::AppError::invalid(
"unsupported_reader_provider",
"The reader provider is not supported by this server",
));
}
Ok(provider)
}

/// 连通性测试:对话发一条最小消息;embedding 试算一条并返回维度;版面识别服务问一声健康。
#[derive(Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TestScope {
Chat,
Embed,
Ocr,
Transcribe,
}

#[derive(Default, Deserialize)]
pub struct TestSettingsQuery {
pub scope: Option<TestScope>,
}

pub async fn test(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(workspace_id): Path<Uuid>,
Query(query): Query<TestSettingsQuery>,
) -> ApiResult<Json<serde_json::Value>> {
utopia_store::workspaces::require_role(&state.pool, user.id, workspace_id, Role::Admin).await?;
let selected = |scope| query.scope.is_none_or(|requested| requested == scope);
let Some(s) = utopia_store::settings::get(&state.pool, workspace_id).await? else {
return Ok(Json(
json!({ "chat": { "ok": false, "error": "Not configured" },
"embed": { "ok": false, "error": "Not configured" },
"ocr": { "ok": false, "error": "Not configured" },
"transcribe": { "ok": false, "error": "Not configured" } }),
));
let missing =
|scope| selected(scope).then(|| json!({ "ok": false, "error": "Not configured" }));
return Ok(Json(json!({
"chat": missing(TestScope::Chat), "embed": missing(TestScope::Embed),
"ocr": missing(TestScope::Ocr), "transcribe": missing(TestScope::Transcribe),
})));
};

let chat_result = match llm_util::chat_client(&s) {
None => json!({ "ok": false, "error": "Not configured" }),
Some(client) => {
let msg = [ChatMessage {
role: "user".into(),
content: "Reply with exactly one word: OK".into(),
}];
match client.chat(&msg).await {
Ok(reply) => {
json!({ "ok": true, "reply": reply.chars().take(50).collect::<String>() })
// 老客户端省略 scope 仍测试全部;界面逐卡测试,避免无关的付费模型调用。
let chat_result = if selected(TestScope::Chat) {
Some(match llm_util::chat_client(&s) {
None => json!({ "ok": false, "error": "Not configured" }),
Some(client) => {
let msg = [ChatMessage {
role: "user".into(),
content: "Reply with exactly one word: OK".into(),
}];
match client.chat(&msg).await {
Ok(reply) => {
json!({ "ok": true, "reply": reply.chars().take(50).collect::<String>() })
}
Err(e) => json!({ "ok": false, "error": e.to_string() }),
}
Err(e) => json!({ "ok": false, "error": e.to_string() }),
}
}
})
} else {
None
};

let embed_result = match llm_util::embed_client(&s) {
None => json!({ "ok": false, "error": "Not configured" }),
Some(client) => match client.embed(&["connectivity test".to_string()]).await {
Ok(v) if !v.is_empty() => json!({ "ok": true, "dim": v[0].len() }),
Ok(_) => json!({ "ok": false, "error": "Empty response" }),
Err(e) => json!({ "ok": false, "error": e.to_string() }),
},
let embed_result = if selected(TestScope::Embed) {
Some(match llm_util::embed_client(&s) {
None => json!({ "ok": false, "error": "Not configured" }),
Some(client) => match client.embed(&["connectivity test".to_string()]).await {
Ok(v) if !v.is_empty() => json!({ "ok": true, "dim": v[0].len() }),
Ok(_) => json!({ "ok": false, "error": "Empty response" }),
Err(e) => json!({ "ok": false, "error": e.to_string() }),
},
})
} else {
None
};

let ocr_result = match crate::readers::Ocr::from_settings(&s) {
None => json!({ "ok": false, "error": "Not configured" }),
Some(ocr) => match ocr.health().await {
Ok(h) => json!({ "ok": true, "version": h["version"] }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
let ocr_result = if selected(TestScope::Ocr) {
Some(match crate::readers::Ocr::from_settings(&s) {
None => json!({ "ok": false, "error": "Not configured" }),
Some(ocr) => match ocr.health().await {
Ok(h) => json!({ "ok": true, "version": h["version"] }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
})
} else {
None
};

let transcribe_result = match crate::readers::Transcriber::from_settings(&s) {
None => json!({ "ok": false, "error": "Not configured" }),
Some(t) => match t.check().await {
Ok(()) => json!({ "ok": true }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
let transcribe_result = if selected(TestScope::Transcribe) {
Some(match crate::readers::Transcriber::from_settings(&s) {
None => json!({ "ok": false, "error": "Not configured" }),
Some(t) => match t.check().await {
Ok(()) => json!({ "ok": true }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
})
} else {
None
};

Ok(Json(json!({
Expand All @@ -281,3 +362,7 @@ pub async fn test(
"transcribe": transcribe_result,
})))
}

#[cfg(test)]
#[path = "settings_routes_tests.rs"]
mod tests;
Loading
Loading