mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
fix: publish v1.21.0 updater assets
This commit is contained in:
+2
-1
@@ -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 批次通过
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user