fix: publish v1.21.0 updater assets

This commit is contained in:
coso
2026-04-28 18:53:46 +08:00
parent 5a05575cde
commit 61fa90905f
9 changed files with 2108 additions and 1347 deletions
+2 -1
View File
@@ -13,6 +13,7 @@
#### 1. 稳定版更新与 R2 发布链路
- `.github/workflows/release.yml` 补齐稳定版 updater 发布门禁,要求签名密钥与更新地址就绪后再生成 updater artifacts。
- 发布流程会规范化 Tauri updater 公钥,并在仅产出 sidecar `.sig` 时由发布脚本生成稳定版 `latest.json`。
- 新增 `scripts/release-updater-manifest.mjs`,聚合各平台 `latest.json`,生成统一 `latest.json`、版本化清单、R2 上传计划与 manifest metadata。
- 新增 `scripts/plan-r2-release-cleanup.mjs`,按稳定版本窗口规划旧 R2 updater 产物清理,避免发布桶无限增长。
- `scripts/release-updater-manifest.test.mjs` 覆盖平台缺失、版本不匹配、同名跨平台 artifact 与旧版本清理保护逻辑。
@@ -86,7 +87,7 @@
- 已通过:
- `npm run verify:app-version`
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`
- `cargo test --manifest-path "src-tauri/Cargo.toml"` — 1109 passed / 0 failed / 2 ignored
- `cargo test --manifest-path "src-tauri/Cargo.toml"` — 1112 passed / 0 failed / 2 ignored
- `cargo clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features`
- `npm run lint`
- `npm test` — 44 个 Vitest smart 批次通过
+146 -50
View File
@@ -11,6 +11,12 @@ const DEFAULT_REQUIRED_PLATFORMS = [
"windows-x86_64",
];
const PLATFORM_BY_TARGET_TRIPLE = {
"aarch64-apple-darwin": "darwin-aarch64",
"x86_64-apple-darwin": "darwin-x86_64",
"x86_64-pc-windows-msvc": "windows-x86_64",
};
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
@@ -139,6 +145,70 @@ function buildPlatformAssetPath(platformKey, assetName) {
return `${encodePathSegment(platformKey)}/${encodePathSegment(assetName)}`;
}
function normalizeSignatureContent(content) {
const trimmed = String(content || "").trim();
if (!trimmed) {
return "";
}
const compact = trimmed.replace(/\s+/g, "");
if (/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) {
try {
const decoded = Buffer.from(compact, "base64").toString("utf8");
if (decoded.includes("untrusted comment: signature")) {
return compact;
}
} catch {
// fall through and treat it as raw minisign content below
}
}
if (trimmed.includes("untrusted comment: signature")) {
return Buffer.from(`${trimmed}\n`, "utf8").toString("base64");
}
return trimmed;
}
function platformKeyFromAssetFile(assetsDir, assetFile) {
const relativeDir = path.relative(assetsDir, path.dirname(assetFile));
const targetTriple = relativeDir.split(path.sep).find(Boolean);
return PLATFORM_BY_TARGET_TRIPLE[targetTriple] || "";
}
function collectSignedUpdaterArtifacts(files, assetsDir) {
const fileSet = new Set(files);
return files
.filter((file) => {
const name = path.basename(file);
return (
!name.endsWith(".sig") &&
!/^latest.*\.json$/i.test(name) &&
fileSet.has(`${file}.sig`)
);
})
.map((assetFile) => {
const platformKey = platformKeyFromAssetFile(assetsDir, assetFile);
if (!platformKey) {
return null;
}
const signatureFile = `${assetFile}.sig`;
const signature = normalizeSignatureContent(
fs.readFileSync(signatureFile, "utf8"),
);
if (!signature) {
throw new Error(`${signatureFile} has empty updater signature`);
}
return {
assetFile,
assetName: path.basename(assetFile),
platformKey,
signature,
};
})
.filter(Boolean);
}
function collectUpdaterManifest(options) {
const assetsDir = path.resolve(options.assetsDir);
const baseUrl = normalizeBaseUrl(options.baseUrl);
@@ -154,66 +224,92 @@ function collectUpdaterManifest(options) {
.map((value) => String(value).trim())
.filter(Boolean);
if (latestFiles.length === 0) {
throw new Error(`no latest*.json files found under ${assetsDir}`);
}
const platforms = {};
const referencedFiles = new Map();
let notes = options.notes || "";
let pubDate = options.pubDate || "";
for (const manifestFile of latestFiles) {
const manifest = readJson(manifestFile);
const manifestVersion = normalizeStableVersion(manifest.version);
if (manifestVersion !== version) {
throw new Error(
`${manifestFile} version ${manifest.version} does not match release ${versionTag}`,
if (latestFiles.length > 0) {
for (const manifestFile of latestFiles) {
const manifest = readJson(manifestFile);
const manifestVersion = normalizeStableVersion(manifest.version);
if (manifestVersion !== version) {
throw new Error(
`${manifestFile} version ${manifest.version} does not match release ${versionTag}`,
);
}
if (!notes && typeof manifest.notes === "string") {
notes = manifest.notes;
}
if (!pubDate && typeof manifest.pub_date === "string") {
pubDate = manifest.pub_date;
}
for (const [platformKey, platform] of Object.entries(
manifest.platforms || {},
)) {
const sourceUrl = String(platform?.url || "").trim();
const signature = String(platform?.signature || "").trim();
if (!sourceUrl || !signature) {
throw new Error(
`${manifestFile} platform ${platformKey} has empty url or signature`,
);
}
if (platforms[platformKey]) {
throw new Error(
`${manifestFile} duplicates updater platform ${platformKey}`,
);
}
const assetName = basenameFromUrl(sourceUrl);
const candidates = filesByBasename.get(assetName) || [];
const assetFile = resolveReferencedAssetFile(manifestFile, candidates);
if (!assetFile) {
throw new Error(
`${manifestFile} platform ${platformKey} references missing asset: ${assetName}`,
);
}
const platformAssetPath = buildPlatformAssetPath(platformKey, assetName);
const targetUrl = `${baseUrl}/lime/${channel}/${versionTag}/${platformAssetPath}`;
platforms[platformKey] = {
signature,
url: targetUrl,
};
referencedFiles.set(platformAssetPath, {
assetName,
file: assetFile,
platformKey,
targetPath: platformAssetPath,
});
}
}
} else {
const signedArtifacts = collectSignedUpdaterArtifacts(files, assetsDir);
if (signedArtifacts.length === 0) {
throw new Error(`no latest*.json files found under ${assetsDir}`);
}
for (const artifact of signedArtifacts) {
if (platforms[artifact.platformKey]) {
throw new Error(
`${artifact.assetFile} duplicates updater platform ${artifact.platformKey}`,
);
}
const platformAssetPath = buildPlatformAssetPath(
artifact.platformKey,
artifact.assetName,
);
}
if (!notes && typeof manifest.notes === "string") {
notes = manifest.notes;
}
if (!pubDate && typeof manifest.pub_date === "string") {
pubDate = manifest.pub_date;
}
for (const [platformKey, platform] of Object.entries(
manifest.platforms || {},
)) {
const sourceUrl = String(platform?.url || "").trim();
const signature = String(platform?.signature || "").trim();
if (!sourceUrl || !signature) {
throw new Error(
`${manifestFile} platform ${platformKey} has empty url or signature`,
);
}
if (platforms[platformKey]) {
throw new Error(
`${manifestFile} duplicates updater platform ${platformKey}`,
);
}
const assetName = basenameFromUrl(sourceUrl);
const candidates = filesByBasename.get(assetName) || [];
const assetFile = resolveReferencedAssetFile(manifestFile, candidates);
if (!assetFile) {
throw new Error(
`${manifestFile} platform ${platformKey} references missing asset: ${assetName}`,
);
}
const platformAssetPath = buildPlatformAssetPath(platformKey, assetName);
const targetUrl = `${baseUrl}/lime/${channel}/${versionTag}/${platformAssetPath}`;
platforms[platformKey] = {
signature,
platforms[artifact.platformKey] = {
signature: artifact.signature,
url: targetUrl,
};
referencedFiles.set(platformAssetPath, {
assetName,
file: assetFile,
platformKey,
assetName: artifact.assetName,
file: artifact.assetFile,
platformKey: artifact.platformKey,
targetPath: platformAssetPath,
});
}
+74
View File
@@ -194,6 +194,80 @@ describe("release updater manifest", () => {
.sort(),
).toEqual(["arm", "x64"]);
});
it("没有 latest.json 时应从 Tauri 签名产物生成清单", () => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), "lime-release-signed-assets-"),
);
const assetsDir = path.join(root, "release-assets");
const rawSignature = [
"untrusted comment: signature from tauri secret key",
"RUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=",
"trusted comment: timestamp:1777371807\tfile:Lime.app.tar.gz",
"MYL3b9yoLwH4W4MRmQQhvOOg9XdKEJkrScotKO5F4kdJ4OZTulm0GmFfp+vweCaiBahx5I+m3FCYP//z/l/gAA==",
].join("\n");
const encodedSignature = Buffer.from(`${rawSignature}\n`, "utf8").toString(
"base64",
);
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
writeFile(
path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz.sig"),
encodedSignature,
);
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz"),
);
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz.sig"),
rawSignature,
);
writeFile(
path.join(
assetsDir,
"x86_64-pc-windows-msvc",
"Lime_1.20.0_x64-setup.exe",
),
);
writeFile(
path.join(
assetsDir,
"x86_64-pc-windows-msvc",
"Lime_1.20.0_x64-setup.exe.sig",
),
encodedSignature,
);
const result = collectUpdaterManifest({
assetsDir,
baseUrl: "https://updates.limecloud.com",
channel: "stable",
notes: "notes",
requiredPlatforms: [
"darwin-aarch64",
"darwin-x86_64",
"windows-x86_64",
],
version: "v1.20.0",
});
expect(result.manifest.version).toBe("1.20.0");
expect(result.manifest.notes).toBe("notes");
expect(result.manifest.platforms["darwin-aarch64"].signature).toBe(
encodedSignature,
);
expect(result.manifest.platforms["darwin-x86_64"].signature).toBe(
encodedSignature,
);
expect(result.manifest.platforms["windows-x86_64"].url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/windows-x86_64/Lime_1.20.0_x64-setup.exe",
);
expect(result.r2UploadPlan.map((item) => item.key).sort()).toEqual([
"lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
"lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz",
"lime/stable/v1.20.0/windows-x86_64/Lime_1.20.0_x64-setup.exe",
]);
});
});
describe("R2 release cleanup", () => {
@@ -59,6 +59,7 @@ const ASTER_AUTO_COMPACTION_COMPLETE_TEXT: &str = "Compaction complete";
const ASTER_AUTO_COMPACTION_THINKING_TEXT: &str = "aster is compacting the conversation...";
const ASTER_AUTO_COMPACTION_ERROR_PREFIX: &str = "Ran into this error trying to compact:";
const ASTER_AUTO_COMPACTION_DISABLED_TEXT: &str = "Automatic compaction is disabled for this turn. The conversation reached the context limit. Compact the session manually or start a new session before retrying.";
const OPTIONAL_WEB_SEARCH_PREFLIGHT_TIMEOUT_MS: u64 = 1_500;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
@@ -160,6 +161,19 @@ impl WebSearchExecutionTracker {
}
}
fn absorb(&mut self, mut other: Self) {
for tool_id in other.ordered_tool_ids.drain(..) {
if self.attempts_by_id.contains_key(&tool_id) {
continue;
}
if let Some(record) = other.attempts_by_id.remove(&tool_id) {
self.ordered_tool_ids.push(tool_id.clone());
self.attempts_by_id.insert(tool_id, record);
}
}
}
pub fn validate_web_search_requirement(
&self,
policy: &RequestToolPolicy,
@@ -1801,16 +1815,49 @@ where
{
let message_text = user_message.as_concat_text();
let mut web_search_tracker = WebSearchExecutionTracker::default();
let preflight = execute_web_search_preflight_if_needed(
agent,
&session_config.id,
&message_text,
working_directory,
cancel_token.clone(),
request_tool_policy,
&mut web_search_tracker,
)
.await;
// 对于 Required 模式,preflight 是必须的,必须等待完成
// 对于 Allowed 模式,preflight 是可选优化,设置超时避免阻塞首字
let preflight = if request_tool_policy.requires_web_search() {
execute_web_search_preflight_if_needed(
agent,
&session_config.id,
&message_text,
working_directory,
cancel_token.clone(),
request_tool_policy,
&mut web_search_tracker,
)
.await
} else {
let mut optional_preflight_tracker = WebSearchExecutionTracker::default();
match tokio::time::timeout(
std::time::Duration::from_millis(OPTIONAL_WEB_SEARCH_PREFLIGHT_TIMEOUT_MS),
execute_web_search_preflight_if_needed(
agent,
&session_config.id,
&message_text,
working_directory,
cancel_token.clone(),
request_tool_policy,
&mut optional_preflight_tracker,
),
)
.await
{
Ok(result) => {
web_search_tracker.absorb(optional_preflight_tracker);
result
}
Err(_) => {
tracing::warn!(
"[AsterAgent][TTFT] optional web search preflight timed out ({}ms), proceeding without preflight context",
OPTIONAL_WEB_SEARCH_PREFLIGHT_TIMEOUT_MS
);
Ok(PreflightToolExecution::none())
}
}
};
let preflight_execution = match preflight {
Ok(preflight_execution) => {
session_config.system_prompt = merge_system_prompt_with_web_search_preflight_context(
@@ -2466,6 +2513,41 @@ mod tests {
assert!(err.contains("尝试记录"));
}
#[test]
fn tracker_absorb_preserves_completed_optional_preflight_attempts() {
let policy = resolve_request_tool_policy(Some(true), false);
let mut target = WebSearchExecutionTracker::default();
let mut optional_tracker = WebSearchExecutionTracker::default();
optional_tracker.record_tool_start(&policy, "tool-1", "WebSearch");
optional_tracker.record_tool_end(&policy, "tool-1", true, None);
target.absorb(optional_tracker);
assert_eq!(target.ordered_tool_ids, vec!["tool-1".to_string()]);
assert_eq!(
target
.attempts_by_id
.get("tool-1")
.and_then(|record| record.success),
Some(true)
);
}
#[test]
fn discarded_optional_preflight_attempt_should_not_force_synthesis_retry() {
let diagnostics = StreamEventDiagnostics::default();
let mode = resolve_reply_retry_mode(
&PreflightToolExecution::none(),
"",
&WebSearchExecutionTracker::default(),
&diagnostics,
&[],
);
assert_eq!(mode, ReplyRetryMode::DirectAnswer);
}
#[test]
fn empty_final_reply_with_only_tool_events_should_not_fallback() {
let diagnostics = StreamEventDiagnostics {
@@ -21,7 +21,9 @@ use aster::tools::{ConfigTool, SkillTool};
use lime_agent::AgentEvent as RuntimeAgentEvent;
use lime_core::workspace::WorkspaceSettings;
use regex::Regex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Instant;
use tauri::Manager;
const ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE: &str = "artifact_document_repaired";
@@ -3710,6 +3712,7 @@ async fn execute_runtime_stream_attempt(
request,
);
let images_for_provider = resolve_runtime_forwarded_images(request);
let stream_timing = RuntimeStreamTiming::new();
let execution = stream_reply_once(
agent,
@@ -3725,6 +3728,7 @@ async fn execute_runtime_stream_attempt(
let app = app.clone();
let event_name = request.event_name.clone();
let timeline_recorder = timeline_recorder.clone();
let stream_timing = stream_timing.clone();
move |event| {
record_runtime_stream_event(
&run_observation,
@@ -3734,6 +3738,7 @@ async fn execute_runtime_stream_attempt(
workspace_root,
request_metadata,
provider_continuation_capability,
&stream_timing,
event,
);
}
@@ -3762,6 +3767,93 @@ async fn execute_runtime_stream_attempt(
Ok(execution.text_output)
}
#[derive(Clone)]
struct RuntimeStreamTiming {
started_at: Instant,
first_delta_emitted: Arc<AtomicBool>,
}
impl RuntimeStreamTiming {
fn new() -> Self {
Self {
started_at: Instant::now(),
first_delta_emitted: Arc::new(AtomicBool::new(false)),
}
}
fn mark_direct_emit(&self, event_name: &str, event: &RuntimeAgentEvent) {
let has_visible_delta = match event {
RuntimeAgentEvent::TextDelta { text } | RuntimeAgentEvent::ThinkingDelta { text } => {
!text.is_empty()
}
_ => false,
};
if !has_visible_delta {
return;
}
if self
.first_delta_emitted
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
tracing::info!(
"[AsterAgent][TTFT] 首个 runtime delta 已透传: event_name={}, elapsed_ms={}",
event_name,
self.started_at.elapsed().as_millis()
);
}
}
fn should_emit_runtime_stream_event_directly(event: &RuntimeAgentEvent) -> bool {
!matches!(
event,
RuntimeAgentEvent::ItemStarted { .. }
| RuntimeAgentEvent::ItemUpdated { .. }
| RuntimeAgentEvent::ItemCompleted { .. }
| RuntimeAgentEvent::ArtifactSnapshot { .. }
| RuntimeAgentEvent::ContextCompactionStarted { .. }
| RuntimeAgentEvent::ContextCompactionCompleted { .. }
| RuntimeAgentEvent::Warning { .. }
| RuntimeAgentEvent::Error { .. }
| RuntimeAgentEvent::Message { .. }
)
}
fn should_record_runtime_stream_event_on_timeline(event: &RuntimeAgentEvent) -> bool {
matches!(
event,
RuntimeAgentEvent::TurnStarted { .. }
| RuntimeAgentEvent::ItemStarted { .. }
| RuntimeAgentEvent::ItemUpdated { .. }
| RuntimeAgentEvent::ItemCompleted { .. }
| RuntimeAgentEvent::ArtifactSnapshot { .. }
| RuntimeAgentEvent::ContextCompactionStarted { .. }
| RuntimeAgentEvent::ContextCompactionCompleted { .. }
| RuntimeAgentEvent::Warning { .. }
| RuntimeAgentEvent::Error { .. }
)
}
fn emit_direct_runtime_stream_event(
app: &AppHandle,
event_name: &str,
stream_timing: &RuntimeStreamTiming,
event: &RuntimeAgentEvent,
) {
stream_timing.mark_direct_emit(event_name, event);
if let Err(error) = app.emit(event_name, event) {
tracing::warn!(
"[AsterAgent] 发送实时运行时事件失败: event_name={}, error={}",
event_name,
error
);
}
}
async fn remove_code_execution_extension_if_added(
agent: &Agent,
added_code_execution: &mut bool,
@@ -4069,8 +4161,13 @@ fn record_runtime_stream_event(
workspace_root: &str,
request_metadata: Option<&serde_json::Value>,
provider_continuation_capability: ProviderContinuationCapability,
stream_timing: &RuntimeStreamTiming,
event: &RuntimeAgentEvent,
) {
if should_emit_runtime_stream_event_directly(event) {
emit_direct_runtime_stream_event(app, event_name, stream_timing, event);
}
let mut observation = match run_observation.lock() {
Ok(guard) => guard,
Err(error) => {
@@ -4084,6 +4181,11 @@ fn record_runtime_stream_event(
request_metadata,
provider_continuation_capability,
);
if !should_record_runtime_stream_event_on_timeline(event) {
return;
}
let mut recorder = match timeline_recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
@@ -4615,6 +4717,7 @@ async fn maybe_auto_compact_runtime_session_before_turn(
request_event_name: &str,
workspace_settings: &WorkspaceSettings,
) -> Result<(), String> {
let check_started_at = Instant::now();
let session = read_session(session_id, true, "读取自动压缩会话失败").await?;
let provider_scope = prepare_auxiliary_provider_scope(
state,
@@ -4640,11 +4743,22 @@ async fn maybe_auto_compact_runtime_session_before_turn(
provider_scope.restore(state, db).await;
if !should_compact_result? {
tracing::debug!(
"[AsterAgent][TTFT] 自动压缩检查跳过: session_id={}, elapsed_ms={}",
session_id,
check_started_at.elapsed().as_millis()
);
return Ok(());
}
tracing::info!(
"[AsterAgent][TTFT] 自动压缩开始阻塞当前 turn: session_id={}, check_elapsed_ms={}",
session_id,
check_started_at.elapsed().as_millis()
);
let compact_started_at = Instant::now();
let auto_event_name = build_auto_context_compaction_event_name(session_id);
if let Err(error) = compact_runtime_session_with_trigger(
match compact_runtime_session_with_trigger(
app,
state,
db,
@@ -4655,17 +4769,27 @@ async fn maybe_auto_compact_runtime_session_before_turn(
)
.await
{
tracing::warn!(
"[AsterAgent] 自动压缩上下文失败,已降级继续当前 turn: session_id={}, error={}",
session_id,
error
);
let warning_event = RuntimeAgentEvent::Warning {
code: Some(AUTO_CONTEXT_COMPACTION_FAILED_WARNING_CODE.to_string()),
message: format!("自动压缩上下文失败,已继续当前请求:{error}"),
};
if let Err(emit_error) = app.emit(request_event_name, &warning_event) {
tracing::warn!("[AsterAgent] 发送自动压缩失败提醒失败: {}", emit_error);
Ok(()) => {
tracing::info!(
"[AsterAgent][TTFT] 自动压缩完成,继续当前 turn: session_id={}, compact_elapsed_ms={}",
session_id,
compact_started_at.elapsed().as_millis()
);
}
Err(error) => {
tracing::warn!(
"[AsterAgent] 自动压缩上下文失败,已降级继续当前 turn: session_id={}, elapsed_ms={}, error={}",
session_id,
compact_started_at.elapsed().as_millis(),
error
);
let warning_event = RuntimeAgentEvent::Warning {
code: Some(AUTO_CONTEXT_COMPACTION_FAILED_WARNING_CODE.to_string()),
message: format!("自动压缩上下文失败,已继续当前请求:{error}"),
};
if let Err(emit_error) = app.emit(request_event_name, &warning_event) {
tracing::warn!("[AsterAgent] 发送自动压缩失败提醒失败: {}", emit_error);
}
}
}
@@ -5373,6 +5497,9 @@ mod tests {
};
use async_trait::async_trait;
use chrono::Utc;
use lime_core::database::dao::agent_timeline::{
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus,
};
use lime_core::database::schema::create_tables;
use lime_services::aster_session_store::LimeSessionStore;
use rmcp::model::Tool;
@@ -5429,6 +5556,60 @@ mod tests {
}
}
fn build_runtime_turn_test_item() -> AgentThreadItem {
let now = Utc::now().to_rfc3339();
AgentThreadItem {
id: "item-test".to_string(),
thread_id: "thread-test".to_string(),
turn_id: "turn-test".to_string(),
sequence: 1,
status: AgentThreadItemStatus::InProgress,
started_at: now.clone(),
completed_at: None,
updated_at: now,
payload: AgentThreadItemPayload::AgentMessage {
text: "hello".to_string(),
phase: None,
},
}
}
#[test]
fn runtime_stream_text_delta_should_bypass_timeline_for_direct_emit() {
let event = RuntimeAgentEvent::TextDelta {
text: "首字".to_string(),
};
assert!(should_emit_runtime_stream_event_directly(&event));
assert!(!should_record_runtime_stream_event_on_timeline(&event));
}
#[test]
fn runtime_stream_item_event_should_remain_timeline_owned() {
let event = RuntimeAgentEvent::ItemStarted {
item: build_runtime_turn_test_item(),
};
assert!(!should_emit_runtime_stream_event_directly(&event));
assert!(should_record_runtime_stream_event_on_timeline(&event));
}
#[test]
fn runtime_stream_status_should_emit_without_timeline_write() {
let event = RuntimeAgentEvent::RuntimeStatus {
status: AgentRuntimeStatus {
phase: "streaming".to_string(),
title: "正在生成".to_string(),
detail: "模型已经开始返回内容。".to_string(),
checkpoints: Vec::new(),
metadata: None,
},
};
assert!(should_emit_runtime_stream_event_directly(&event));
assert!(!should_record_runtime_stream_event_on_timeline(&event));
}
#[derive(Clone)]
struct AutoCompactThresholdTestProvider {
context_limit: Option<usize>,
+76 -8
View File
@@ -465,10 +465,13 @@ describe("AppSidebar", () => {
'[data-testid="app-sidebar-account-menu"]',
);
expect(accountMenu).not.toBeNull();
expect(accountMenu?.textContent).toContain("zhong feng shan");
expect(accountMenu?.textContent).toContain("user@example.com");
expect(accountMenu?.textContent).toContain("云端已连接");
expect(accountMenu?.textContent).toContain("套餐、积分和模型目录");
expect(accountMenu?.textContent).toContain("免费版");
expect(accountMenu?.textContent).toContain("查看详情");
expect(accountMenu?.textContent).not.toContain("云端已连接");
expect(accountMenu?.textContent).not.toContain("套餐、积分和模型目录");
expect(accountMenu?.textContent).not.toContain("登录方式:");
expect(accountMenu?.textContent).not.toContain("默认服务:");
expect(accountMenu?.textContent).toContain("语言");
expect(accountMenu?.textContent).toContain("持续流程");
expect(accountMenu?.textContent).toContain("消息渠道");
@@ -495,6 +498,69 @@ describe("AppSidebar", () => {
});
});
it("已登录账号弹框应展示真实套餐摘要并可直达详情", async () => {
const onNavigate = vi.fn();
setStoredOemCloudSessionState({
token: "session-token",
tenant: { id: "tenant-0001", name: "Lime Cloud" },
user: {
id: "user-001",
displayName: "晚风",
email: "wanfeng@example.com",
},
session: { id: "session-001", provider: "google" },
});
setOemCloudBootstrapSnapshot({
features: {
referralEnabled: false,
},
providerPreference: {
providerKey: "lime-hub",
},
providerOffersSummary: [
{
providerKey: "lime-hub",
currentPlan: "免费版",
creditsSummary: "0 / 20 积分 已用0%",
},
],
});
const container = mountSidebarContainer({
currentPage: "agent",
currentPageParams: {
agentEntry: "new-task",
} as AgentPageParams,
onNavigate,
});
await flushEffects(2);
await openAccountMenu(container);
const accountMenu = container.querySelector(
'[data-testid="app-sidebar-account-menu"]',
);
expect(accountMenu?.textContent).toContain("免费版");
expect(accountMenu?.textContent).toContain("0 / 20 积分 已用0%");
expect(accountMenu?.textContent).toContain("查看详情");
expect(accountMenu?.textContent).toContain("wanfeng@example.com");
expect(accountMenu?.textContent).toContain("Lime Cloud");
expect(accountMenu?.textContent).not.toContain("登录方式:Google");
await act(async () => {
accountMenu
?.querySelector<HTMLButtonElement>(
'[data-testid="app-sidebar-cloud-account-card"]',
)
?.click();
await Promise.resolve();
});
expect(onNavigate).toHaveBeenCalledWith("settings", {
tab: SettingsTabs.Providers,
providerView: "cloud",
});
});
it("云端开启邀请时应在头部展示入口并读取 share 事实源", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
@@ -628,8 +694,9 @@ describe("AppSidebar", () => {
const accountMenu = container.querySelector(
'[data-testid="app-sidebar-account-menu"]',
);
expect(accountMenu?.textContent).toContain("开源版");
expect(accountMenu?.textContent).toContain("云端可选");
expect(accountMenu?.textContent).toContain("开源使用");
expect(accountMenu?.textContent).toContain("免费版");
expect(accountMenu?.textContent).toContain("本地模型可配置");
expect(accountMenu?.textContent).toContain("模型设置");
expect(accountMenu?.textContent).toContain("连接 Lime 云端");
expect(accountMenu?.textContent).not.toContain("退出登录");
@@ -650,7 +717,7 @@ describe("AppSidebar", () => {
);
});
it("开源版说明应折叠到信息图标中", async () => {
it("开源使用说明应折叠到信息图标中", async () => {
const container = mountSidebarContainer({
currentPage: "agent",
currentPageParams: {
@@ -667,7 +734,7 @@ describe("AppSidebar", () => {
"本地开源功能可直接使用",
);
expect(
accountMenu?.querySelector('button[aria-label="开源版说明"]'),
accountMenu?.querySelector('button[aria-label="开源使用说明"]'),
).not.toBeNull();
});
@@ -713,7 +780,8 @@ describe("AppSidebar", () => {
);
expect(accountMenu?.textContent).toContain("wanfeng@example.com");
expect(accountMenu?.textContent).toContain("Lime Cloud");
expect(accountMenu?.textContent).toContain("登录方式:Google");
expect(accountMenu?.textContent).toContain("免费版");
expect(accountMenu?.textContent).not.toContain("登录方式:Google");
});
it("用户弹框的语言入口应使用二级弹框并保存真实语言设置", async () => {
+163 -103
View File
@@ -1353,14 +1353,38 @@ const AccountMenuPopover = styled.div<{ $collapsed?: boolean }>`
const AccountPlanCard = styled.div`
width: 100%;
border: 1px solid var(--lime-card-subtle-border, rgba(226, 240, 226, 0.92));
border-radius: 15px;
background: var(--lime-surface-soft, #f8fcf9);
border-radius: 14px;
background: var(--lime-surface, #ffffff);
color: var(--lime-text, #1a3b2b);
padding: 11px 12px;
padding: 10px 11px;
text-align: left;
display: flex;
flex-direction: column;
gap: 9px;
gap: 8px;
`;
const AccountPlanButton = styled.button`
width: 100%;
border: 1px solid var(--lime-card-subtle-border, rgba(226, 240, 226, 0.92));
border-radius: 14px;
background: var(--lime-surface, #ffffff);
color: var(--lime-text, #1a3b2b);
padding: 10px 11px;
text-align: left;
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
transition:
border-color 0.16s ease,
background-color 0.16s ease,
transform 0.16s ease;
&:hover {
border-color: var(--lime-brand-soft-border, #bbf7d0);
background: var(--lime-surface-soft, #f8fcf9);
transform: translateY(-1px);
}
`;
const AccountPlanActions = styled.div`
@@ -1447,6 +1471,27 @@ const AccountPlanTitle = styled.span`
display: inline-flex;
align-items: center;
gap: 7px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const AccountPlanDetailsPill = styled.span`
flex-shrink: 0;
border-radius: 999px;
background: var(--lime-surface-soft, #f8fcf9);
color: var(--lime-text-muted, #6b826b);
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 7px;
font-size: 10px;
font-weight: 800;
svg {
width: 11px;
height: 11px;
}
`;
const AccountInfoIconButton = styled.button`
@@ -1496,31 +1541,35 @@ const AccountPlanBadge = styled.span<{ $connected?: boolean }>`
font-weight: 800;
`;
const AccountCloudIdentity = styled.span`
const AccountPlanUsage = styled.span`
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10px;
`;
const AccountCloudIdentityText = styled.span`
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
`;
const AccountCloudName = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 800;
color: var(--lime-text-strong, #0f172a);
font-size: 12px;
font-weight: 750;
color: var(--lime-text-muted, #6b826b);
`;
const AccountCloudEmail = styled.span`
const AccountPlanProgressTrack = styled.span`
display: block;
width: 100%;
height: 3px;
border-radius: 999px;
background: var(--lime-card-subtle-border, #e5e7eb);
overflow: hidden;
`;
const AccountPlanProgressFill = styled.span<{ $percent: number | null }>`
display: block;
width: ${({ $percent }) => ($percent === null ? "0%" : `${$percent}%`)};
height: 100%;
border-radius: inherit;
background: var(--lime-brand-strong, #166534);
`;
const AccountPlanMeta = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1539,17 +1588,6 @@ const AccountPlanDetail = styled.span`
color: var(--lime-text-muted, #6b826b);
`;
const AccountPlanDescription = styled.span`
display: block;
border-radius: 12px;
background: var(--lime-surface, #ffffff);
padding: 9px 10px;
font-size: 12px;
font-weight: 700;
line-height: 1.5;
color: var(--lime-text-muted, #6b826b);
`;
const AccountMenuList = styled.div`
display: flex;
flex-direction: column;
@@ -1804,36 +1842,61 @@ function resolveAccountTenantLabel(
);
}
function resolveAccountProviderLabel(
sessionState: OemCloudStoredSessionState | null,
): string {
const provider = sessionState?.session.session.provider?.trim();
if (!provider) {
return "系统账号";
}
return provider.toLowerCase() === "google" ? "Google" : provider;
}
function resolveBootstrapDefaultProviderSummary(
bootstrap: OemCloudBootstrapResponse | null,
): string | null {
const preference = bootstrap?.providerPreference;
if (!preference) {
function parseAccountUsagePercent(value: string | undefined): number | null {
if (!value) {
return null;
}
const modelSuffix = preference.defaultModel
? ` · ${preference.defaultModel}`
: "";
if (preference.providerSource === "local") {
return `本地 Key${modelSuffix}`;
const percentMatch = value.match(/(?:已用\s*)?(\d+(?:\.\d+)?)\s*%/);
if (percentMatch) {
return Math.min(100, Math.max(0, Number(percentMatch[1])));
}
const matchedOffer = bootstrap.providerOffersSummary.find(
const ratioMatch = value.match(
/([\d,]+(?:\.\d+)?)\s*\/\s*([\d,]+(?:\.\d+)?)/,
);
if (!ratioMatch) {
return null;
}
const used = Number(ratioMatch[1].replace(/,/g, ""));
const total = Number(ratioMatch[2].replace(/,/g, ""));
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) {
return null;
}
return Math.min(100, Math.max(0, (used / total) * 100));
}
function resolveAccountPlanSummary(
bootstrap: OemCloudBootstrapResponse | null,
): {
planLabel: string;
usageLabel: string | null;
usagePercent: number | null;
} {
const preference = bootstrap?.providerPreference;
if (!preference) {
return {
planLabel: "免费版",
usageLabel: null,
usagePercent: null,
};
}
const providerOffers = Array.isArray(bootstrap.providerOffersSummary)
? bootstrap.providerOffersSummary
: [];
const matchedOffer = providerOffers.find(
(offer) => offer.providerKey === preference.providerKey,
);
return `${matchedOffer?.displayName?.trim() || "Lime 云端"}${modelSuffix}`;
const usageLabel = matchedOffer?.creditsSummary?.trim() || null;
return {
planLabel: matchedOffer?.currentPlan?.trim() || "免费版",
usageLabel,
usagePercent: parseAccountUsagePercent(usageLabel ?? undefined),
};
}
function resolveAccountInitial(name: string): string {
@@ -2123,13 +2186,13 @@ export function AppSidebar({
getOemCloudBootstrapSnapshot<OemCloudBootstrapResponse>();
setCloudBootstrapState(currentBootstrap);
setCachedReferralState(
readCachedOemCloudReferralState(currentBootstrap?.session.tenant.id),
readCachedOemCloudReferralState(currentBootstrap?.session?.tenant.id),
);
return subscribeOemCloudBootstrapChanged((payload) => {
const nextBootstrap = (payload as OemCloudBootstrapResponse) ?? null;
setCloudBootstrapState(nextBootstrap);
setCachedReferralState(
readCachedOemCloudReferralState(nextBootstrap?.session.tenant.id),
readCachedOemCloudReferralState(nextBootstrap?.session?.tenant.id),
);
});
}, []);
@@ -2138,7 +2201,7 @@ export function AppSidebar({
const cachedInviteDashboard =
cloudBootstrapState?.referral ?? cachedReferralState?.dashboard ?? null;
const inviteFeatureEnabled =
cloudBootstrapState?.features.referralEnabled ??
cloudBootstrapState?.features?.referralEnabled ??
cachedReferralState?.referralEnabled ??
true;
const canLoadReferralDashboard =
@@ -2947,14 +3010,15 @@ export function AppSidebar({
const accountDisplayName = resolveAccountDisplayName(cloudSessionState);
const accountEmail = resolveAccountEmail(cloudSessionState);
const accountTenantLabel = resolveAccountTenantLabel(cloudSessionState);
const accountProviderLabel = resolveAccountProviderLabel(cloudSessionState);
const accountDefaultProviderSummary =
resolveBootstrapDefaultProviderSummary(cloudBootstrapState);
const accountPlanSummary = resolveAccountPlanSummary(cloudBootstrapState);
const cloudBrandLabel = resolveCloudBrandLabel(cloudBootstrapState);
const connectCloudLabel = `连接 ${cloudBrandLabel}`;
const accountAvatarUrl = cloudSessionState?.session.user.avatarUrl?.trim();
const accountInitial = resolveAccountInitial(accountDisplayName);
const hasCloudAccount = Boolean(cloudSessionState);
const accountMetaLine =
[accountEmail, accountTenantLabel].filter(Boolean).join(" · ") ||
accountDisplayName;
const inviteEntryVisible = inviteFeatureEnabled;
const accountButtonTooltip = hasCloudAccount
? `${accountDisplayName}${accountEmail ? ` · ${accountEmail}` : ""}`
@@ -3420,54 +3484,50 @@ export function AppSidebar({
data-testid="app-sidebar-account-menu"
>
{hasCloudAccount ? (
<AccountPlanCard data-testid="app-sidebar-cloud-account-card">
<AccountPlanButton
type="button"
aria-label="查看套餐详情"
data-testid="app-sidebar-cloud-account-card"
onClick={() =>
handleAccountMenuNavigate({
tab: SettingsTabs.Providers,
providerView: "cloud",
})
}
>
<AccountPlanHeader>
<AccountCloudIdentity>
<AccountAvatar>
{accountAvatarUrl ? (
<img
src={accountAvatarUrl}
alt={accountDisplayName}
/>
) : (
accountInitial
)}
</AccountAvatar>
<AccountCloudIdentityText>
<AccountCloudName>
{accountDisplayName}
</AccountCloudName>
<AccountCloudEmail>
{accountEmail ?? "云端账号"}
</AccountCloudEmail>
</AccountCloudIdentityText>
</AccountCloudIdentity>
<AccountPlanBadge $connected>云端已连接</AccountPlanBadge>
<AccountPlanTitle>
{accountPlanSummary.planLabel}
</AccountPlanTitle>
<AccountPlanDetailsPill>
查看详情
<ChevronRight />
</AccountPlanDetailsPill>
</AccountPlanHeader>
<AccountPlanDescription>
已连接
{accountTenantLabel
? ` ${accountTenantLabel} `
: ` ${cloudBrandLabel} `}
;套餐、积分和模型目录以云端实时状态为准。
</AccountPlanDescription>
<AccountPlanDetail>
<span>登录方式:{accountProviderLabel}</span>
<span>
默认服务:{accountDefaultProviderSummary ?? "云端同步"}
</span>
</AccountPlanDetail>
</AccountPlanCard>
{accountPlanSummary.usageLabel ? (
<>
<AccountPlanUsage>
{accountPlanSummary.usageLabel}
</AccountPlanUsage>
<AccountPlanProgressTrack aria-hidden="true">
<AccountPlanProgressFill
$percent={accountPlanSummary.usagePercent}
/>
</AccountPlanProgressTrack>
</>
) : null}
<AccountPlanMeta>{accountMetaLine}</AccountPlanMeta>
</AccountPlanButton>
) : (
<AccountPlanCard data-testid="app-sidebar-open-source-card">
<AccountPlanHeader>
<AccountPlanTitle>
<span>开源版</span>
<span>开源使用</span>
<Tooltip>
<TooltipTrigger asChild>
<AccountInfoIconButton
type="button"
aria-label="开源版说明"
aria-label="开源使用说明"
onClick={(event) => event.stopPropagation()}
>
<Info />
@@ -3478,11 +3538,11 @@ export function AppSidebar({
</TooltipContent>
</Tooltip>
</AccountPlanTitle>
<AccountPlanBadge>本地可用</AccountPlanBadge>
<AccountPlanBadge>免费版</AccountPlanBadge>
</AccountPlanHeader>
<AccountPlanDetail>
<span>不登录也可用</span>
<span>云端可选</span>
<span>本地模型可配置</span>
</AccountPlanDetail>
<AccountPlanActions>
<AccountPlanActionButton
@@ -598,16 +598,16 @@ describe("CloudProviderSettings", () => {
});
await act(async () => {
findButton(container, "刷新云端状态").dispatchEvent(
findButton(container, "刷新").dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
const text = container.textContent ?? "";
expect(text).toContain("Demo Operator");
expect(text).toContain("云端模型调用已就绪");
expect(text).toContain("购买套餐");
expect(text).toContain("Token 积分");
expect(text).toContain("使用详情");
expect(text).toContain("查看套餐");
expect(text).toContain("可用积分");
expect(text).toContain("API Key");
expect(text).toContain("SDK 调用");
expect(text).toContain("OpenAI SDK");
@@ -615,8 +615,8 @@ describe("CloudProviderSettings", () => {
expect(text).toContain("https://llm.limeai.run/v1");
expect(text).toContain("X-Lime-Tenant-ID");
expect(text).toContain("最小 curl 测试");
expect(text).toContain("云端额度");
expect(text).toContain("账本记录");
expect(text).toContain("近 14 天消耗");
expect(text).toContain("用量记录");
expect(text).toContain("Lime Hub 主服务");
expect(text).toContain("GPT-5.2 Pro");
expect(text).toContain("Lime 云端");
@@ -894,29 +894,41 @@ describe("CloudProviderSettings", () => {
const text = container.textContent ?? "";
expect(
container.querySelector(
'[data-testid="oem-cloud-commerce-compact-panel"]',
),
container.querySelector('[data-testid="cloud-commerce-dashboard"]'),
).not.toBeNull();
expect(text).toContain("购买套餐");
expect(text).toContain("查看套餐");
expect(text).toContain("Pro");
expect(text).toContain("存在待支付订单");
expect(text).toContain("待支付:Pro");
expect(text).toContain("继续支付");
expect(text).toContain("Anthropic-compatible coding");
expect(text).toContain("积分充值");
expect(text).toContain("10 万积分包");
expect(text).toContain("sk-lime-once");
expect(text).toContain("glm-4.6");
expect(text).toContain("账本记录");
expect(text).toContain("最近用量");
expect(text).toContain("已支付");
expect(text).not.toContain("套餐订单");
expect(text).not.toContain("充值订单");
expect(text).not.toContain("积分钱包");
expect(text).toContain("近 14 天消耗");
expect(text).toContain("用量记录");
expect(text).not.toContain("支付后台配置");
await act(async () => {
findButton(container, "购买套餐").dispatchEvent(
findButton(container, "账单").dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(container.textContent ?? "").toContain("已支付");
await act(async () => {
findButton(container, "查看套餐").dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(
container.querySelector('[data-testid="cloud-plan-upgrade-page"]'),
).not.toBeNull();
const upgradeText = container.textContent ?? "";
expect(upgradeText).toContain("Anthropic-compatible coding");
expect(upgradeText).toContain("补充积分");
expect(upgradeText).toContain("10 万积分包");
await act(async () => {
findButton(container, "选择套餐").dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
@@ -928,7 +940,7 @@ describe("CloudProviderSettings", () => {
});
await act(async () => {
findButton(container, "立即充值").dispatchEvent(
findButton(container, "充值").dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
File diff suppressed because it is too large Load Diff