Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9118583ff | |||
| 24f56ac53e | |||
| ce3dc9e4f4 | |||
| d8011e2959 | |||
| 36a69c265f | |||
| c177e0ce77 | |||
| f1265d863a | |||
| d956b3f9f4 | |||
| bf16139695 | |||
| bd129fc160 | |||
| cb5c0febbd |
@@ -40,3 +40,7 @@ desktop/target/
|
|||||||
desktop/src-tauri/target/
|
desktop/src-tauri/target/
|
||||||
desktop/src-tauri/gen/schemas/
|
desktop/src-tauri/gen/schemas/
|
||||||
desktop/website/node_modules/
|
desktop/website/node_modules/
|
||||||
|
|
||||||
|
# Root uses pnpm (pnpm-lock.yaml); ignore stray npm lockfile
|
||||||
|
package-lock.json
|
||||||
|
package-lock.json
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ ComfyUI Agent 默认走 **本地模式**:复用本服务自身的 `/v1/*` 路
|
|||||||
- **配置**:`[jimeng]` 段,AK/SK 走 env(`BOOSAPI_JIMENG__ACCESS_KEY` / `__SECRET_KEY` / 可选 `__SESSION_TOKEN`),不硬编码;`enabled=false` 时路由返回 503「能力未启用」。
|
- **配置**:`[jimeng]` 段,AK/SK 走 env(`BOOSAPI_JIMENG__ACCESS_KEY` / `__SECRET_KEY` / 可选 `__SESSION_TOKEN`),不硬编码;`enabled=false` 时路由返回 503「能力未启用」。
|
||||||
- **首版范围**:图生视频只支持 `image_urls`(公网图 URL),前端上传 → 转存留二期;`frames ∈ {121(5s), 241(10s)}`,`aspect_ratio` 白名单。
|
- **首版范围**:图生视频只支持 `image_urls`(公网图 URL),前端上传 → 转存留二期;`frames ∈ {121(5s), 241(10s)}`,`aspect_ratio` 白名单。
|
||||||
|
|
||||||
|
### 即梦在「站点管理 / 连接管理」里的特殊处理(platform=`jimeng`)
|
||||||
|
|
||||||
|
即梦作为一个 `platform = "jimeng"` 录进现有 `sites` + `accounts` 体系(复用 CRUD / 列表 / 状态 UI),但因为它**不是 OpenAI 兼容协议**(火山 V4 HMAC 签名、异步两步生成、无 `/models` 端点),所有 OpenAI 专属路径都为它开了**干净的分支**,绝不污染其它平台:
|
||||||
|
|
||||||
|
- **平台判定**:`routes::admin::accounts::is_jimeng_platform(&site.platform)`(大小写不敏感)是唯一事实源;站点 `platform` 字段为自由字符串,无白名单,前端下拉新增 `jimeng` 选项即可。
|
||||||
|
- **AK/SK 存法**:AK 落 `accounts.access_token`;SK + region/host/session_token 收进 `extra_config.jimeng.{secretKey,region,host,sessionToken}` JSON。`credentialMode` 强制 `apikey`(只在「API Key 管理」分段出现,永不进 session 分段)。**SK / sessionToken 是机密**:列表 / get / update 响应一律用 `mask_account_extra_config` 替换为 `••••`;带掩码的 `extra_config` 回写会被 `update_account` 拒绝(留空即保留原值)。
|
||||||
|
- **验证(签名探测)**:`verify_token` 检测到 jimeng 平台时走 `verify_jimeng_token` → `JimengProvider::from_credentials(ak, sk, region, host, session).verify()`,对 GetResult 发一个哨兵 `task_id` 的最小签名请求。火山网关在业务层之前校验 V4 签名:签名通过 → 业务层报「任务不存在」(即 `Authenticated`);签名失败 → 网关返回鉴权类 `Code`(`classify_verify_response` 按 `is_auth_error_code` 判定 `AuthFailed`)。**绝不调 `/models`**,绝不触发生成。
|
||||||
|
- **不进路由表**:`create_account` 检测 jimeng 后走 `create_jimeng_account`,单条插入、**不** `spawn_route_rebuild`;前端 `handleTokenAdd` 对 jimeng 也跳过 `checkModels` / `rebuildRoutes`。
|
||||||
|
- **站点 probe 短路**:`build_probe_plan`(probe-now / probe-stream 共用入口)对 jimeng 直接 `BadRequest`,提示改用连接管理的「验证」做签名连通性检查(逐模型 chat 探测对它无意义)。
|
||||||
|
- **运行健康来源**:jimeng 不走 OpenAI 代理通道(独立 `/api/jimeng/*`),`proxy_logs` 无记录,否则永远 `unknown`。改由 `derive_jimeng_runtime_health` 从最近一条 `background_tasks`(`jimeng-image/video-generation`)派生平台级健康(succeeded→healthy / failed→unhealthy / running·pending→进行中 / 无任务→「凭证验证通过后即可使用」),`account_response_value_with_jimeng` 在站点为 jimeng 时用它覆盖 proxy-log 派生值;其它平台传入的 jimeng 健康一律忽略(回归零影响,有断言测试守护)。
|
||||||
|
- **前端表单**:`Sites.tsx` 选 jimeng 时预填 URL `https://visual.volcengineapi.com`,隐藏「API 请求地址池」与「刷新后自动探测」;`Accounts.tsx` 在 API Key 分段对 jimeng 改成 AK + SK(必填)+ region/host/session(选填),点「验证 AK/SK 签名」做签名探测,校验通过才能添加。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Agent Workbench conversation history.
|
||||||
|
--
|
||||||
|
-- Adds a `transcript` column holding the full multi-turn message history of an
|
||||||
|
-- agent session as a JSON array (the SDK's `Vec<Message>`, serde-serialized).
|
||||||
|
-- Each `/api/agent/chat` round-trip loads this transcript into the agent before
|
||||||
|
-- the turn and writes the updated transcript back when the turn completes, so a
|
||||||
|
-- session resumed by id sees everything that happened in prior turns. NULL/empty
|
||||||
|
-- means a fresh session with no history yet.
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN transcript TEXT;
|
||||||
Generated
+9448
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,19 @@ pub struct CreateAccountRequest {
|
|||||||
pub refresh_token: Option<String>,
|
pub refresh_token: Option<String>,
|
||||||
#[serde(alias = "tokenExpiresAt")]
|
#[serde(alias = "tokenExpiresAt")]
|
||||||
pub token_expires_at: Option<i64>,
|
pub token_expires_at: Option<i64>,
|
||||||
|
/// Jimeng only: the Volcengine secret key (SK), paired with `access_token`
|
||||||
|
/// = AK. Stored (server-side only) in `extra_config.jimeng.secretKey`.
|
||||||
|
#[serde(default, alias = "secretKey")]
|
||||||
|
pub secret_key: Option<String>,
|
||||||
|
/// Jimeng only: signing region override.
|
||||||
|
#[serde(default)]
|
||||||
|
pub region: Option<String>,
|
||||||
|
/// Jimeng only: API host override.
|
||||||
|
#[serde(default)]
|
||||||
|
pub host: Option<String>,
|
||||||
|
/// Jimeng only: optional STS session token.
|
||||||
|
#[serde(default, alias = "sessionToken")]
|
||||||
|
pub session_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -88,8 +101,39 @@ pub struct VerifyTokenRequest {
|
|||||||
pub platform_user_id: Option<i64>,
|
pub platform_user_id: Option<i64>,
|
||||||
#[serde(alias = "credentialMode")]
|
#[serde(alias = "credentialMode")]
|
||||||
pub credential_mode: Option<String>,
|
pub credential_mode: Option<String>,
|
||||||
|
/// Jimeng only: the Volcengine secret key (paired with `access_token` = AK).
|
||||||
|
#[serde(default, alias = "secretKey")]
|
||||||
|
pub secret_key: Option<String>,
|
||||||
|
/// Jimeng only: signing region override (defaults to `cn-north-1`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub region: Option<String>,
|
||||||
|
/// Jimeng only: API host override (defaults to `visual.volcengineapi.com`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub host: Option<String>,
|
||||||
|
/// Jimeng only: optional STS session token.
|
||||||
|
#[serde(default, alias = "sessionToken")]
|
||||||
|
pub session_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `platform` value under which Jimeng connections are recorded in the
|
||||||
|
/// existing `sites`/`accounts` tables. Jimeng is *not* an OpenAI-compatible
|
||||||
|
/// provider (Volcengine V4 HMAC signing, async two-step generation, no
|
||||||
|
/// `/models` endpoint), so the OpenAI-specific code paths (verify-token model
|
||||||
|
/// probe, per-model site probe, proxy-log runtime health, route rebuild) branch
|
||||||
|
/// on this constant and take a dedicated path for Jimeng, leaving every other
|
||||||
|
/// platform's behaviour untouched.
|
||||||
|
pub const JIMENG_PLATFORM: &str = "jimeng";
|
||||||
|
|
||||||
|
/// True when `platform` denotes a Jimeng connection (case-insensitive).
|
||||||
|
pub fn is_jimeng_platform(platform: &str) -> bool {
|
||||||
|
platform.trim().eq_ignore_ascii_case(JIMENG_PLATFORM)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentinel returned (and accepted) in place of a stored Jimeng secret so the
|
||||||
|
/// raw secret key / session token are never echoed back to the browser. Mirrors
|
||||||
|
/// the Pixelle connection mask convention.
|
||||||
|
pub const JIMENG_SECRET_MASK: &str = "••••";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RefreshAccountHealthRequest {
|
pub struct RefreshAccountHealthRequest {
|
||||||
@@ -232,11 +276,29 @@ pub async fn list_accounts(
|
|||||||
)
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Jimeng accounts don't appear in proxy_logs (they use the dedicated
|
||||||
|
// /api/jimeng/* job pipeline), so derive a platform-level health signal
|
||||||
|
// from the most recent jimeng background_tasks row. Computed once and shared
|
||||||
|
// by every jimeng account row. `None` when no jimeng account is listed.
|
||||||
|
let jimeng_health: Option<Value> = if accounts
|
||||||
|
.iter()
|
||||||
|
.any(|a| site_by_id.get(&a.site_id).is_some_and(|s| is_jimeng_platform(&s.platform)))
|
||||||
|
{
|
||||||
|
derive_jimeng_runtime_health(&state).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let enriched: Vec<Value> = accounts
|
let enriched: Vec<Value> = accounts
|
||||||
.iter()
|
.iter()
|
||||||
.map(|account| {
|
.map(|account| {
|
||||||
let site = site_by_id.get(&account.site_id).copied();
|
let site = site_by_id.get(&account.site_id).copied();
|
||||||
account_response_value(account, site, health_by_account.get(&account.id))
|
account_response_value_with_jimeng(
|
||||||
|
account,
|
||||||
|
site,
|
||||||
|
health_by_account.get(&account.id),
|
||||||
|
jimeng_health.as_ref(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -264,9 +326,44 @@ fn account_response_value(
|
|||||||
i64,
|
i64,
|
||||||
)>,
|
)>,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
|
account_response_value_with_jimeng(account, site, runtime_health, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`account_response_value`] but, for Jimeng accounts, replaces the
|
||||||
|
/// proxy-log-derived `runtimeHealth` with the supplied platform-level Jimeng
|
||||||
|
/// health (see `derive_jimeng_runtime_health`). `jimeng_health` is ignored for
|
||||||
|
/// every other platform, so non-Jimeng behaviour is identical.
|
||||||
|
fn account_response_value_with_jimeng(
|
||||||
|
account: &Account,
|
||||||
|
site: Option<&Site>,
|
||||||
|
runtime_health: Option<&(
|
||||||
|
String,
|
||||||
|
Option<i64>,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
|
Option<i64>,
|
||||||
|
i64,
|
||||||
|
)>,
|
||||||
|
jimeng_health: Option<&Value>,
|
||||||
|
) -> Value {
|
||||||
|
let is_jimeng = site.is_some_and(|s| is_jimeng_platform(&s.platform));
|
||||||
let credential_mode = resolve_account_credential_mode(account);
|
let credential_mode = resolve_account_credential_mode(account);
|
||||||
let mut row = serde_json::to_value(account).unwrap_or(json!({}));
|
let mut row = serde_json::to_value(account).unwrap_or(json!({}));
|
||||||
if let Value::Object(map) = &mut row {
|
if let Value::Object(map) = &mut row {
|
||||||
|
// Never echo Jimeng secret material (SK / session token) back to the
|
||||||
|
// browser — replace it with the mask sentinel in the serialized
|
||||||
|
// `extraConfig` field. No-op for every other platform.
|
||||||
|
if let Some(extra) = account
|
||||||
|
.extra_config
|
||||||
|
.as_deref()
|
||||||
|
.map(mask_account_extra_config)
|
||||||
|
{
|
||||||
|
map.insert("extraConfig".to_string(), json!(extra));
|
||||||
|
}
|
||||||
map.insert("credentialMode".to_string(), json!(credential_mode));
|
map.insert("credentialMode".to_string(), json!(credential_mode));
|
||||||
map.insert(
|
map.insert(
|
||||||
"capabilities".to_string(),
|
"capabilities".to_string(),
|
||||||
@@ -294,6 +391,16 @@ fn account_response_value(
|
|||||||
"state": "unhealthy",
|
"state": "unhealthy",
|
||||||
"reason": "连接凭证已过期,请更新凭证",
|
"reason": "连接凭证已过期,请更新凭证",
|
||||||
})
|
})
|
||||||
|
} else if is_jimeng {
|
||||||
|
// Jimeng never writes proxy_logs; use the platform-level signal
|
||||||
|
// derived from recent jimeng background_tasks, falling back to a
|
||||||
|
// descriptive "unknown" when there are no tasks yet.
|
||||||
|
jimeng_health.cloned().unwrap_or_else(|| {
|
||||||
|
json!({
|
||||||
|
"state": "unknown",
|
||||||
|
"reason": "尚无即梦生成任务记录,凭证验证通过后即可使用",
|
||||||
|
})
|
||||||
|
})
|
||||||
} else if let Some((
|
} else if let Some((
|
||||||
status,
|
status,
|
||||||
http_status,
|
http_status,
|
||||||
@@ -396,6 +503,27 @@ pub async fn create_account(
|
|||||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||||
info!("Creating account for site_id: {}", payload.site_id);
|
info!("Creating account for site_id: {}", payload.site_id);
|
||||||
|
|
||||||
|
let site_platform: Option<(String,)> =
|
||||||
|
sqlx::query_as("SELECT platform FROM sites WHERE id = ?")
|
||||||
|
.bind(payload.site_id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((site_platform,)) = site_platform else {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Site with id {} not found",
|
||||||
|
payload.site_id
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jimeng connections store a Volcengine AK/SK pair (not a single OpenAI
|
||||||
|
// Bearer token) and never join the OpenAI route table, so they take a
|
||||||
|
// dedicated insert path that skips the token-batch loop and the route
|
||||||
|
// rebuild. Every other platform keeps the original behaviour below.
|
||||||
|
if is_jimeng_platform(&site_platform) {
|
||||||
|
return create_jimeng_account(&state, &payload).await;
|
||||||
|
}
|
||||||
|
|
||||||
let mut tokens = payload
|
let mut tokens = payload
|
||||||
.access_tokens
|
.access_tokens
|
||||||
.iter()
|
.iter()
|
||||||
@@ -515,6 +643,194 @@ pub async fn create_account(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert a single Jimeng connection. The Volcengine access key (AK) lands in
|
||||||
|
/// `accounts.access_token`; the secret key (SK) plus region/host/session token
|
||||||
|
/// are tucked into `extra_config.jimeng.*` (SK/session token are masked on
|
||||||
|
/// read-back, see [`mask_account_extra_config`]). Jimeng never joins the OpenAI
|
||||||
|
/// route table, so no route rebuild is fired.
|
||||||
|
async fn create_jimeng_account(
|
||||||
|
state: &AppState,
|
||||||
|
payload: &CreateAccountRequest,
|
||||||
|
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||||
|
let access_key = payload
|
||||||
|
.access_token
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|ak| !ak.is_empty())
|
||||||
|
.ok_or_else(|| AppError::BadRequest("即梦连接需要 Access Key(AK)".into()))?;
|
||||||
|
let secret_key = payload
|
||||||
|
.secret_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|sk| !sk.is_empty())
|
||||||
|
.ok_or_else(|| AppError::BadRequest("即梦连接需要 Secret Key(SK)".into()))?;
|
||||||
|
if secret_key == JIMENG_SECRET_MASK {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"请填写真实的 Secret Key(SK),而不是掩码".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let extra_config = build_jimeng_extra_config(
|
||||||
|
payload.extra_config.as_deref(),
|
||||||
|
secret_key,
|
||||||
|
payload.region.as_deref(),
|
||||||
|
payload.host.as_deref(),
|
||||||
|
payload.session_token.as_deref(),
|
||||||
|
)?;
|
||||||
|
let status = payload.status.clone().unwrap_or_else(|| "active".to_string());
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO accounts (site_id, username, access_token, api_token, status, checkin_enabled, extra_config) VALUES (?, ?, ?, NULL, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(payload.site_id)
|
||||||
|
.bind(&payload.username)
|
||||||
|
.bind(access_key)
|
||||||
|
.bind(&status)
|
||||||
|
.bind(payload.checkin_enabled.unwrap_or(false))
|
||||||
|
.bind(&extra_config)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let account_id = result.last_insert_rowid();
|
||||||
|
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||||
|
.bind(account_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
let site: Option<Site> = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||||
|
.bind(account.site_id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// NB: deliberately NO spawn_route_rebuild — Jimeng does not enter the route table.
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"id": account.id,
|
||||||
|
"tokenType": JIMENG_PLATFORM,
|
||||||
|
"data": account_response_value(&account, site.as_ref(), None),
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `extra_config` JSON for a Jimeng account: forces
|
||||||
|
/// `credentialMode = "apikey"` (so it lists under the API Key segment, never
|
||||||
|
/// the session segment) and nests the secret material under a `jimeng` object.
|
||||||
|
/// Any caller-supplied `extra_config` object is preserved and merged.
|
||||||
|
fn build_jimeng_extra_config(
|
||||||
|
raw_extra_config: Option<&str>,
|
||||||
|
secret_key: &str,
|
||||||
|
region: Option<&str>,
|
||||||
|
host: Option<&str>,
|
||||||
|
session_token: Option<&str>,
|
||||||
|
) -> AppResult<Option<String>> {
|
||||||
|
let mut value = match raw_extra_config
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|raw| !raw.is_empty())
|
||||||
|
{
|
||||||
|
Some(raw) => serde_json::from_str::<Value>(raw)
|
||||||
|
.map_err(|err| AppError::BadRequest(format!("Invalid extra_config JSON: {}", err)))?,
|
||||||
|
None => json!({}),
|
||||||
|
};
|
||||||
|
let Some(obj) = value.as_object_mut() else {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"extra_config must be a JSON object".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
obj.insert("credentialMode".to_string(), json!("apikey"));
|
||||||
|
|
||||||
|
let mut jimeng = serde_json::Map::new();
|
||||||
|
jimeng.insert("secretKey".to_string(), json!(secret_key));
|
||||||
|
if let Some(region) = region.map(str::trim).filter(|r| !r.is_empty()) {
|
||||||
|
jimeng.insert("region".to_string(), json!(region));
|
||||||
|
}
|
||||||
|
if let Some(host) = host.map(str::trim).filter(|h| !h.is_empty()) {
|
||||||
|
jimeng.insert("host".to_string(), json!(host));
|
||||||
|
}
|
||||||
|
if let Some(token) = session_token.map(str::trim).filter(|t| !t.is_empty()) {
|
||||||
|
jimeng.insert("sessionToken".to_string(), json!(token));
|
||||||
|
}
|
||||||
|
obj.insert("jimeng".to_string(), Value::Object(jimeng));
|
||||||
|
|
||||||
|
serde_json::to_string(&value)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|err| AppError::Internal(err.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a copy of a Jimeng `extra_config` string with the secret key and
|
||||||
|
/// session token replaced by [`JIMENG_SECRET_MASK`] so they are never sent to
|
||||||
|
/// the browser. Non-Jimeng configs (no `jimeng` object) are returned unchanged.
|
||||||
|
fn mask_account_extra_config(extra_config: &str) -> String {
|
||||||
|
let Ok(mut value) = serde_json::from_str::<Value>(extra_config) else {
|
||||||
|
return extra_config.to_string();
|
||||||
|
};
|
||||||
|
let Some(jimeng) = value
|
||||||
|
.get_mut("jimeng")
|
||||||
|
.and_then(Value::as_object_mut)
|
||||||
|
else {
|
||||||
|
return extra_config.to_string();
|
||||||
|
};
|
||||||
|
for key in ["secretKey", "sessionToken"] {
|
||||||
|
if jimeng.get(key).is_some() {
|
||||||
|
jimeng.insert(key.to_string(), json!(JIMENG_SECRET_MASK));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string(&value).unwrap_or_else(|_| extra_config.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a platform-level `runtimeHealth` for Jimeng accounts from the most
|
||||||
|
/// recent jimeng `background_tasks` row. Jimeng jobs run through the dedicated
|
||||||
|
/// `/api/jimeng/*` pipeline and never touch `proxy_logs`, so the OpenAI
|
||||||
|
/// proxy-log derivation would leave every Jimeng account perpetually `unknown`.
|
||||||
|
/// Returns `None` when there are no jimeng tasks yet (caller falls back to a
|
||||||
|
/// descriptive "no tasks" message).
|
||||||
|
async fn derive_jimeng_runtime_health(state: &AppState) -> Option<Value> {
|
||||||
|
let row: Option<(String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT status, message, error, updated_at
|
||||||
|
FROM background_tasks
|
||||||
|
WHERE task_type IN ('jimeng-image-generation', 'jimeng-video-generation')
|
||||||
|
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let (status, message, error, updated_at) = row?;
|
||||||
|
let health = match status.as_str() {
|
||||||
|
"succeeded" => json!({
|
||||||
|
"state": "healthy",
|
||||||
|
"reason": "最近一次即梦生成任务成功",
|
||||||
|
"checkedAt": updated_at,
|
||||||
|
}),
|
||||||
|
"failed" => json!({
|
||||||
|
"state": "unhealthy",
|
||||||
|
"reason": error
|
||||||
|
.filter(|e| !e.trim().is_empty())
|
||||||
|
.or(message)
|
||||||
|
.unwrap_or_else(|| "最近一次即梦生成任务失败".to_string()),
|
||||||
|
"checkedAt": updated_at,
|
||||||
|
}),
|
||||||
|
"running" | "pending" => json!({
|
||||||
|
"state": "unknown",
|
||||||
|
"reason": "即梦生成任务进行中",
|
||||||
|
"checkedAt": updated_at,
|
||||||
|
}),
|
||||||
|
"cancelled" => json!({
|
||||||
|
"state": "unknown",
|
||||||
|
"reason": "最近一次即梦生成任务已取消",
|
||||||
|
"checkedAt": updated_at,
|
||||||
|
}),
|
||||||
|
other => json!({
|
||||||
|
"state": "unknown",
|
||||||
|
"reason": format!("最近即梦任务状态:{other}"),
|
||||||
|
"checkedAt": updated_at,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Some(health)
|
||||||
|
}
|
||||||
|
|
||||||
/// Fire a route rebuild in the background after account creation. We don't
|
/// Fire a route rebuild in the background after account creation. We don't
|
||||||
/// block the HTTP response on this — `model_availability` is filled by the
|
/// block the HTTP response on this — `model_availability` is filled by the
|
||||||
/// separate `POST /api/accounts/{id}/models/refresh` call the frontend makes
|
/// separate `POST /api/accounts/{id}/models/refresh` call the frontend makes
|
||||||
@@ -562,6 +878,14 @@ pub async fn verify_token(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", payload.site_id)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", payload.site_id)))?;
|
||||||
|
|
||||||
|
// Jimeng connections are not OpenAI-compatible: there is no `/models`
|
||||||
|
// endpoint and auth is a Volcengine V4 HMAC signature, not a Bearer token.
|
||||||
|
// Verify by signing a minimal probe request (see `JimengProvider::verify`)
|
||||||
|
// instead of the GET /models path used for every other platform.
|
||||||
|
if is_jimeng_platform(&site.platform) {
|
||||||
|
return verify_jimeng_token(&state, &site, &payload).await;
|
||||||
|
}
|
||||||
|
|
||||||
let base_url = resolve_site_api_base_url(&state, &site).await?;
|
let base_url = resolve_site_api_base_url(&state, &site).await?;
|
||||||
let credential_mode = payload
|
let credential_mode = payload
|
||||||
.credential_mode
|
.credential_mode
|
||||||
@@ -650,6 +974,72 @@ pub async fn verify_token(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify a Jimeng connection by performing a Volcengine V4 signature probe
|
||||||
|
/// with the supplied AK/SK (+ optional region/host/session token). This never
|
||||||
|
/// touches `/models` (Jimeng has none) and never triggers a generation — it
|
||||||
|
/// only proves the gateway accepts the signature. `access_token` carries the
|
||||||
|
/// access key (AK); `secret_key` the secret key (SK).
|
||||||
|
async fn verify_jimeng_token(
|
||||||
|
state: &AppState,
|
||||||
|
site: &Site,
|
||||||
|
payload: &VerifyTokenRequest,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
use crate::services::jimeng::{JimengProvider, JimengVerifyOutcome};
|
||||||
|
|
||||||
|
let access_key = payload.access_token.trim();
|
||||||
|
let secret_key = payload.secret_key.as_deref().map(str::trim).unwrap_or("");
|
||||||
|
if secret_key.is_empty() {
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"success": false,
|
||||||
|
"tokenType": JIMENG_PLATFORM,
|
||||||
|
"siteId": site.id,
|
||||||
|
"message": "请填写 Secret Key(SK)",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider = match JimengProvider::from_credentials(
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
payload.region.as_deref(),
|
||||||
|
payload.host.as_deref(),
|
||||||
|
payload.session_token.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(provider) => provider,
|
||||||
|
Err(err) => {
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"success": false,
|
||||||
|
"tokenType": JIMENG_PLATFORM,
|
||||||
|
"siteId": site.id,
|
||||||
|
"message": format!("凭据无效:{err}"),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = provider.verify(&state.http).await;
|
||||||
|
let body = match outcome {
|
||||||
|
JimengVerifyOutcome::Authenticated => json!({
|
||||||
|
"success": true,
|
||||||
|
"tokenType": JIMENG_PLATFORM,
|
||||||
|
"siteId": site.id,
|
||||||
|
"siteName": site.name,
|
||||||
|
"message": "即梦签名验证成功(AK/SK 可用)",
|
||||||
|
}),
|
||||||
|
JimengVerifyOutcome::AuthFailed { code, message } => json!({
|
||||||
|
"success": false,
|
||||||
|
"tokenType": JIMENG_PLATFORM,
|
||||||
|
"siteId": site.id,
|
||||||
|
"message": format!("即梦签名验证失败({code}):{message}"),
|
||||||
|
}),
|
||||||
|
JimengVerifyOutcome::Indeterminate { detail } => json!({
|
||||||
|
"success": false,
|
||||||
|
"tokenType": JIMENG_PLATFORM,
|
||||||
|
"siteId": site.id,
|
||||||
|
"message": format!("无法判定即梦凭据有效性:{detail}"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Ok(Json(body))
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/accounts/health/refresh
|
/// POST /api/accounts/health/refresh
|
||||||
///
|
///
|
||||||
/// Runtime health is derived from recent proxy logs in `list_accounts`.
|
/// Runtime health is derived from recent proxy logs in `list_accounts`.
|
||||||
@@ -681,7 +1071,7 @@ pub async fn get_account(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
|
||||||
|
|
||||||
Ok(Json(json!({ "data": account })))
|
Ok(Json(json!({ "data": account_data_masked(&account) })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/accounts/{id}
|
/// PUT /api/accounts/{id}
|
||||||
@@ -698,6 +1088,19 @@ pub async fn update_account(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
|
||||||
|
|
||||||
|
// Guard against persisting a masked Jimeng secret: the list/get responses
|
||||||
|
// return `extra_config` with SK/session-token replaced by the mask
|
||||||
|
// sentinel, so an edit round-trip that echoes that string back must not
|
||||||
|
// overwrite the real secret. Reject it and let the caller omit the field
|
||||||
|
// (which preserves the stored value) instead.
|
||||||
|
if let Some(extra) = payload.extra_config.as_deref() {
|
||||||
|
if extra.contains(JIMENG_SECRET_MASK) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"extra_config 含掩码占位符,请留空以保留原有 SK,或提交真实凭据".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the final status before the UPDATE so we can compare it against
|
// Resolve the final status before the UPDATE so we can compare it against
|
||||||
// the previous value and decide whether routing needs to be rebuilt.
|
// the previous value and decide whether routing needs to be rebuilt.
|
||||||
// `rebuild_routes` filters on `accounts.status = 'active'`, so any flip
|
// `rebuild_routes` filters on `accounts.status = 'active'`, so any flip
|
||||||
@@ -738,7 +1141,24 @@ pub async fn update_account(
|
|||||||
spawn_route_rebuild(state.clone());
|
spawn_route_rebuild(state.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({ "data": account })))
|
Ok(Json(json!({ "data": account_data_masked(&account) })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize an [`Account`] to JSON with its `extraConfig` masked (Jimeng SK /
|
||||||
|
/// session token replaced by the sentinel). Used by the get/update endpoints
|
||||||
|
/// which return the bare account object rather than [`account_response_value`].
|
||||||
|
fn account_data_masked(account: &Account) -> Value {
|
||||||
|
let mut value = serde_json::to_value(account).unwrap_or_else(|_| json!({}));
|
||||||
|
if let Some(map) = value.as_object_mut() {
|
||||||
|
if let Some(extra) = account
|
||||||
|
.extra_config
|
||||||
|
.as_deref()
|
||||||
|
.map(mask_account_extra_config)
|
||||||
|
{
|
||||||
|
map.insert("extraConfig".to_string(), json!(extra));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/accounts/{id}
|
/// DELETE /api/accounts/{id}
|
||||||
@@ -1512,4 +1932,131 @@ mod tests {
|
|||||||
Some(7928)
|
Some(7928)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn jimeng_site_fixture(status: &str) -> Site {
|
||||||
|
let mut site = site_fixture(status);
|
||||||
|
site.platform = "jimeng".to_string();
|
||||||
|
site.url = "https://visual.volcengineapi.com".to_string();
|
||||||
|
site
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_jimeng_platform_matches_case_insensitively() {
|
||||||
|
assert!(is_jimeng_platform("jimeng"));
|
||||||
|
assert!(is_jimeng_platform("JIMENG"));
|
||||||
|
assert!(is_jimeng_platform(" Jimeng "));
|
||||||
|
assert!(!is_jimeng_platform("openai"));
|
||||||
|
assert!(!is_jimeng_platform("comfy_cloud"));
|
||||||
|
assert!(!is_jimeng_platform(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_jimeng_extra_config_nests_secret_and_forces_apikey() {
|
||||||
|
let raw = build_jimeng_extra_config(
|
||||||
|
None,
|
||||||
|
"the-secret-key",
|
||||||
|
Some("cn-north-1"),
|
||||||
|
Some("visual.volcengineapi.com"),
|
||||||
|
Some("sts-token"),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&raw).unwrap();
|
||||||
|
assert_eq!(value["credentialMode"], "apikey");
|
||||||
|
assert_eq!(value["jimeng"]["secretKey"], "the-secret-key");
|
||||||
|
assert_eq!(value["jimeng"]["region"], "cn-north-1");
|
||||||
|
assert_eq!(value["jimeng"]["host"], "visual.volcengineapi.com");
|
||||||
|
assert_eq!(value["jimeng"]["sessionToken"], "sts-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_jimeng_extra_config_omits_blank_optionals() {
|
||||||
|
let raw = build_jimeng_extra_config(None, "sk", Some(" "), None, Some(""))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&raw).unwrap();
|
||||||
|
let jimeng = value["jimeng"].as_object().unwrap();
|
||||||
|
assert!(jimeng.contains_key("secretKey"));
|
||||||
|
assert!(!jimeng.contains_key("region"));
|
||||||
|
assert!(!jimeng.contains_key("host"));
|
||||||
|
assert!(!jimeng.contains_key("sessionToken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_account_extra_config_hides_secret_and_session_token() {
|
||||||
|
let raw = build_jimeng_extra_config(None, "supersecret", None, None, Some("stoken"))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let masked = mask_account_extra_config(&raw);
|
||||||
|
let value: Value = serde_json::from_str(&masked).unwrap();
|
||||||
|
assert_eq!(value["jimeng"]["secretKey"], JIMENG_SECRET_MASK);
|
||||||
|
assert_eq!(value["jimeng"]["sessionToken"], JIMENG_SECRET_MASK);
|
||||||
|
assert!(!masked.contains("supersecret"));
|
||||||
|
assert!(!masked.contains("stoken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_account_extra_config_leaves_non_jimeng_config_untouched() {
|
||||||
|
let raw = json!({ "credentialMode": "apikey", "platformUserId": 42 }).to_string();
|
||||||
|
assert_eq!(mask_account_extra_config(&raw), raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jimeng_account_uses_platform_health_override_not_proxy_logs() {
|
||||||
|
let mut account = account_fixture("active", "apikey");
|
||||||
|
account.extra_config = build_jimeng_extra_config(None, "sk", None, None, None).unwrap();
|
||||||
|
let site = jimeng_site_fixture("active");
|
||||||
|
let jimeng_health = json!({
|
||||||
|
"state": "healthy",
|
||||||
|
"reason": "最近一次即梦生成任务成功",
|
||||||
|
});
|
||||||
|
|
||||||
|
// A stale proxy-log tuple is passed too; the jimeng override must win.
|
||||||
|
let value = account_response_value_with_jimeng(
|
||||||
|
&account,
|
||||||
|
Some(&site),
|
||||||
|
None,
|
||||||
|
Some(&jimeng_health),
|
||||||
|
);
|
||||||
|
let rh = value.get("runtimeHealth").unwrap();
|
||||||
|
assert_eq!(rh.get("state").and_then(Value::as_str), Some("healthy"));
|
||||||
|
// SK must be masked in the response.
|
||||||
|
let extra = value.get("extraConfig").and_then(Value::as_str).unwrap();
|
||||||
|
assert!(extra.contains(JIMENG_SECRET_MASK));
|
||||||
|
assert!(!extra.contains("\"sk\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jimeng_account_without_tasks_reports_descriptive_unknown() {
|
||||||
|
let mut account = account_fixture("active", "apikey");
|
||||||
|
account.extra_config = build_jimeng_extra_config(None, "sk", None, None, None).unwrap();
|
||||||
|
let site = jimeng_site_fixture("active");
|
||||||
|
|
||||||
|
let value = account_response_value_with_jimeng(&account, Some(&site), None, None);
|
||||||
|
let rh = value.get("runtimeHealth").unwrap();
|
||||||
|
assert_eq!(rh.get("state").and_then(Value::as_str), Some("unknown"));
|
||||||
|
assert_eq!(
|
||||||
|
rh.get("reason").and_then(Value::as_str),
|
||||||
|
Some("尚无即梦生成任务记录,凭证验证通过后即可使用")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_jimeng_account_ignores_jimeng_health_override() {
|
||||||
|
// Regression guard: passing a jimeng_health value must NOT alter an
|
||||||
|
// OpenAI account's proxy-log-derived health.
|
||||||
|
let account = account_fixture("active", "apikey");
|
||||||
|
let site = site_fixture("active"); // platform = openai
|
||||||
|
let stray = json!({ "state": "healthy", "reason": "should be ignored" });
|
||||||
|
|
||||||
|
let value =
|
||||||
|
account_response_value_with_jimeng(&account, Some(&site), None, Some(&stray));
|
||||||
|
let rh = value.get("runtimeHealth").unwrap();
|
||||||
|
// Falls through to the normal "no proxy logs yet" message, not the override.
|
||||||
|
assert_eq!(rh.get("state").and_then(Value::as_str), Some("unknown"));
|
||||||
|
assert_eq!(
|
||||||
|
rh.get("reason").and_then(Value::as_str),
|
||||||
|
Some("尚无代理请求记录,首次调用后自动更新")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use serde_json::{json, Value};
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
mod account_tokens;
|
mod account_tokens;
|
||||||
mod accounts;
|
pub(crate) mod accounts;
|
||||||
mod cli_templates;
|
mod cli_templates;
|
||||||
mod compat;
|
mod compat;
|
||||||
mod downstream_keys;
|
mod downstream_keys;
|
||||||
|
|||||||
@@ -642,6 +642,14 @@ async fn build_probe_plan(
|
|||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("site not found".into()))?;
|
.ok_or_else(|| AppError::NotFound("site not found".into()))?;
|
||||||
|
// Jimeng has no `/models` endpoint and no per-model chat semantics, so the
|
||||||
|
// per-model probe is meaningless for it. Short-circuit with a clear message
|
||||||
|
// (covers both probe-now and probe-stream, which funnel through here).
|
||||||
|
if crate::routes::admin::accounts::is_jimeng_platform(&site.platform) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"即梦平台无需逐模型探测,请在连接管理用「验证」做签名连通性检查".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if site.status != "active" {
|
if site.status != "active" {
|
||||||
return Err(AppError::BadRequest("站点未启用,无法执行探测".into()));
|
return Err(AppError::BadRequest("站点未启用,无法执行探测".into()));
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-3
@@ -24,6 +24,7 @@
|
|||||||
use crate::auth::jwt::validate_jwt;
|
use crate::auth::jwt::validate_jwt;
|
||||||
use crate::services::agent_workbench::{
|
use crate::services::agent_workbench::{
|
||||||
approval::{self, ApprovalOutcome, SettledState},
|
approval::{self, ApprovalOutcome, SettledState},
|
||||||
|
ask::{self, AskOutcome},
|
||||||
bash_sandbox::SandboxedBashTool,
|
bash_sandbox::SandboxedBashTool,
|
||||||
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
|
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
|
||||||
sandbox,
|
sandbox,
|
||||||
@@ -39,12 +40,14 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use futures::stream::Stream;
|
use futures::stream::Stream;
|
||||||
use open_agent_sdk::api::openai::OpenAIProvider;
|
use open_agent_sdk::api::openai::OpenAIProvider;
|
||||||
use open_agent_sdk::types::{ContentBlock, PermissionDecision};
|
use open_agent_sdk::types::{ContentBlock, Message, PermissionDecision};
|
||||||
use open_agent_sdk::{Agent, AgentOptions, LLMProvider, SDKMessage};
|
use open_agent_sdk::{Agent, AgentOptions, CancellationToken, LLMProvider, SDKMessage};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
@@ -54,6 +57,8 @@ use uuid::Uuid;
|
|||||||
const MAX_TURNS: u32 = 20;
|
const MAX_TURNS: u32 = 20;
|
||||||
/// How long a tool approval waits for the user before auto-denying.
|
/// How long a tool approval waits for the user before auto-denying.
|
||||||
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
|
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
/// How long an AskUserQuestion waits for the user before giving up.
|
||||||
|
const ASK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
/// Body limit for a chat request (images/attachments can be large).
|
/// Body limit for a chat request (images/attachments can be large).
|
||||||
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
|
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -67,6 +72,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/agent/chat", post(chat_handler))
|
.route("/api/agent/chat", post(chat_handler))
|
||||||
.route("/api/agent/approve", post(approve_handler))
|
.route("/api/agent/approve", post(approve_handler))
|
||||||
|
.route("/api/agent/answer", post(answer_handler))
|
||||||
.route("/api/agent/status", get(status_handler))
|
.route("/api/agent/status", get(status_handler))
|
||||||
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
||||||
}
|
}
|
||||||
@@ -92,6 +98,13 @@ struct ApproveRequest {
|
|||||||
approved: bool,
|
approved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AnswerRequest {
|
||||||
|
request_id: String,
|
||||||
|
/// The user's free-text reply to an `AskUserQuestion` prompt.
|
||||||
|
answer: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolved caller identity for an agent request.
|
/// Resolved caller identity for an agent request.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct AgentAuth {
|
struct AgentAuth {
|
||||||
@@ -237,6 +250,53 @@ fn build_can_use_tool(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the SDK `ask_fn` for the `AskUserQuestion` tool. When the agent needs
|
||||||
|
/// the user to clarify something, this emits an `ask` SSE event and blocks the
|
||||||
|
/// tool call until the user answers via `POST /api/agent/answer` (or the wait
|
||||||
|
/// times out, which surfaces a cancellation back to the agent). Reuses the same
|
||||||
|
/// SSE round-trip pattern as the approval flow.
|
||||||
|
fn build_ask_fn(
|
||||||
|
sse_tx: UnboundedSender<Result<Event, Infallible>>,
|
||||||
|
) -> open_agent_sdk::tools::askuser::AskUserFn {
|
||||||
|
use open_agent_sdk::tools::askuser::AskUserRequest;
|
||||||
|
Arc::new(move |req: AskUserRequest| {
|
||||||
|
let sse_tx = sse_tx.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let request_id = Uuid::new_v4().to_string();
|
||||||
|
// `_guard` removes the registry entry when this future ends (normal
|
||||||
|
// completion or drop on session abort) — no leak.
|
||||||
|
let (entry, _guard) = ask::register(&request_id);
|
||||||
|
send_sse(
|
||||||
|
&sse_tx,
|
||||||
|
json!({
|
||||||
|
"type": "ask",
|
||||||
|
"data": {
|
||||||
|
"request_id": request_id,
|
||||||
|
"question": req.question,
|
||||||
|
"options": req.options,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let outcome = ask::wait_for_answer(&entry, ASK_TIMEOUT).await;
|
||||||
|
// Tell the client the prompt is resolved so it can dismiss the modal.
|
||||||
|
send_sse(
|
||||||
|
&sse_tx,
|
||||||
|
json!({
|
||||||
|
"type": "ask_resolved",
|
||||||
|
"data": { "request_id": request_id }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
match outcome {
|
||||||
|
AskOutcome::Answered(text) => Ok(text),
|
||||||
|
AskOutcome::Cancelled => {
|
||||||
|
Err("User did not answer the question in time".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
async fn chat_handler(
|
async fn chat_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -259,6 +319,18 @@ async fn chat_handler(
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||||
|
// Refuse to resume a session owned by another tenant (which would leak/
|
||||||
|
// overwrite their transcript); fall back to a fresh id instead of erroring.
|
||||||
|
let session_id = match session_store::session_is_claimable(&state.db, &session_id, auth.user_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(true) => session_id,
|
||||||
|
Ok(false) => Uuid::new_v4().to_string(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%e, "failed to check agent session ownership");
|
||||||
|
Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
let mode = PermissionMode::from_wire(body.permission_mode.as_deref().unwrap_or("default"));
|
let mode = PermissionMode::from_wire(body.permission_mode.as_deref().unwrap_or("default"));
|
||||||
let model = body
|
let model = body
|
||||||
.model
|
.model
|
||||||
@@ -292,6 +364,26 @@ async fn chat_handler(
|
|||||||
tracing::warn!(%e, "failed to ensure agent session row");
|
tracing::warn!(%e, "failed to ensure agent session row");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load any prior conversation for this session so the agent sees the full
|
||||||
|
// multi-turn history (scoped to the caller — never another tenant's).
|
||||||
|
let history: Vec<Message> = match session_store::load_transcript(
|
||||||
|
&state.db,
|
||||||
|
&session_id,
|
||||||
|
auth.user_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(raw)) => serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(%e, "failed to parse agent transcript; starting fresh");
|
||||||
|
Vec::new()
|
||||||
|
}),
|
||||||
|
Ok(None) => Vec::new(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%e, "failed to load agent transcript");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Route the agent's LLM calls back through BoosAPI's own aggregation using
|
// Route the agent's LLM calls back through BoosAPI's own aggregation using
|
||||||
// the built-in downstream key.
|
// the built-in downstream key.
|
||||||
let base_url = boosapi_base(&state);
|
let base_url = boosapi_base(&state);
|
||||||
@@ -299,9 +391,16 @@ async fn chat_handler(
|
|||||||
|
|
||||||
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||||
|
|
||||||
|
// Cancellation: the token is handed to the agent loop (so a long LLM call or
|
||||||
|
// tool can be interrupted mid-flight); a guard wrapping the SSE stream fires
|
||||||
|
// it when the client disconnects (stream dropped). See `CancelGuard`.
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
|
let user_id = auth.user_id;
|
||||||
let prompt = body.message;
|
let prompt = body.message;
|
||||||
let session_for_event = session_id.clone();
|
let session_for_event = session_id.clone();
|
||||||
|
let agent_cancel = cancel.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// First event: hand the (possibly newly minted) session id to the client.
|
// First event: hand the (possibly newly minted) session id to the client.
|
||||||
@@ -318,6 +417,7 @@ async fn chat_handler(
|
|||||||
));
|
));
|
||||||
|
|
||||||
let can_use_tool = build_can_use_tool(tx.clone(), cwd.clone(), mode);
|
let can_use_tool = build_can_use_tool(tx.clone(), cwd.clone(), mode);
|
||||||
|
let ask_fn = build_ask_fn(tx.clone());
|
||||||
|
|
||||||
// Replace the SDK's built-in Bash (a bare subprocess) with an
|
// Replace the SDK's built-in Bash (a bare subprocess) with an
|
||||||
// OS-sandboxed one confined to this session's directory. Registered as
|
// OS-sandboxed one confined to this session's directory. Registered as
|
||||||
@@ -333,6 +433,8 @@ async fn chat_handler(
|
|||||||
system_prompt: Some(SYSTEM_PROMPT.to_string()),
|
system_prompt: Some(SYSTEM_PROMPT.to_string()),
|
||||||
max_turns: Some(MAX_TURNS),
|
max_turns: Some(MAX_TURNS),
|
||||||
can_use_tool: Some(can_use_tool),
|
can_use_tool: Some(can_use_tool),
|
||||||
|
ask_fn: Some(ask_fn),
|
||||||
|
abort_signal: Some(agent_cancel),
|
||||||
custom_tools: vec![sandboxed_bash],
|
custom_tools: vec![sandboxed_bash],
|
||||||
session_id: Some(session_id.clone()),
|
session_id: Some(session_id.clone()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -348,22 +450,84 @@ async fn chat_handler(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Seed the agent with the prior conversation so this turn continues it.
|
||||||
|
agent.messages = history;
|
||||||
|
|
||||||
let (mut rx_msg, handle) = agent.query(&prompt).await;
|
let (mut rx_msg, handle) = agent.query(&prompt).await;
|
||||||
|
|
||||||
|
// The SDK's Result event carries the full updated message history; we
|
||||||
|
// capture it here and persist it after the stream drains so the next
|
||||||
|
// turn (same session id) resumes from it.
|
||||||
|
let mut latest_transcript: Option<Vec<Message>> = None;
|
||||||
|
|
||||||
while let Some(event) = rx_msg.recv().await {
|
while let Some(event) = rx_msg.recv().await {
|
||||||
// Stop early if the client disconnected.
|
// Stop early if the client disconnected.
|
||||||
if tx.is_closed() {
|
if tx.is_closed() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if let SDKMessage::Result { messages, .. } = &event {
|
||||||
|
latest_transcript = Some(messages.clone());
|
||||||
|
}
|
||||||
forward_sdk_event(&tx, &db, &session_id, event).await;
|
forward_sdk_event(&tx, &db, &session_id, event).await;
|
||||||
}
|
}
|
||||||
let _ = handle.await;
|
let _ = handle.await;
|
||||||
agent.close().await;
|
agent.close().await;
|
||||||
|
|
||||||
|
// Persist the updated transcript (best-effort). Skipped if the turn was
|
||||||
|
// cancelled before producing a Result, leaving the prior history intact.
|
||||||
|
if let Some(messages) = latest_transcript {
|
||||||
|
match serde_json::to_string(&messages) {
|
||||||
|
Ok(raw) => {
|
||||||
|
if let Err(e) =
|
||||||
|
session_store::save_transcript(&db, &session_id, user_id, &raw).await
|
||||||
|
{
|
||||||
|
tracing::warn!(%e, "failed to save agent transcript");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(%e, "failed to serialize agent transcript"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
send_sse(&tx, json!({ "type": "done" }));
|
send_sse(&tx, json!({ "type": "done" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
// Wrap the stream so dropping it (client disconnect) cancels the agent loop.
|
||||||
|
Ok(Sse::new(CancelGuard::new(
|
||||||
|
UnboundedReceiverStream::new(rx),
|
||||||
|
cancel,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps the SSE body stream and cancels a [`CancellationToken`] when the stream
|
||||||
|
/// is dropped — i.e. when the client disconnects and Axum tears down the
|
||||||
|
/// response. This propagates cancellation into the agent loop's `abort_signal`,
|
||||||
|
/// so a long-running LLM call or tool execution is interrupted promptly instead
|
||||||
|
/// of running to completion on a server nobody is listening to.
|
||||||
|
struct CancelGuard<S> {
|
||||||
|
inner: S,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> CancelGuard<S> {
|
||||||
|
fn new(inner: S, cancel: CancellationToken) -> Self {
|
||||||
|
Self { inner, cancel }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Stream + Unpin> Stream for CancelGuard<S> {
|
||||||
|
type Item = S::Item;
|
||||||
|
|
||||||
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||||
|
Pin::new(&mut self.inner).poll_next(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Drop for CancelGuard<S> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Idempotent: a normal end-of-stream also drops this, harmlessly
|
||||||
|
// cancelling an already-finished loop.
|
||||||
|
self.cancel.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translate one SDK message into SSE event(s), and persist usage on Result.
|
/// Translate one SDK message into SSE event(s), and persist usage on Result.
|
||||||
@@ -448,6 +612,18 @@ async fn approve_handler(
|
|||||||
Ok(Json(json!({ "ok": delivered })))
|
Ok(Json(json!({ "ok": delivered })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn answer_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<AnswerRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
// Auth required; the pending question is keyed by an unguessable uuid, so any
|
||||||
|
// authenticated caller may answer their own running session's prompt.
|
||||||
|
resolve_auth(&headers, &state).await?;
|
||||||
|
let delivered = ask::answer(&body.request_id, body.answer);
|
||||||
|
Ok(Json(json!({ "ok": delivered })))
|
||||||
|
}
|
||||||
|
|
||||||
async fn status_handler(
|
async fn status_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|||||||
+58
-4
@@ -19,10 +19,12 @@ use serde_json::{json, Value};
|
|||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
/// Default spending cap (USD) granted to a newly created user's auto-generated
|
/// Default spending cap (USD) on a newly created user's auto-generated default
|
||||||
/// API key. Per the billing design (新用户默认 $1 额度), new users start with a
|
/// API key. Set to `0.0` so a freshly registered user has **zero** spend
|
||||||
/// $1 allowance on their default downstream key.
|
/// allowance until they top up or an admin raises the cap. This must always be
|
||||||
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 1.0;
|
/// bound explicitly on INSERT: a NULL `max_cost` is treated as *unlimited* by
|
||||||
|
/// the proxy, so leaving it unbound silently grants free unlimited usage.
|
||||||
|
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 0.0;
|
||||||
|
|
||||||
// ── Request / Response types ─────────────────────────────────────────
|
// ── Request / Response types ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1418,4 +1420,56 @@ mod tests {
|
|||||||
let (_, _, message, _, _) = latest_audit_event(&state).await;
|
let (_, _, message, _, _) = latest_audit_event(&state).await;
|
||||||
assert!(message.contains("IP=203.0.113.9"), "{message}");
|
assert!(message.contains("IP=203.0.113.9"), "{message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_user_default_quota_is_zero() {
|
||||||
|
// New users must start with zero spend allowance, not unlimited.
|
||||||
|
assert_eq!(DEFAULT_NEW_USER_MAX_COST_USD, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registration_default_key_has_non_null_zero_max_cost() {
|
||||||
|
// Regression guard for the billing leak: the default downstream key
|
||||||
|
// created at registration must persist max_cost = 0.0, never NULL
|
||||||
|
// (NULL is treated as unlimited by the proxy). This runs the exact
|
||||||
|
// INSERT the register handler uses.
|
||||||
|
let state = test_state().await;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')",
|
||||||
|
)
|
||||||
|
.bind("quota-user")
|
||||||
|
.bind("quota@example.test")
|
||||||
|
.bind("hash")
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user");
|
||||||
|
let user_id = sqlx::query_scalar::<_, i64>("SELECT id FROM users WHERE email = ?")
|
||||||
|
.bind("quota@example.test")
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("user id");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) \
|
||||||
|
VALUES (?, ?, ?, 1, ?)",
|
||||||
|
)
|
||||||
|
.bind("quota-user 的默认密钥")
|
||||||
|
.bind("sk-user-quota-test")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert default key");
|
||||||
|
|
||||||
|
// max_cost must be present (non-NULL) and exactly zero.
|
||||||
|
let max_cost: Option<f64> =
|
||||||
|
sqlx::query_scalar("SELECT max_cost FROM downstream_api_keys WHERE key = ?")
|
||||||
|
.bind("sk-user-quota-test")
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("select max_cost");
|
||||||
|
assert!(max_cost.is_some(), "max_cost must not be NULL (NULL = unlimited)");
|
||||||
|
assert_eq!(max_cost.unwrap(), 0.0, "new user default key must have zero quota");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//! Pending-question registry for the Agent Workbench's `AskUserQuestion` tool.
|
||||||
|
//!
|
||||||
|
//! Mirrors [`super::approval`] but carries a free-text answer instead of a
|
||||||
|
//! boolean decision. When the agent calls `AskUserQuestion`, the SDK invokes the
|
||||||
|
//! configured `ask_fn`, which registers a pending question, emits an `ask`
|
||||||
|
//! SSE event, and awaits the user's reply (or a timeout). The
|
||||||
|
//! `POST /api/agent/answer` endpoint resolves the pending entry with the user's
|
||||||
|
//! text.
|
||||||
|
//!
|
||||||
|
//! ## Single source of truth
|
||||||
|
//!
|
||||||
|
//! Each entry carries an [`AtomicU8`] state machine (`Pending → Answered /
|
||||||
|
//! Cancelled`) plus a [`Notify`] and a slot holding the answer. The answer is
|
||||||
|
//! published *before* the state transitions to `Answered`, so any waiter that
|
||||||
|
//! observes `Answered` is guaranteed to read the answer. Exactly one
|
||||||
|
//! `compare_exchange` from `Pending` wins; later answers observe a losing CAS
|
||||||
|
//! and no-op, so the user can never answer the same question twice.
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
const PENDING: u8 = 0;
|
||||||
|
const ANSWERED: u8 = 1;
|
||||||
|
const CANCELLED: u8 = 2;
|
||||||
|
|
||||||
|
/// Settled state of a pending question.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AskOutcome {
|
||||||
|
/// The user replied with this text.
|
||||||
|
Answered(String),
|
||||||
|
/// No answer arrived before the timeout (or the session ended).
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared question entry: an atomic state machine, the answer slot, and a wakeup.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AskEntry {
|
||||||
|
state: AtomicU8,
|
||||||
|
answer: Mutex<Option<String>>,
|
||||||
|
notify: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AskEntry {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
state: AtomicU8::new(PENDING),
|
||||||
|
answer: Mutex::new(None),
|
||||||
|
notify: Notify::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outcome(&self) -> Option<AskOutcome> {
|
||||||
|
match self.state.load(Ordering::SeqCst) {
|
||||||
|
ANSWERED => Some(AskOutcome::Answered(
|
||||||
|
self.answer.lock().unwrap().clone().unwrap_or_default(),
|
||||||
|
)),
|
||||||
|
CANCELLED => Some(AskOutcome::Cancelled),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static REGISTRY: Lazy<Mutex<HashMap<String, Arc<AskEntry>>>> =
|
||||||
|
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
/// RAII guard that removes its entry from the registry when dropped, so a
|
||||||
|
/// pending question is cleaned up whether `ask_fn` finishes normally or its
|
||||||
|
/// future is dropped (session aborted / client disconnected). No leak on any
|
||||||
|
/// path.
|
||||||
|
pub struct PendingGuard {
|
||||||
|
request_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PendingGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
REGISTRY.lock().unwrap().remove(&self.request_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new pending question. Returns the shared entry (await
|
||||||
|
/// [`wait_for_answer`]) plus a [`PendingGuard`] the caller must hold for the
|
||||||
|
/// lifetime of the wait.
|
||||||
|
pub fn register(request_id: &str) -> (Arc<AskEntry>, PendingGuard) {
|
||||||
|
let entry = Arc::new(AskEntry::new());
|
||||||
|
REGISTRY
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(request_id.to_string(), entry.clone());
|
||||||
|
(
|
||||||
|
entry,
|
||||||
|
PendingGuard {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until the entry is answered or `timeout` elapses. On timeout this claims
|
||||||
|
/// `Cancelled` (if still pending) so the result is always terminal.
|
||||||
|
pub async fn wait_for_answer(
|
||||||
|
entry: &AskEntry,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> AskOutcome {
|
||||||
|
// Arm the notified future before checking state to avoid a lost wakeup.
|
||||||
|
let notified = entry.notify.notified();
|
||||||
|
|
||||||
|
if let Some(o) = entry.outcome() {
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokio::time::timeout(timeout, notified).await.is_err() {
|
||||||
|
// Timer fired first — claim Cancelled if nobody answered meanwhile.
|
||||||
|
let _ = entry
|
||||||
|
.state
|
||||||
|
.compare_exchange(PENDING, CANCELLED, Ordering::SeqCst, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.outcome().unwrap_or(AskOutcome::Cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliver the user's answer to a pending question.
|
||||||
|
///
|
||||||
|
/// Returns `true` iff this call's CAS won — i.e. it is the call that settled the
|
||||||
|
/// question. Returns `false` for an unknown id (already cleaned up) or when the
|
||||||
|
/// timeout / another answer already claimed the entry.
|
||||||
|
pub fn answer(request_id: &str, text: String) -> bool {
|
||||||
|
let entry = REGISTRY.lock().unwrap().get(request_id).cloned();
|
||||||
|
let Some(entry) = entry else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// Publish the answer *before* flipping state so a waiter that sees ANSWERED
|
||||||
|
// always reads the value.
|
||||||
|
*entry.answer.lock().unwrap() = Some(text);
|
||||||
|
if entry
|
||||||
|
.state
|
||||||
|
.compare_exchange(PENDING, ANSWERED, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
entry.notify.notify_one();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn answer_before_wait_is_observed() {
|
||||||
|
let (entry, _guard) = register("q1");
|
||||||
|
assert!(answer("q1", "hello".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_answer(&entry, Duration::from_secs(5)).await,
|
||||||
|
AskOutcome::Answered("hello".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn answer_wakes_waiter() {
|
||||||
|
let (entry, _guard) = register("q2");
|
||||||
|
let id = "q2".to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
answer(&id, "later".to_string());
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_answer(&entry, Duration::from_secs(5)).await,
|
||||||
|
AskOutcome::Answered("later".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeout_cancels_and_late_answer_loses() {
|
||||||
|
let (entry, _guard) = register("q3");
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_answer(&entry, Duration::from_millis(20)).await,
|
||||||
|
AskOutcome::Cancelled
|
||||||
|
);
|
||||||
|
// A late answer must report false: the CAS already went to Cancelled.
|
||||||
|
assert!(!answer("q3", "too late".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn first_answer_wins_second_loses() {
|
||||||
|
let (_entry, _guard) = register("q4");
|
||||||
|
assert!(answer("q4", "first".to_string()));
|
||||||
|
assert!(!answer("q4", "second".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn answer_unknown_id_is_false() {
|
||||||
|
assert!(!answer("nope", "x".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn guard_drop_removes_entry() {
|
||||||
|
{
|
||||||
|
let (_entry, _guard) = register("q5");
|
||||||
|
}
|
||||||
|
assert!(!answer("q5", "x".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@
|
|||||||
//! - [`sandbox`]: per-session isolated working directory + path validation.
|
//! - [`sandbox`]: per-session isolated working directory + path validation.
|
||||||
//! - [`bash_sandbox`]: OS-level (bubblewrap) confinement for the Bash tool.
|
//! - [`bash_sandbox`]: OS-level (bubblewrap) confinement for the Bash tool.
|
||||||
//! - [`approval`]: process-global registry bridging the SSE approval round-trip.
|
//! - [`approval`]: process-global registry bridging the SSE approval round-trip.
|
||||||
|
//! - [`ask`]: process-global registry bridging the SSE AskUserQuestion round-trip.
|
||||||
//! - [`session_store`]: persistence of per-session token/cost accounting.
|
//! - [`session_store`]: persistence of per-session token/cost accounting.
|
||||||
//!
|
//!
|
||||||
//! The HTTP surface (SSE chat, approval callback, status) lives in
|
//! The HTTP surface (SSE chat, approval callback, status) lives in
|
||||||
//! `crate::routes::agent`.
|
//! `crate::routes::agent`.
|
||||||
|
|
||||||
pub mod approval;
|
pub mod approval;
|
||||||
|
pub mod ask;
|
||||||
pub mod bash_sandbox;
|
pub mod bash_sandbox;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
|
|||||||
@@ -93,6 +93,70 @@ pub async fn record_turn(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `user_id` may use `session_id` for a new turn.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(true)` when the id is free (no row yet) or already owned by this
|
||||||
|
/// caller, and `Ok(false)` only when a row exists under a *different* owner —
|
||||||
|
/// so a caller can never resume (and thus read/overwrite) another tenant's
|
||||||
|
/// session by guessing its id. Admin callers pass `None`, which owns the
|
||||||
|
/// NULL-`user_id` rows.
|
||||||
|
pub async fn session_is_claimable(
|
||||||
|
db: &SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
) -> AppResult<bool> {
|
||||||
|
let row: Option<(Option<i64>,)> =
|
||||||
|
sqlx::query_as("SELECT user_id FROM agent_sessions WHERE id = ?")
|
||||||
|
.bind(session_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(match row {
|
||||||
|
None => true, // free — first use of this id
|
||||||
|
Some((owner,)) => owner == user_id, // already ours?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the raw transcript JSON for a session, scoped to `user_id` so a caller
|
||||||
|
/// can never read another tenant's conversation. `Ok(None)` when the session is
|
||||||
|
/// new, owned by someone else, or has no transcript yet — all of which mean
|
||||||
|
/// "start this turn with empty history".
|
||||||
|
pub async fn load_transcript(
|
||||||
|
db: &SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
) -> AppResult<Option<String>> {
|
||||||
|
let row: Option<(Option<String>,)> =
|
||||||
|
sqlx::query_as("SELECT transcript FROM agent_sessions WHERE id = ? AND user_id IS ?")
|
||||||
|
.bind(session_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
// Outer None: no such (owned) session. Inner None / empty: no transcript.
|
||||||
|
Ok(row
|
||||||
|
.and_then(|(t,)| t)
|
||||||
|
.filter(|s| !s.trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the full transcript JSON for a session, scoped to `user_id` so a
|
||||||
|
/// caller can only overwrite their own session's history.
|
||||||
|
pub async fn save_transcript(
|
||||||
|
db: &SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
transcript: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query("UPDATE agent_sessions SET transcript = ? WHERE id = ? AND user_id IS ?")
|
||||||
|
.bind(transcript)
|
||||||
|
.bind(session_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch a single session by id (scoped to `user_id` when provided, so a user
|
/// Fetch a single session by id (scoped to `user_id` when provided, so a user
|
||||||
/// cannot read another tenant's session). Admin callers pass `None`.
|
/// cannot read another tenant's session). Admin callers pass `None`.
|
||||||
pub async fn get_session(
|
pub async fn get_session(
|
||||||
@@ -138,6 +202,12 @@ mod tests {
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
sqlx::query(include_str!(
|
||||||
|
"../../../migrations/20260623000001_agent_session_transcript.sql"
|
||||||
|
))
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
pool
|
pool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,4 +268,54 @@ mod tests {
|
|||||||
// Owner can.
|
// Owner can.
|
||||||
assert!(get_session(&db, "s3", Some(1)).await.unwrap().is_some());
|
assert!(get_session(&db, "s3", Some(1)).await.unwrap().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transcript_round_trips_for_owner() {
|
||||||
|
let db = pool().await;
|
||||||
|
ensure_session(&db, "t1", Some(5), "m", "default").await.unwrap();
|
||||||
|
// Fresh session has no transcript.
|
||||||
|
assert!(load_transcript(&db, "t1", Some(5)).await.unwrap().is_none());
|
||||||
|
|
||||||
|
save_transcript(&db, "t1", Some(5), r#"[{"role":"user"}]"#)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
load_transcript(&db, "t1", Some(5)).await.unwrap().as_deref(),
|
||||||
|
Some(r#"[{"role":"user"}]"#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transcript_is_user_scoped() {
|
||||||
|
let db = pool().await;
|
||||||
|
ensure_session(&db, "t2", Some(1), "m", "default").await.unwrap();
|
||||||
|
save_transcript(&db, "t2", Some(1), "history").await.unwrap();
|
||||||
|
|
||||||
|
// Another tenant can neither read nor overwrite it.
|
||||||
|
assert!(load_transcript(&db, "t2", Some(2)).await.unwrap().is_none());
|
||||||
|
save_transcript(&db, "t2", Some(2), "evil").await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
load_transcript(&db, "t2", Some(1)).await.unwrap().as_deref(),
|
||||||
|
Some("history")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn claimable_is_true_when_free_or_owned() {
|
||||||
|
let db = pool().await;
|
||||||
|
// Free id is claimable by anyone.
|
||||||
|
assert!(session_is_claimable(&db, "c1", Some(9)).await.unwrap());
|
||||||
|
|
||||||
|
ensure_session(&db, "c1", Some(9), "m", "default").await.unwrap();
|
||||||
|
// Owner can re-claim.
|
||||||
|
assert!(session_is_claimable(&db, "c1", Some(9)).await.unwrap());
|
||||||
|
// Another tenant cannot.
|
||||||
|
assert!(!session_is_claimable(&db, "c1", Some(8)).await.unwrap());
|
||||||
|
assert!(!session_is_claimable(&db, "c1", None).await.unwrap());
|
||||||
|
|
||||||
|
ensure_session(&db, "c2", None, "m", "default").await.unwrap();
|
||||||
|
// Admin (None) owns NULL-user rows.
|
||||||
|
assert!(session_is_claimable(&db, "c2", None).await.unwrap());
|
||||||
|
assert!(!session_is_claimable(&db, "c2", Some(9)).await.unwrap());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+339
-2
@@ -18,6 +18,99 @@ use types::{
|
|||||||
};
|
};
|
||||||
use volc_visual_sdk::VisualClient;
|
use volc_visual_sdk::VisualClient;
|
||||||
|
|
||||||
|
/// Sentinel task id used by [`JimengProvider::verify`]. It is intentionally not
|
||||||
|
/// a real Volcengine task id: the gateway validates the V4 signature *before*
|
||||||
|
/// the business layer ever looks up the task, so a valid AK/SK is accepted
|
||||||
|
/// (and the business layer simply reports "not found"), while a bad AK/SK is
|
||||||
|
/// rejected at the gateway with an auth-class error. We only care which side
|
||||||
|
/// of that line the response lands on — never about the task itself.
|
||||||
|
const VERIFY_SENTINEL_TASK_ID: &str = "metapi-credential-probe";
|
||||||
|
|
||||||
|
/// Outcome of a credential signature probe (see [`JimengProvider::verify`]).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum JimengVerifyOutcome {
|
||||||
|
/// The gateway accepted the V4 signature (AK/SK valid). The business layer
|
||||||
|
/// may still have rejected the sentinel task — that is expected and means
|
||||||
|
/// "authenticated".
|
||||||
|
Authenticated,
|
||||||
|
/// The gateway rejected the signature: bad AK/SK, region/host mismatch or
|
||||||
|
/// an expired session token.
|
||||||
|
AuthFailed {
|
||||||
|
/// Volcengine gateway error `Code` (e.g. `SignatureDoesNotMatch`).
|
||||||
|
code: String,
|
||||||
|
/// Human-readable gateway message.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
/// Could not determine the result (network/transport error, or an
|
||||||
|
/// unparseable response). Carries a short detail for surfacing to the UI.
|
||||||
|
Indeterminate {
|
||||||
|
/// Short human-readable detail.
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when a Volcengine gateway error `Code` denotes an
|
||||||
|
/// authentication / signature failure (as opposed to a business or
|
||||||
|
/// request-shape error, which still proves the signature was accepted).
|
||||||
|
fn is_auth_error_code(code: &str) -> bool {
|
||||||
|
let lc = code.to_ascii_lowercase();
|
||||||
|
[
|
||||||
|
"signature",
|
||||||
|
"credential",
|
||||||
|
"accesskey",
|
||||||
|
"securitytoken",
|
||||||
|
"denied",
|
||||||
|
"unauthorized",
|
||||||
|
"forbidden",
|
||||||
|
"authentication",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|needle| lc.contains(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify the raw response of a signature-probe call into a
|
||||||
|
/// [`JimengVerifyOutcome`]. Pure (no IO) so it is unit-testable.
|
||||||
|
///
|
||||||
|
/// Decision order:
|
||||||
|
/// 1. A gateway `ResponseMetadata.Error` with an auth-class `Code` → auth
|
||||||
|
/// failed. Any *other* gateway error (e.g. `InvalidParameter` triggered by
|
||||||
|
/// our sentinel task id) still means the signature was accepted.
|
||||||
|
/// 2. A flat business envelope (top-level integer `code`, as the CV actions
|
||||||
|
/// return) → the gateway routed us to the business layer, so the signature
|
||||||
|
/// was accepted.
|
||||||
|
/// 3. Otherwise fall back to the HTTP status: `2xx` → authenticated.
|
||||||
|
pub fn classify_verify_response(http_status: u16, body: &str) -> JimengVerifyOutcome {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(body) {
|
||||||
|
if let Some(err) = value
|
||||||
|
.get("ResponseMetadata")
|
||||||
|
.and_then(|meta| meta.get("Error"))
|
||||||
|
{
|
||||||
|
let code = err.get("Code").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
if is_auth_error_code(code) {
|
||||||
|
let message = err
|
||||||
|
.get("Message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("signature rejected")
|
||||||
|
.to_string();
|
||||||
|
return JimengVerifyOutcome::AuthFailed {
|
||||||
|
code: code.to_string(),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return JimengVerifyOutcome::Authenticated;
|
||||||
|
}
|
||||||
|
if value.get("code").and_then(Value::as_i64).is_some() {
|
||||||
|
return JimengVerifyOutcome::Authenticated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (200..300).contains(&http_status) {
|
||||||
|
return JimengVerifyOutcome::Authenticated;
|
||||||
|
}
|
||||||
|
JimengVerifyOutcome::Indeterminate {
|
||||||
|
detail: format!("HTTP {http_status}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use self::types::VideoRequest;
|
use self::types::VideoRequest;
|
||||||
|
|
||||||
/// Errors produced by the Jimeng provider.
|
/// Errors produced by the Jimeng provider.
|
||||||
@@ -88,6 +181,58 @@ impl JimengProvider {
|
|||||||
Ok(Self { client })
|
Ok(Self { client })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a provider from an explicit credential set (the site/account layer
|
||||||
|
/// path — each jimeng "connection" carries its own AK/SK). Unlike
|
||||||
|
/// [`Self::from_config`] this is not gated on the global `enabled` switch:
|
||||||
|
/// it exists so the admin console can validate and store credentials before
|
||||||
|
/// the provider is turned on. `region`/`host` fall back to the Volcengine
|
||||||
|
/// defaults when blank.
|
||||||
|
pub fn from_credentials(
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
region: Option<&str>,
|
||||||
|
host: Option<&str>,
|
||||||
|
session_token: Option<&str>,
|
||||||
|
) -> Result<Self, JimengError> {
|
||||||
|
if access_key.trim().is_empty() {
|
||||||
|
return Err(JimengError::Invalid("access key is empty".into()));
|
||||||
|
}
|
||||||
|
if secret_key.trim().is_empty() {
|
||||||
|
return Err(JimengError::Invalid("secret key is empty".into()));
|
||||||
|
}
|
||||||
|
let mut client = VisualClient::new(access_key.to_string(), secret_key.to_string());
|
||||||
|
if let Some(region) = region.map(str::trim).filter(|r| !r.is_empty()) {
|
||||||
|
client = client.with_region(region.to_string());
|
||||||
|
}
|
||||||
|
if let Some(host) = host.map(str::trim).filter(|h| !h.is_empty()) {
|
||||||
|
client = client.with_host(host.to_string());
|
||||||
|
}
|
||||||
|
if let Some(token) = session_token.map(str::trim).filter(|t| !t.is_empty()) {
|
||||||
|
client = client.with_security_token(token.to_string());
|
||||||
|
}
|
||||||
|
Ok(Self { client })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe whether the configured credentials can sign a request the
|
||||||
|
/// Volcengine gateway accepts. Issues a single GetResult call for a sentinel
|
||||||
|
/// task id: the gateway checks the V4 signature before the business layer,
|
||||||
|
/// so a valid AK/SK passes (business layer reports "not found") and a bad
|
||||||
|
/// one is rejected with an auth-class error. No generation is triggered and
|
||||||
|
/// nothing is stored. See [`classify_verify_response`].
|
||||||
|
pub async fn verify(&self, http: &reqwest::Client) -> JimengVerifyOutcome {
|
||||||
|
let body = build_get_result_body(JimengCapability::Image, VERIFY_SENTINEL_TASK_ID);
|
||||||
|
match self.post_signed_raw(http, ACTION_GET_RESULT, &body).await {
|
||||||
|
Ok((status, text)) => classify_verify_response(status, &text),
|
||||||
|
Err(JimengError::Sign(detail)) => JimengVerifyOutcome::AuthFailed {
|
||||||
|
code: "SigningError".into(),
|
||||||
|
message: detail,
|
||||||
|
},
|
||||||
|
Err(other) => JimengVerifyOutcome::Indeterminate {
|
||||||
|
detail: other.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Submit a request body for `capability` and return the Volcengine task id.
|
/// Submit a request body for `capability` and return the Volcengine task id.
|
||||||
pub async fn submit(
|
pub async fn submit(
|
||||||
&self,
|
&self,
|
||||||
@@ -133,6 +278,20 @@ impl JimengProvider {
|
|||||||
action: &str,
|
action: &str,
|
||||||
body: &Value,
|
body: &Value,
|
||||||
) -> Result<String, JimengError> {
|
) -> Result<String, JimengError> {
|
||||||
|
self.post_signed_raw(http, action, body)
|
||||||
|
.await
|
||||||
|
.map(|(_status, text)| text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::post_signed`] but also returns the HTTP status code, which
|
||||||
|
/// the credential probe ([`Self::verify`]) needs to distinguish a gateway
|
||||||
|
/// rejection from a business response.
|
||||||
|
async fn post_signed_raw(
|
||||||
|
&self,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
action: &str,
|
||||||
|
body: &Value,
|
||||||
|
) -> Result<(u16, String), JimengError> {
|
||||||
let prepared = self
|
let prepared = self
|
||||||
.client
|
.client
|
||||||
.build_signed_request(action, API_VERSION, body)
|
.build_signed_request(action, API_VERSION, body)
|
||||||
@@ -154,9 +313,12 @@ impl JimengProvider {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| JimengError::Http(e.to_string()))?;
|
.map_err(|e| JimengError::Http(e.to_string()))?;
|
||||||
resp.text()
|
let status = resp.status().as_u16();
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| JimengError::Http(e.to_string()))
|
.map_err(|e| JimengError::Http(e.to_string()))?;
|
||||||
|
Ok((status, text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,4 +546,179 @@ mod tests {
|
|||||||
Err(JimengError::Disabled)
|
Err(JimengError::Disabled)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_body_accepts_all_allowed_aspect_ratios() {
|
||||||
|
for ratio in ALLOWED_ASPECT_RATIOS {
|
||||||
|
let req = VideoRequest {
|
||||||
|
prompt: Some("x".into()),
|
||||||
|
image_urls: vec![],
|
||||||
|
frames: 121,
|
||||||
|
aspect_ratio: Some(ratio.to_string()),
|
||||||
|
seed: None,
|
||||||
|
};
|
||||||
|
let body = build_video_body(&req)
|
||||||
|
.unwrap_or_else(|e| panic!("ratio {ratio} should be allowed: {e:?}"));
|
||||||
|
assert_eq!(body["aspect_ratio"], ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_credentials_requires_both_keys() {
|
||||||
|
assert!(matches!(
|
||||||
|
JimengProvider::from_credentials("", "sk", None, None, None),
|
||||||
|
Err(JimengError::Invalid(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
JimengProvider::from_credentials("ak", " ", None, None, None),
|
||||||
|
Err(JimengError::Invalid(_))
|
||||||
|
));
|
||||||
|
assert!(JimengProvider::from_credentials("ak", "sk", None, None, None).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_classifies_signature_error_as_auth_failed() {
|
||||||
|
// Gateway rejects the V4 signature before the business layer.
|
||||||
|
let body = r#"{"ResponseMetadata":{"RequestId":"x","Error":{"Code":"SignatureDoesNotMatch","Message":"The request signature we calculated does not match"}}}"#;
|
||||||
|
match classify_verify_response(403, body) {
|
||||||
|
JimengVerifyOutcome::AuthFailed { code, .. } => {
|
||||||
|
assert_eq!(code, "SignatureDoesNotMatch");
|
||||||
|
}
|
||||||
|
other => panic!("expected AuthFailed, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_body_accepts_both_frame_counts() {
|
||||||
|
for frames in ALLOWED_FRAMES {
|
||||||
|
let req = VideoRequest {
|
||||||
|
prompt: Some("x".into()),
|
||||||
|
image_urls: vec![],
|
||||||
|
frames,
|
||||||
|
aspect_ratio: None,
|
||||||
|
seed: None,
|
||||||
|
};
|
||||||
|
let body = build_video_body(&req)
|
||||||
|
.unwrap_or_else(|e| panic!("frames {frames} should be allowed: {e:?}"));
|
||||||
|
assert_eq!(body["frames"], frames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_body_includes_prompt_and_image_together() {
|
||||||
|
// Both inputs supplied (image-to-video with a guiding prompt): both end
|
||||||
|
// up in the body.
|
||||||
|
let req = VideoRequest {
|
||||||
|
prompt: Some("a dragon".into()),
|
||||||
|
image_urls: vec!["https://x/a.jpg".into(), "https://x/b.jpg".into()],
|
||||||
|
frames: 241,
|
||||||
|
aspect_ratio: Some("9:16".into()),
|
||||||
|
seed: Some(42),
|
||||||
|
};
|
||||||
|
let body = build_video_body(&req).unwrap();
|
||||||
|
assert_eq!(body["prompt"], "a dragon");
|
||||||
|
assert_eq!(body["image_urls"][1], "https://x/b.jpg");
|
||||||
|
assert_eq!(body["frames"], 241);
|
||||||
|
assert_eq!(body["seed"], 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_body_drops_whitespace_prompt_but_keeps_image() {
|
||||||
|
// A whitespace-only prompt is not a real prompt: it must not be written
|
||||||
|
// to the body, yet the image alone satisfies the "one of" requirement.
|
||||||
|
let req = VideoRequest {
|
||||||
|
prompt: Some(" ".into()),
|
||||||
|
image_urls: vec!["https://x/a.jpg".into()],
|
||||||
|
frames: 121,
|
||||||
|
aspect_ratio: None,
|
||||||
|
seed: None,
|
||||||
|
};
|
||||||
|
let body = build_video_body(&req).unwrap();
|
||||||
|
let obj = body.as_object().unwrap();
|
||||||
|
assert!(!obj.contains_key("prompt"), "blank prompt must be omitted");
|
||||||
|
assert_eq!(body["image_urls"][0], "https://x/a.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_body_omits_absent_optional_fields() {
|
||||||
|
let req = VideoRequest {
|
||||||
|
prompt: Some("x".into()),
|
||||||
|
image_urls: vec![],
|
||||||
|
frames: 121,
|
||||||
|
aspect_ratio: None,
|
||||||
|
seed: None,
|
||||||
|
};
|
||||||
|
let body = build_video_body(&req).unwrap();
|
||||||
|
let obj = body.as_object().unwrap();
|
||||||
|
assert!(!obj.contains_key("aspect_ratio"));
|
||||||
|
assert!(!obj.contains_key("seed"));
|
||||||
|
assert!(!obj.contains_key("image_urls"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_poll_parameters_differ_image_vs_video() {
|
||||||
|
// The submit→poll state machine reads interval/timeout from config; lock
|
||||||
|
// the shipped defaults so a regression in JimengConfig is caught here.
|
||||||
|
let cfg = JimengConfig::default();
|
||||||
|
assert_eq!(cfg.image_poll_interval_secs, 2);
|
||||||
|
assert_eq!(cfg.image_timeout_secs, 120);
|
||||||
|
assert_eq!(cfg.video_poll_interval_secs, 8);
|
||||||
|
assert_eq!(cfg.video_timeout_secs, 600);
|
||||||
|
// Video is the longer-running capability: bigger interval and timeout.
|
||||||
|
assert!(cfg.video_poll_interval_secs > cfg.image_poll_interval_secs);
|
||||||
|
assert!(cfg.video_timeout_secs > cfg.image_timeout_secs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_classifies_invalid_access_key_as_auth_failed() {
|
||||||
|
let body = r#"{"ResponseMetadata":{"Error":{"Code":"InvalidAccessKeyId","Message":"bad ak"}}}"#;
|
||||||
|
assert!(matches!(
|
||||||
|
classify_verify_response(403, body),
|
||||||
|
JimengVerifyOutcome::AuthFailed { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_treats_non_auth_gateway_error_as_authenticated() {
|
||||||
|
// Signature accepted; the gateway only complains about our sentinel
|
||||||
|
// task id shape. That still proves the credentials are valid.
|
||||||
|
let body = r#"{"ResponseMetadata":{"Error":{"Code":"InvalidParameter","Message":"task_id invalid"}}}"#;
|
||||||
|
assert_eq!(
|
||||||
|
classify_verify_response(400, body),
|
||||||
|
JimengVerifyOutcome::Authenticated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_treats_business_envelope_as_authenticated() {
|
||||||
|
// The CV actions return a flat `{code, message, data}` envelope; reaching
|
||||||
|
// it means the gateway routed us past signature validation.
|
||||||
|
let body = r#"{"code":50404,"message":"task not found","data":null}"#;
|
||||||
|
assert_eq!(
|
||||||
|
classify_verify_response(200, body),
|
||||||
|
JimengVerifyOutcome::Authenticated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_falls_back_to_http_status_when_unparseable() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_verify_response(200, "not json"),
|
||||||
|
JimengVerifyOutcome::Authenticated
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
classify_verify_response(500, "<html>oops</html>"),
|
||||||
|
JimengVerifyOutcome::Indeterminate { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_error_code_matcher_is_case_insensitive() {
|
||||||
|
assert!(is_auth_error_code("SignatureDoesNotMatch"));
|
||||||
|
assert!(is_auth_error_code("InvalidCredential"));
|
||||||
|
assert!(is_auth_error_code("AccessDenied"));
|
||||||
|
assert!(is_auth_error_code("InvalidSecurityToken"));
|
||||||
|
assert!(!is_auth_error_code("InvalidParameter"));
|
||||||
|
assert!(!is_auth_error_code("QuotaExceeded"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,4 +349,122 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, JimengError::Cancelled));
|
assert!(matches!(err, JimengError::Cancelled));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A non-success code carrying a (bogus) `done` status in `data`. Used to
|
||||||
|
/// prove `decide` consults the business code *before* `data.status`.
|
||||||
|
fn err_code_with_done_status(code: i64) -> GetResultResponse {
|
||||||
|
GetResultResponse {
|
||||||
|
code,
|
||||||
|
message: Some("m".into()),
|
||||||
|
data: Some(GetResultData {
|
||||||
|
status: Some("done".into()),
|
||||||
|
image_urls: Some(vec!["https://x/should-be-ignored.png".into()]),
|
||||||
|
binary_data_base64: None,
|
||||||
|
video_url: Some("https://x/should-be-ignored.mp4".into()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decide_checks_code_before_status() {
|
||||||
|
// Fatal code wins over a "done" status: must Fail, never Done.
|
||||||
|
assert!(matches!(
|
||||||
|
decide(JimengCapability::Image, &err_code_with_done_status(50411)),
|
||||||
|
PollDecision::Fail(JimengError::Api { code: 50411, .. })
|
||||||
|
));
|
||||||
|
// Retryable code wins over a "done" status: must Backoff, never Done.
|
||||||
|
assert_eq!(
|
||||||
|
decide(JimengCapability::Video, &err_code_with_done_status(50429)),
|
||||||
|
PollDecision::Backoff(50429)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decide_not_found_is_terminal() {
|
||||||
|
assert!(matches!(
|
||||||
|
decide(JimengCapability::Video, &resp(10000, Some("not_found"), None)),
|
||||||
|
PollDecision::Fail(JimengError::NotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Driver variant that records every sleep duration instead of sleeping, so
|
||||||
|
/// backoff math is observable. `interval` is 1s to make the multipliers read
|
||||||
|
/// as whole seconds.
|
||||||
|
async fn run_sequence_capturing_sleeps(
|
||||||
|
capability: JimengCapability,
|
||||||
|
seq: Vec<GetResultResponse>,
|
||||||
|
) -> (Result<JimengOutput, JimengError>, Vec<Duration>) {
|
||||||
|
let idx = RefCell::new(0usize);
|
||||||
|
let seq = RefCell::new(seq);
|
||||||
|
let sleeps = RefCell::new(Vec::new());
|
||||||
|
let out = poll_until_done(
|
||||||
|
capability,
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_secs(3600),
|
||||||
|
|| async {
|
||||||
|
let mut i = idx.borrow_mut();
|
||||||
|
let s = seq.borrow();
|
||||||
|
let item = s.get(*i).cloned().unwrap_or_else(|| s.last().cloned().unwrap());
|
||||||
|
*i += 1;
|
||||||
|
Ok(item)
|
||||||
|
},
|
||||||
|
|| async { false },
|
||||||
|
|d| {
|
||||||
|
sleeps.borrow_mut().push(d);
|
||||||
|
async {}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
(out, sleeps.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn driver_backoff_grows_exponentially_and_caps() {
|
||||||
|
// Five retryable codes then done: delays double (1,2,4,8) and cap at 8.
|
||||||
|
let seq = vec![
|
||||||
|
resp(50429, None, None),
|
||||||
|
resp(50429, None, None),
|
||||||
|
resp(50429, None, None),
|
||||||
|
resp(50429, None, None),
|
||||||
|
resp(50429, None, None),
|
||||||
|
resp(10000, Some("done"), Some("https://x/v.mp4")),
|
||||||
|
];
|
||||||
|
let (out, sleeps) = run_sequence_capturing_sleeps(JimengCapability::Video, seq).await;
|
||||||
|
assert!(out.is_ok());
|
||||||
|
let secs: Vec<u64> = sleeps.iter().map(Duration::as_secs).collect();
|
||||||
|
assert_eq!(secs, vec![1, 2, 4, 8, 8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn driver_backoff_resets_after_a_normal_wait() {
|
||||||
|
// retryable, retryable, then a normal Wait (generating) must reset the
|
||||||
|
// multiplier back to 1 before the next retryable backs off afresh.
|
||||||
|
let seq = vec![
|
||||||
|
resp(50429, None, None), // backoff -> sleep 1, mult 1->2
|
||||||
|
resp(50429, None, None), // backoff -> sleep 2, mult 2->4
|
||||||
|
resp(10000, Some("generating"), None), // Wait -> sleep 1, mult reset to 1
|
||||||
|
resp(50429, None, None), // backoff -> sleep 1, mult 1->2
|
||||||
|
resp(10000, Some("done"), Some("https://x/v.mp4")),
|
||||||
|
];
|
||||||
|
let (out, sleeps) = run_sequence_capturing_sleeps(JimengCapability::Video, seq).await;
|
||||||
|
assert!(out.is_ok());
|
||||||
|
let secs: Vec<u64> = sleeps.iter().map(Duration::as_secs).collect();
|
||||||
|
assert_eq!(secs, vec![1, 2, 1, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn driver_retryable_then_fatal_stops() {
|
||||||
|
// A transient code may be followed by a fatal one; the fatal must end
|
||||||
|
// the loop with its Api error rather than retrying forever.
|
||||||
|
let seq = vec![
|
||||||
|
resp(50429, None, None), // retryable QPS
|
||||||
|
resp(50411, None, None), // fatal input moderation
|
||||||
|
];
|
||||||
|
let (out, _sleeps) =
|
||||||
|
run_sequence_capturing_sleeps(JimengCapability::Image, seq).await;
|
||||||
|
assert!(matches!(
|
||||||
|
out,
|
||||||
|
Err(JimengError::Api { code: 50411, retryable: false, .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,4 +379,73 @@ mod tests {
|
|||||||
assert_eq!(parsed.code, 50429);
|
assert_eq!(parsed.code, 50429);
|
||||||
assert!(parsed.data.is_none());
|
assert!(parsed.data.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_audio_moderation_codes_are_capability_independent() {
|
||||||
|
// 50516/50517 are documented as "video only" but `classify` does not
|
||||||
|
// gate them by capability — they are retryable for both. Pin that
|
||||||
|
// actual behavior so a future capability gate is a conscious change.
|
||||||
|
for code in [50516, 50517] {
|
||||||
|
assert_eq!(classify(code, JimengCapability::Video), CodeClass::Retryable);
|
||||||
|
assert_eq!(classify(code, JimengCapability::Image), CodeClass::Retryable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjacent_copyright_codes_split_by_class() {
|
||||||
|
// Input copyright pre-moderation (50518) is fatal; output copyright
|
||||||
|
// post-moderation (50519) is retryable — adjacent codes, opposite class.
|
||||||
|
assert_eq!(classify(50518, JimengCapability::Image), CodeClass::Fatal);
|
||||||
|
assert_eq!(classify(50519, JimengCapability::Image), CodeClass::Retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moderation_service_exception_range_is_fatal() {
|
||||||
|
// The whole 50520..=50522 upstream-exception range is fatal for both
|
||||||
|
// capabilities (covers the inclusive upper bound 50522 explicitly).
|
||||||
|
for code in [50520, 50521, 50522] {
|
||||||
|
assert_eq!(classify(code, JimengCapability::Image), CodeClass::Fatal);
|
||||||
|
assert_eq!(classify(code, JimengCapability::Video), CodeClass::Fatal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internal_error_is_fatal_for_image_via_classify() {
|
||||||
|
// Complements the `retryable()` helper test with a direct CodeClass
|
||||||
|
// assertion: 50500/50501 are Fatal for image, Retryable for video.
|
||||||
|
for code in [50500, 50501] {
|
||||||
|
assert_eq!(classify(code, JimengCapability::Image), CodeClass::Fatal);
|
||||||
|
assert_eq!(classify(code, JimengCapability::Video), CodeClass::Retryable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_code_is_fatal() {
|
||||||
|
// Defensive: out-of-domain codes never loop the poller.
|
||||||
|
assert_eq!(classify(-1, JimengCapability::Image), CodeClass::Fatal);
|
||||||
|
assert!(!retryable(-1, JimengCapability::Video));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn done_image_response_parses_base64() {
|
||||||
|
// Default GetResult output (no return_url) carries base64, not urls.
|
||||||
|
let raw = r#"{"code":10000,"message":"Success","data":{"status":"done","binary_data_base64":["aGVsbG8="]}}"#;
|
||||||
|
let parsed: GetResultResponse = serde_json::from_str(raw).unwrap();
|
||||||
|
assert_eq!(parsed.code, CODE_SUCCESS);
|
||||||
|
assert_eq!(parsed.task_status(), TaskStatus::Done);
|
||||||
|
let data = parsed.data.unwrap();
|
||||||
|
assert!(data.image_urls.is_none());
|
||||||
|
assert_eq!(data.binary_data_base64.unwrap(), vec!["aGVsbG8=".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_with_absent_data_field_parses_to_none() {
|
||||||
|
// `data` omitted entirely (not just null) must still decode (serde
|
||||||
|
// default) and report Unknown status without panicking.
|
||||||
|
let submit: SubmitResponse = serde_json::from_str(r#"{"code":50429}"#).unwrap();
|
||||||
|
assert!(submit.data.is_none());
|
||||||
|
let result: GetResultResponse = serde_json::from_str(r#"{"code":50429}"#).unwrap();
|
||||||
|
assert!(result.data.is_none());
|
||||||
|
assert_eq!(result.task_status(), TaskStatus::Unknown);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1861,6 +1861,12 @@ export const api = {
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
platformUserId?: number;
|
platformUserId?: number;
|
||||||
credentialMode?: "auto" | "session" | "apikey";
|
credentialMode?: "auto" | "session" | "apikey";
|
||||||
|
// Jimeng connections (platform === "jimeng") carry a Volcengine AK/SK pair
|
||||||
|
// plus optional region/host/session token instead of a single Bearer token.
|
||||||
|
secretKey?: string;
|
||||||
|
region?: string;
|
||||||
|
host?: string;
|
||||||
|
sessionToken?: string;
|
||||||
}) =>
|
}) =>
|
||||||
request("/api/accounts/verify-token", {
|
request("/api/accounts/verify-token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
+149
-13
@@ -94,6 +94,12 @@ function createTokenForm(credentialMode: "session" | "apikey" = "session") {
|
|||||||
tokenExpiresAt: "",
|
tokenExpiresAt: "",
|
||||||
credentialMode,
|
credentialMode,
|
||||||
skipModelFetch: false,
|
skipModelFetch: false,
|
||||||
|
// Jimeng connections (platform === "jimeng") carry a Volcengine AK/SK pair
|
||||||
|
// plus optional region/host/session token. accessToken doubles as the AK.
|
||||||
|
jimengSecretKey: "",
|
||||||
|
jimengRegion: "",
|
||||||
|
jimengHost: "",
|
||||||
|
jimengSessionToken: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +296,8 @@ export default function Accounts() {
|
|||||||
);
|
);
|
||||||
const isSub2ApiSelected =
|
const isSub2ApiSelected =
|
||||||
(selectedTokenSite?.platform || "").toLowerCase() === "sub2api";
|
(selectedTokenSite?.platform || "").toLowerCase() === "sub2api";
|
||||||
|
const isJimengSelected =
|
||||||
|
(selectedTokenSite?.platform || "").toLowerCase() === "jimeng";
|
||||||
const activeAddCredentialMode =
|
const activeAddCredentialMode =
|
||||||
activeSegment === "apikey" ? "apikey" : "session";
|
activeSegment === "apikey" ? "apikey" : "session";
|
||||||
const createIntentPreset = useMemo(
|
const createIntentPreset = useMemo(
|
||||||
@@ -456,7 +464,7 @@ export default function Accounts() {
|
|||||||
|
|
||||||
const handleVerifyToken = async () => {
|
const handleVerifyToken = async () => {
|
||||||
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
||||||
if (isBatchApiKeyInput) {
|
if (!isJimengSelected && isBatchApiKeyInput) {
|
||||||
toast.info(
|
toast.info(
|
||||||
`检测到 ${parsedApiKeys.length} 个 API Key,批量模式会在添加时逐条校验`,
|
`检测到 ${parsedApiKeys.length} 个 API Key,批量模式会在添加时逐条校验`,
|
||||||
);
|
);
|
||||||
@@ -473,10 +481,20 @@ export default function Accounts() {
|
|||||||
? parseInt(tokenForm.platformUserId)
|
? parseInt(tokenForm.platformUserId)
|
||||||
: undefined,
|
: undefined,
|
||||||
credentialMode,
|
credentialMode,
|
||||||
|
...(isJimengSelected
|
||||||
|
? {
|
||||||
|
secretKey: tokenForm.jimengSecretKey.trim() || undefined,
|
||||||
|
region: tokenForm.jimengRegion.trim() || undefined,
|
||||||
|
host: tokenForm.jimengHost.trim() || undefined,
|
||||||
|
sessionToken: tokenForm.jimengSessionToken.trim() || undefined,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
setVerifyResult(result);
|
setVerifyResult(result);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.tokenType === "apikey") {
|
if (result.tokenType === "jimeng") {
|
||||||
|
toast.success("即梦签名验证成功(AK/SK 可用)");
|
||||||
|
} else if (result.tokenType === "apikey") {
|
||||||
toast.success(
|
toast.success(
|
||||||
`API Key 验证成功(可用模型 ${result.modelCount || 0} 个)`,
|
`API Key 验证成功(可用模型 ${result.modelCount || 0} 个)`,
|
||||||
);
|
);
|
||||||
@@ -500,7 +518,12 @@ export default function Accounts() {
|
|||||||
|
|
||||||
const handleTokenAdd = async () => {
|
const handleTokenAdd = async () => {
|
||||||
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
||||||
|
if (isJimengSelected && !verifyResult?.success) {
|
||||||
|
toast.error("请先验证即梦 AK/SK 成功后再添加连接");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
|
!isJimengSelected &&
|
||||||
!isBatchApiKeyInput &&
|
!isBatchApiKeyInput &&
|
||||||
!verifyResult?.success &&
|
!verifyResult?.success &&
|
||||||
!tokenForm.skipModelFetch
|
!tokenForm.skipModelFetch
|
||||||
@@ -516,7 +539,8 @@ export default function Accounts() {
|
|||||||
siteId: tokenForm.siteId,
|
siteId: tokenForm.siteId,
|
||||||
username: tokenForm.username.trim() || undefined,
|
username: tokenForm.username.trim() || undefined,
|
||||||
accessToken: tokenForm.accessToken,
|
accessToken: tokenForm.accessToken,
|
||||||
accessTokens: isBatchApiKeyInput ? parsedApiKeys : undefined,
|
accessTokens:
|
||||||
|
!isJimengSelected && isBatchApiKeyInput ? parsedApiKeys : undefined,
|
||||||
platformUserId: tokenForm.platformUserId
|
platformUserId: tokenForm.platformUserId
|
||||||
? parseInt(tokenForm.platformUserId)
|
? parseInt(tokenForm.platformUserId)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -530,7 +554,23 @@ export default function Accounts() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
credentialMode,
|
credentialMode,
|
||||||
skipModelFetch: tokenForm.skipModelFetch,
|
skipModelFetch: tokenForm.skipModelFetch,
|
||||||
|
...(isJimengSelected
|
||||||
|
? {
|
||||||
|
secretKey: tokenForm.jimengSecretKey.trim() || undefined,
|
||||||
|
region: tokenForm.jimengRegion.trim() || undefined,
|
||||||
|
host: tokenForm.jimengHost.trim() || undefined,
|
||||||
|
sessionToken: tokenForm.jimengSessionToken.trim() || undefined,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
// Jimeng connections never join the OpenAI route table: no model sync,
|
||||||
|
// no route rebuild. Just close, toast, refresh and bail.
|
||||||
|
if (isJimengSelected) {
|
||||||
|
closeAddPanel();
|
||||||
|
toast.success("即梦连接已添加(AK/SK 已保存,SK 已掩码)");
|
||||||
|
load(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const createdAccountIds = result?.batch
|
const createdAccountIds = result?.batch
|
||||||
? Array.isArray(result?.data)
|
? Array.isArray(result?.data)
|
||||||
? result.data
|
? result.data
|
||||||
@@ -1510,14 +1550,18 @@ export default function Accounts() {
|
|||||||
|
|
||||||
const canAddVerifiedConnection = Boolean(
|
const canAddVerifiedConnection = Boolean(
|
||||||
verifyResult?.success &&
|
verifyResult?.success &&
|
||||||
((activeSegment === "apikey" && verifyResult.tokenType === "apikey") ||
|
((activeSegment === "apikey" &&
|
||||||
|
(verifyResult.tokenType === "apikey" ||
|
||||||
|
verifyResult.tokenType === "jimeng")) ||
|
||||||
(activeSegment === "session" && verifyResult.tokenType === "session")),
|
(activeSegment === "session" && verifyResult.tokenType === "session")),
|
||||||
);
|
);
|
||||||
const canSubmitApiKeyConnection =
|
const canSubmitApiKeyConnection =
|
||||||
activeSegment === "apikey"
|
activeSegment === "apikey"
|
||||||
? isBatchApiKeyInput ||
|
? isJimengSelected
|
||||||
canAddVerifiedConnection ||
|
? canAddVerifiedConnection
|
||||||
!!tokenForm.skipModelFetch
|
: isBatchApiKeyInput ||
|
||||||
|
canAddVerifiedConnection ||
|
||||||
|
!!tokenForm.skipModelFetch
|
||||||
: canAddVerifiedConnection;
|
: canAddVerifiedConnection;
|
||||||
const accountsListBusy =
|
const accountsListBusy =
|
||||||
!loaded ||
|
!loaded ||
|
||||||
@@ -2449,7 +2493,11 @@ export default function Accounts() {
|
|||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="粘贴 API Key"
|
placeholder={
|
||||||
|
isJimengSelected
|
||||||
|
? "粘贴 Access Key(AK)"
|
||||||
|
: "粘贴 API Key"
|
||||||
|
}
|
||||||
value={tokenForm.accessToken}
|
value={tokenForm.accessToken}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTokenForm((f) => ({
|
setTokenForm((f) => ({
|
||||||
@@ -2466,7 +2514,67 @@ export default function Accounts() {
|
|||||||
resize: "none" as const,
|
resize: "none" as const,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{parsedApiKeys.length > 0 && (
|
{isJimengSelected && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
placeholder="Secret Key(SK,必填)"
|
||||||
|
type="password"
|
||||||
|
value={tokenForm.jimengSecretKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTokenForm((f) => ({
|
||||||
|
...f,
|
||||||
|
jimengSecretKey: e.target.value,
|
||||||
|
credentialMode: "apikey",
|
||||||
|
}));
|
||||||
|
setVerifyResult(null);
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<input
|
||||||
|
placeholder="region(默认 cn-north-1)"
|
||||||
|
value={tokenForm.jimengRegion}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTokenForm((f) => ({
|
||||||
|
...f,
|
||||||
|
jimengRegion: e.target.value.trim(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="host(默认 visual.volcengineapi.com)"
|
||||||
|
value={tokenForm.jimengHost}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTokenForm((f) => ({
|
||||||
|
...f,
|
||||||
|
jimengHost: e.target.value.trim(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
placeholder="session token(STS,可选)"
|
||||||
|
value={tokenForm.jimengSessionToken}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTokenForm((f) => ({
|
||||||
|
...f,
|
||||||
|
jimengSessionToken: e.target.value.trim(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
即梦走火山 V4 签名(非 OpenAI 协议):填 AK/SK
|
||||||
|
后点「验证」做签名连通性探测,不会调用 /models,也不进路由表。SK
|
||||||
|
保存后以 •••• 掩码回显。
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isJimengSelected && parsedApiKeys.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
@@ -2476,9 +2584,12 @@ export default function Accounts() {
|
|||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
{!isJimengSelected && (
|
||||||
支持换行、空格、逗号批量粘贴多个 API Key。
|
<div style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
</div>
|
支持换行、空格、逗号批量粘贴多个 API Key。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isJimengSelected && (
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
>
|
>
|
||||||
@@ -2501,6 +2612,8 @@ export default function Accounts() {
|
|||||||
若站点要求 New-Api-User / User-ID,请在这里提前填写。
|
若站点要求 New-Api-User / User-ID,请在这里提前填写。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{!isJimengSelected && (
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -2524,6 +2637,25 @@ export default function Accounts() {
|
|||||||
/>
|
/>
|
||||||
<span>跳过模型验证(直接添加 API Key)</span>
|
<span>跳过模型验证(直接添加 API Key)</span>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
{verifyResult &&
|
||||||
|
verifyResult.success &&
|
||||||
|
verifyResult.tokenType === "jimeng" && (
|
||||||
|
<div className="alert alert-info animate-scale-in">
|
||||||
|
<div className="alert-title">
|
||||||
|
即梦签名验证成功(AK/SK 可用)
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
火山网关已接受 V4 签名。保存后 SK 将以 •••• 掩码回显。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{verifyResult &&
|
{verifyResult &&
|
||||||
verifyResult.success &&
|
verifyResult.success &&
|
||||||
verifyResult.tokenType === "apikey" && (
|
verifyResult.tokenType === "apikey" && (
|
||||||
@@ -2610,7 +2742,9 @@ export default function Accounts() {
|
|||||||
verifying ||
|
verifying ||
|
||||||
!tokenForm.siteId ||
|
!tokenForm.siteId ||
|
||||||
!tokenForm.accessToken ||
|
!tokenForm.accessToken ||
|
||||||
isBatchApiKeyInput
|
(isJimengSelected
|
||||||
|
? !tokenForm.jimengSecretKey.trim()
|
||||||
|
: isBatchApiKeyInput)
|
||||||
}
|
}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{
|
style={{
|
||||||
@@ -2623,6 +2757,8 @@ export default function Accounts() {
|
|||||||
<span className="spinner spinner-sm" />
|
<span className="spinner spinner-sm" />
|
||||||
验证中...
|
验证中...
|
||||||
</>
|
</>
|
||||||
|
) : isJimengSelected ? (
|
||||||
|
"验证 AK/SK 签名"
|
||||||
) : isBatchApiKeyInput ? (
|
) : isBatchApiKeyInput ? (
|
||||||
"批量添加时校验"
|
"批量添加时校验"
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ type ApprovalPrompt = {
|
|||||||
risk: string;
|
risk: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AskPrompt = {
|
||||||
|
requestId: string;
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type SessionUsage = {
|
type SessionUsage = {
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
@@ -88,11 +94,16 @@ export default function AgentWorkbench() {
|
|||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const [usage, setUsage] = useState<SessionUsage | null>(null);
|
const [usage, setUsage] = useState<SessionUsage | null>(null);
|
||||||
const [approval, setApproval] = useState<ApprovalPrompt | null>(null);
|
const [approval, setApproval] = useState<ApprovalPrompt | null>(null);
|
||||||
|
const [ask, setAsk] = useState<AskPrompt | null>(null);
|
||||||
|
|
||||||
|
// Lets the user stop a running turn: aborting the fetch drops the SSE stream,
|
||||||
|
// which the backend observes and uses to cancel the agent loop.
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||||
}, [messages, approval]);
|
}, [messages, approval, ask]);
|
||||||
|
|
||||||
const authToken = useCallback(
|
const authToken = useCallback(
|
||||||
() => getSessionToken(audience, localStorage) ?? '',
|
() => getSessionToken(audience, localStorage) ?? '',
|
||||||
@@ -132,6 +143,25 @@ export default function AgentWorkbench() {
|
|||||||
[authToken, toast],
|
[authToken, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const submitAnswer = useCallback(
|
||||||
|
async (requestId: string, answer: string) => {
|
||||||
|
setAsk(null);
|
||||||
|
try {
|
||||||
|
await fetch('/api/agent/answer', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authToken()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ request_id: requestId, answer }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error('提交回答失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authToken, toast],
|
||||||
|
);
|
||||||
|
|
||||||
const handleEvent = useCallback(
|
const handleEvent = useCallback(
|
||||||
(e: SseEvent) => {
|
(e: SseEvent) => {
|
||||||
const d = e.data ?? {};
|
const d = e.data ?? {};
|
||||||
@@ -179,6 +209,19 @@ export default function AgentWorkbench() {
|
|||||||
risk: String(d.risk ?? ''),
|
risk: String(d.risk ?? ''),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'ask':
|
||||||
|
setAsk({
|
||||||
|
requestId: String(d.request_id ?? ''),
|
||||||
|
question: String(d.question ?? ''),
|
||||||
|
options: Array.isArray(d.options)
|
||||||
|
? (d.options as unknown[]).map((o) => String(o))
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'ask_resolved':
|
||||||
|
// Agent moved on (answered elsewhere, or timed out) — drop the modal.
|
||||||
|
setAsk((cur) => (cur && cur.requestId === d.request_id ? null : cur));
|
||||||
|
break;
|
||||||
case 'result':
|
case 'result':
|
||||||
setUsage({
|
setUsage({
|
||||||
inputTokens: Number(d.input_tokens ?? 0),
|
inputTokens: Number(d.input_tokens ?? 0),
|
||||||
@@ -212,6 +255,7 @@ export default function AgentWorkbench() {
|
|||||||
|
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setApproval(null);
|
setApproval(null);
|
||||||
|
setAsk(null);
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ role: 'user', text: prompt, tools: [] },
|
{ role: 'user', text: prompt, tools: [] },
|
||||||
@@ -219,6 +263,8 @@ export default function AgentWorkbench() {
|
|||||||
]);
|
]);
|
||||||
setInput('');
|
setInput('');
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/agent/chat', {
|
const response = await fetch('/api/agent/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -231,6 +277,7 @@ export default function AgentWorkbench() {
|
|||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
permission_mode: mode,
|
permission_mode: mode,
|
||||||
}),
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await response.text().catch(() => '');
|
const detail = await response.text().catch(() => '');
|
||||||
@@ -238,22 +285,36 @@ export default function AgentWorkbench() {
|
|||||||
}
|
}
|
||||||
await readSse(response, handleEvent);
|
await readSse(response, handleEvent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
|
if (controller.signal.aborted) {
|
||||||
updateAssistant((m) => {
|
// User pressed stop — not an error.
|
||||||
m.text += '\n\n[连接中断]';
|
updateAssistant((m) => {
|
||||||
});
|
m.text += '\n\n[已停止]';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
updateAssistant((m) => {
|
||||||
|
m.text += '\n\n[连接中断]';
|
||||||
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
abortRef.current = null;
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
setApproval(null);
|
setApproval(null);
|
||||||
|
setAsk(null);
|
||||||
}
|
}
|
||||||
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]);
|
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const newSession = useCallback(() => {
|
const newSession = useCallback(() => {
|
||||||
if (running) return;
|
if (running) return;
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionId(null);
|
setSessionId(null);
|
||||||
setUsage(null);
|
setUsage(null);
|
||||||
setApproval(null);
|
setApproval(null);
|
||||||
|
setAsk(null);
|
||||||
}, [running]);
|
}, [running]);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 12, padding: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 12, padding: 16 }}>
|
||||||
@@ -340,6 +401,14 @@ export default function AgentWorkbench() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ask-user-question modal */}
|
||||||
|
{ask && (
|
||||||
|
<AskModal
|
||||||
|
prompt={ask}
|
||||||
|
onSubmit={(answer) => submitAnswer(ask.requestId, answer)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Composer */}
|
{/* Composer */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -370,6 +439,21 @@ export default function AgentWorkbench() {
|
|||||||
>
|
>
|
||||||
{running ? '运行中…' : '发送'}
|
{running ? '运行中…' : '发送'}
|
||||||
</button>
|
</button>
|
||||||
|
{running && (
|
||||||
|
<button
|
||||||
|
onClick={stop}
|
||||||
|
style={{
|
||||||
|
padding: '0 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#ef4444',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -480,3 +564,106 @@ function ApprovalModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AskModal({
|
||||||
|
prompt,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
prompt: AskPrompt;
|
||||||
|
onSubmit: (answer: string) => void;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [freeText, setFreeText] = useState('');
|
||||||
|
|
||||||
|
const toggle = (opt: string) => {
|
||||||
|
setSelected((cur) =>
|
||||||
|
cur.includes(opt) ? cur.filter((o) => o !== opt) : [...cur, opt],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combine chosen options and any free-form text into one reply.
|
||||||
|
const compose = (): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (selected.length > 0) parts.push(selected.join(', '));
|
||||||
|
const extra = freeText.trim();
|
||||||
|
if (extra) parts.push(extra);
|
||||||
|
return parts.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const answer = compose();
|
||||||
|
const canSubmit = answer.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg, #1a1a1a)',
|
||||||
|
border: '1px solid var(--border-color, #333)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
width: 'min(520px, 92vw)',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16 }}>Agent 提问</h3>
|
||||||
|
<p style={{ margin: '0 0 12px', fontSize: 14, lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{prompt.question}
|
||||||
|
</p>
|
||||||
|
{prompt.options.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||||
|
{prompt.options.map((opt) => (
|
||||||
|
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(opt)}
|
||||||
|
onChange={() => toggle(opt)}
|
||||||
|
/>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={freeText}
|
||||||
|
onChange={(e) => setFreeText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canSubmit) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(answer);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={prompt.options.length > 0 ? '补充说明(可选)…' : '输入你的回答…'}
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%', boxSizing: 'border-box', padding: 10, borderRadius: 8, resize: 'vertical', fontFamily: 'inherit', fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(answer)}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: canSubmit ? 'var(--primary, #4a9eff)' : 'var(--border-color, #333)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: canSubmit ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
提交回答
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+19
-2
@@ -707,6 +707,7 @@ const SITE_PLATFORM_OPTIONS = [
|
|||||||
{ value: 'gemini', label: 'gemini', description: '通用 Gemini / Google AI 兼容接口' },
|
{ value: 'gemini', label: 'gemini', description: '通用 Gemini / Google AI 兼容接口' },
|
||||||
{ value: 'cliproxyapi', label: 'cliproxyapi', description: 'CPA接入口' },
|
{ value: 'cliproxyapi', label: 'cliproxyapi', description: 'CPA接入口' },
|
||||||
{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API(数字人/图片视频)' },
|
{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API(数字人/图片视频)' },
|
||||||
|
{ value: 'jimeng', label: '即梦 (火山视觉)', description: '火山引擎即梦 AIGC,V4 签名调用,异步生成(无 /models 探测)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sites() {
|
export default function Sites() {
|
||||||
@@ -1942,7 +1943,21 @@ export default function Sites() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setForm((prev) => ({ ...prev, platform: value }));
|
setForm((prev) => {
|
||||||
|
// Jimeng's real requests carry Action/Version query params the
|
||||||
|
// URL field can't express; prefill the Volcengine endpoint as a
|
||||||
|
// placeholder when the field is empty so operators aren't asked
|
||||||
|
// to invent one.
|
||||||
|
if (value === 'jimeng') {
|
||||||
|
const currentUrl = prev.url.trim();
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
platform: value,
|
||||||
|
url: currentUrl ? prev.url : 'https://visual.volcengineapi.com',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...prev, platform: value };
|
||||||
|
});
|
||||||
setSelectedInitializationPresetId(null);
|
setSelectedInitializationPresetId(null);
|
||||||
}}
|
}}
|
||||||
options={platformOptions}
|
options={platformOptions}
|
||||||
@@ -1995,6 +2010,7 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{form.platform.trim() !== 'jimeng' && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -2086,6 +2102,7 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -2288,7 +2305,7 @@ export default function Sites() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditing && (
|
{isEditing && form.platform.trim() !== 'jimeng' && (
|
||||||
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
|
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>刷新后自动测试请求</div>
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>刷新后自动测试请求</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user