From 61fa90905f9a69437cb6f311dbf3ab850be09c4b Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 28 Apr 2026 18:53:46 +0800 Subject: [PATCH] fix: publish v1.21.0 updater assets --- RELEASE_NOTES.md | 3 +- scripts/release-updater-manifest.mjs | 196 +- scripts/release-updater-manifest.test.mjs | 74 + .../crates/agent/src/request_tool_policy.rs | 102 +- .../commands/aster_agent_cmd/runtime_turn.rs | 205 +- src/components/AppSidebar.test.tsx | 84 +- src/components/AppSidebar.tsx | 266 +- .../agent/providers/index.test.tsx | 56 +- .../settings-v2/agent/providers/index.tsx | 2469 +++++++++-------- 9 files changed, 2108 insertions(+), 1347 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f6ff56335..6a0689fb8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 批次通过 diff --git a/scripts/release-updater-manifest.mjs b/scripts/release-updater-manifest.mjs index e9660b98c..e0b4731ce 100644 --- a/scripts/release-updater-manifest.mjs +++ b/scripts/release-updater-manifest.mjs @@ -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, }); } diff --git a/scripts/release-updater-manifest.test.mjs b/scripts/release-updater-manifest.test.mjs index f7b903c84..a14dbd67b 100644 --- a/scripts/release-updater-manifest.test.mjs +++ b/scripts/release-updater-manifest.test.mjs @@ -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", () => { diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index 594837e7a..9b40034e9 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -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 { diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index 96ed6f6fa..81b46a998 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -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, +} + +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, diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 068e35e79..40c5876f2 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -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( + '[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 () => { diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 04efc9991..857a9248a 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -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(); 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 ? ( - + + handleAccountMenuNavigate({ + tab: SettingsTabs.Providers, + providerView: "cloud", + }) + } + > - - - {accountAvatarUrl ? ( - {accountDisplayName} - ) : ( - accountInitial - )} - - - - {accountDisplayName} - - - {accountEmail ?? "云端账号"} - - - - 云端已连接 + + {accountPlanSummary.planLabel} + + + 查看详情 + + - - 已连接 - {accountTenantLabel - ? ` ${accountTenantLabel} ` - : ` ${cloudBrandLabel} `} - ;套餐、积分和模型目录以云端实时状态为准。 - - - 登录方式:{accountProviderLabel} - - 默认服务:{accountDefaultProviderSummary ?? "云端同步"} - - - + {accountPlanSummary.usageLabel ? ( + <> + + {accountPlanSummary.usageLabel} + + + + ) : null} + {accountMetaLine} + ) : ( - 开源版 + 开源使用 event.stopPropagation()} > @@ -3478,11 +3538,11 @@ export function AppSidebar({ - 本地可用 + 免费版 不登录也可用 - 云端可选 + 本地模型可配置 { }); 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 }), ); }); diff --git a/src/components/settings-v2/agent/providers/index.tsx b/src/components/settings-v2/agent/providers/index.tsx index 65dc78f0c..8907cd336 100644 --- a/src/components/settings-v2/agent/providers/index.tsx +++ b/src/components/settings-v2/agent/providers/index.tsx @@ -14,8 +14,6 @@ import { ReceiptText, RefreshCw, Search, - ShieldCheck, - Sparkles, WalletCards, } from "lucide-react"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; @@ -41,7 +39,7 @@ import type { OemCloudProviderOfferState, OemCloudProviderOfferSummary, OemCloudPaymentConfig, - OemCloudTopupPackage, + OemCloudUsageRecord, } from "@/lib/api/oemCloudControlPlane"; import { getCompanionPetStatus, @@ -119,7 +117,7 @@ function NoticeBar(props: { tone: "error" | "success"; message: string }) { ); } -function CloudReadinessPanel(props: { +function CloudCommerceStatusBar(props: { readiness: ReturnType["cloudReadiness"]; pendingPayment: ReturnType["pendingPayment"]; paymentWatcher: ReturnType["paymentWatcher"]; @@ -127,157 +125,90 @@ function CloudReadinessPanel(props: { onCreateKey: () => void; onOpenPayment?: () => void; creatingKey: boolean; + refreshing: boolean; }) { - const { readiness, pendingPayment } = props; - if (!readiness) { + const { readiness, pendingPayment, paymentWatcher } = props; + const needsAttention = Boolean( + readiness && + (readiness.status !== "ready" || pendingPayment || paymentWatcher), + ); + + if (!needsAttention) { return null; } - const ready = readiness.status === "ready"; - const warning = - readiness.status === "quota_low" || readiness.status === "payment_pending"; - const primaryLabel = - readiness.status === "no_api_key" - ? "创建 API Key" - : readiness.status === "payment_pending" - ? "刷新支付状态" - : readiness.nextAction || "刷新云端状态"; - const primaryAction = - readiness.status === "no_api_key" ? props.onCreateKey : props.onRefresh; + const isPendingPayment = readiness?.status === "payment_pending"; + const isMissingKey = readiness?.status === "no_api_key"; const canOpenPayment = Boolean( - readiness.status === "payment_pending" && pendingPayment?.paymentReference?.trim() && /^https?:\/\//i.test(pendingPayment.paymentReference), ); + const title = pendingPayment + ? `待支付:${pendingPayment.title || pendingPayment.orderId}` + : readiness?.title || "云端状态需要处理"; + const description = pendingPayment + ? `${formatMoneyCents(pendingPayment.amountCents)} · ${pendingPayment.paymentChannel || "支付渠道"}` + : readiness?.description || readiness?.nextAction || "刷新后同步最新状态。"; + const actionLabel = canOpenPayment + ? "继续支付" + : isMissingKey + ? "创建 API Key" + : isPendingPayment + ? "刷新支付状态" + : "刷新状态"; + const action = canOpenPayment + ? props.onOpenPayment + : isMissingKey + ? props.onCreateKey + : props.onRefresh; return (
-
-
+
+
-

- {readiness.title} -

+

{title}

- {readiness.description ? ( -

- {readiness.description} -

- ) : null} - {pendingPayment ? ( -
- 待支付:{pendingPayment.title || pendingPayment.orderId} ·{" "} - {formatMoneyCents(pendingPayment.amountCents)} ·{" "} - {pendingPayment.paymentChannel || "未配置渠道"} -
- ) : null} - {props.paymentWatcher ? ( -
- {props.paymentWatcher.status === "waiting" ? ( - - ) : props.paymentWatcher.status === "confirmed" ? ( - - ) : ( - - )} - - {props.paymentWatcher.title} ·{" "} - {props.paymentWatcher.message || "正在确认支付状态"} - {props.paymentWatcher.status === "waiting" - ? `(第 ${props.paymentWatcher.attempts} 次)` - : ""} - -
- ) : null} -
-
- {canOpenPayment ? ( - - ) : null} - +

+ {paymentWatcher + ? `${paymentWatcher.title} · ${paymentWatcher.message || "正在确认支付状态"}` + : description} +

+
- - {readiness.steps.length > 0 ? ( -
- {readiness.steps.map((step) => ( -
-
- {step.done ? ( - - ) : ( - - )} - {step.label} -
- {step.description ? ( -

- {step.description} -

- ) : null} -
- ))} -
- ) : null}
); } @@ -452,51 +383,16 @@ function formatWalletStatusLabel(value?: string): string { } } -type CompactCommerceRecord = { - id: string; - kind: string; - title: string; - statusLabel: string; - statusTone: "slate" | "emerald" | "amber"; - detail: string; -}; +type CommerceTone = "slate" | "emerald" | "amber"; -function resolvePaidCommerceTone( - value?: string, -): CompactCommerceRecord["statusTone"] { +function resolvePaidCommerceTone(value?: string): CommerceTone { return String(value).toLowerCase() === "paid" ? "emerald" : "amber"; } -function resolveActiveCommerceTone( - value?: string, -): CompactCommerceRecord["statusTone"] { +function resolveActiveCommerceTone(value?: string): CommerceTone { return String(value).toLowerCase() === "active" ? "emerald" : "slate"; } -function CommerceRecordRow(props: { record: CompactCommerceRecord }) { - return ( -
-
-
- - {props.record.kind} - - - {props.record.title} - -
- -
-

- {props.record.detail} -

-
- ); -} - function resolveInitialPlanCycle( plan: OemCloudEntitlementPlan, ): OemCloudBillingCycle | undefined { @@ -506,19 +402,138 @@ function resolveInitialPlanCycle( )?.key; } -function resolvePlanCycle( +function resolvePlanCycleByKey( plan: OemCloudEntitlementPlan, - selectedCycles: Record, + selectedCycle: OemCloudBillingCycle, ) { - const selected = selectedCycles[plan.id]; return ( - plan.billingCycles.find((item) => item.key === selected) ?? + plan.billingCycles.find((item) => item.key === selectedCycle) ?? plan.billingCycles.find( (item) => item.key === resolveInitialPlanCycle(plan), - ) + ) ?? + plan.billingCycles[0] ); } +function buildUsagePercent(used: number, limit: number): number { + if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) { + return 0; + } + + return Math.min(100, Math.max(0, Math.round((used / limit) * 100))); +} + +function formatUsageRatio(used: number, limit: number): string { + if (limit <= 0) { + return formatCreditAmount(used); + } + + return `${formatCreditAmount(used)} / ${formatCreditAmount(limit)}`; +} + +type DailyUsageBucket = { + key: string; + label: string; + credits: number; + tokens: number; + count: number; +}; + +function formatDateKey(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function formatDayLabel(dateKey: string): string { + const [, month = "", day = ""] = dateKey.split("-"); + if (!month || !day) { + return dateKey; + } + + return `${Number(month)}/${Number(day)}`; +} + +function buildDailyUsageBuckets( + records: OemCloudUsageRecord[], + days = 14, +): DailyUsageBucket[] { + const parsedDates = records + .map((record) => new Date(record.createdAt)) + .filter((date) => Number.isFinite(date.getTime())); + const endDate = parsedDates.length + ? new Date(Math.max(...parsedDates.map((date) => date.getTime()))) + : new Date(); + endDate.setHours(0, 0, 0, 0); + + const buckets = new Map(); + for (let index = days - 1; index >= 0; index -= 1) { + const date = new Date(endDate); + date.setDate(endDate.getDate() - index); + const key = formatDateKey(date); + buckets.set(key, { + key, + label: formatDayLabel(key), + credits: 0, + tokens: 0, + count: 0, + }); + } + + records.forEach((record) => { + const date = new Date(record.createdAt); + if (!Number.isFinite(date.getTime())) { + return; + } + date.setHours(0, 0, 0, 0); + const key = formatDateKey(date); + const bucket = buckets.get(key); + if (!bucket) { + return; + } + bucket.credits += record.credits; + bucket.tokens += record.tokens; + bucket.count += 1; + }); + + return Array.from(buckets.values()); +} + +function formatUsageStatusLabel(value?: string): string { + switch (String(value || "").toLowerCase()) { + case "charged": + case "success": + case "succeeded": + return "已结算"; + case "reserved": + return "预占中"; + case "released": + return "已释放"; + case "failed": + return "失败"; + default: + return value || "未知"; + } +} + +function formatSubscriptionPeriod( + start?: string, + end?: string, + fallback?: string, +): string { + if (start && end) { + return `${formatOemCloudDateTime(start)} - ${formatOemCloudDateTime(end)}`; + } + if (end) { + return `有效至 ${formatOemCloudDateTime(end)}`; + } + if (fallback) { + return fallback; + } + return "以服务端权益为准"; +} + function copyTextToClipboard(value: string) { if (typeof navigator === "undefined" || !navigator.clipboard) { return; @@ -1695,9 +1710,11 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { useState(defaultView); const [selectedPaymentOptionId, setSelectedPaymentOptionId] = useState(""); - const [selectedPlanCycles, setSelectedPlanCycles] = useState< - Record - >({}); + const [selectedPlanBillingCycle, setSelectedPlanBillingCycle] = + useState("monthly"); + const [cloudCommerceView, setCloudCommerceView] = useState< + "usage" | "billing" | "plans" + >("usage"); const [modelSearch, setModelSearch] = useState(""); const cloudLoginAutoOpenKeyRef = useRef(null); @@ -1790,6 +1807,51 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { [paymentOptions, selectedPaymentOptionId], ); const selectedPaymentConfig = selectedPaymentOption?.config ?? null; + const availablePlanBillingCycles = useMemo(() => { + const cycleMap = new Map(); + plans.forEach((plan) => { + plan.billingCycles.forEach((cycle) => { + if (!cycleMap.has(cycle.key)) { + cycleMap.set( + cycle.key, + cycle.label || formatBillingCycleLabel(cycle.key), + ); + } + }); + }); + + const order = ["monthly", "yearly", "one_time"]; + return Array.from(cycleMap.entries()) + .map(([key, label]) => ({ key, label })) + .sort((left, right) => { + const leftIndex = order.indexOf(left.key); + const rightIndex = order.indexOf(right.key); + if (leftIndex === -1 && rightIndex === -1) { + return left.label.localeCompare(right.label, "zh-CN"); + } + if (leftIndex === -1) { + return 1; + } + if (rightIndex === -1) { + return -1; + } + return leftIndex - rightIndex; + }); + }, [plans]); + + useEffect(() => { + if (availablePlanBillingCycles.length === 0) { + return; + } + if ( + !availablePlanBillingCycles.some( + (cycle) => cycle.key === selectedPlanBillingCycle, + ) + ) { + setSelectedPlanBillingCycle(availablePlanBillingCycles[0].key); + } + }, [availablePlanBillingCycles, selectedPlanBillingCycle]); + const rawBillingCurrency = billingDashboard?.billingSummary.currency ?? ""; const billingCurrency = /^[A-Z]{3}$/.test(rawBillingCurrency) ? rawBillingCurrency @@ -1845,50 +1907,59 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { ? creditsDashboard.creditOrders : creditTopupOrders; const displayedCreditWallets = creditsDashboard?.creditWallets ?? []; - const featuredPlan = - plans.find((plan) => plan.recommended) ?? plans[0] ?? null; - const featuredPlanCycle = featuredPlan - ? resolvePlanCycle(featuredPlan, selectedPlanCycles) - : undefined; - const featuredPlanIsCurrent = Boolean( - featuredPlan && - (subscription?.planId === featuredPlan.id || - subscription?.planKey === featuredPlan.key), + const creditBreakdownItems = [ + { + key: "free", + label: "每日基础积分", + used: monthlyUsageSummary?.freeCreditsUsed ?? 0, + limit: monthlyUsageSummary?.freeCreditsLimit ?? 0, + tone: "sky", + }, + { + key: "subscription", + label: "套餐积分", + used: monthlyUsageSummary?.subscriptionCreditsUsed ?? 0, + limit: monthlyUsageSummary?.subscriptionCreditsLimit ?? 0, + tone: "emerald", + }, + { + key: "topup", + label: "补充积分", + used: monthlyUsageSummary?.topupCreditsUsed ?? 0, + limit: monthlyUsageSummary?.topupCreditsLimit ?? 0, + tone: "amber", + }, + ]; + const monthlyUsagePercent = buildUsagePercent( + monthlyUsedCredits, + monthlyLimitCredits, ); - const featuredPlanActionLabel = featuredPlanIsCurrent - ? "续费套餐" - : subscription - ? "升级套餐" - : "购买套餐"; - const featuredTopupPackage = - topupPackages.find((item) => item.recommended) ?? topupPackages[0] ?? null; - const commerceRecords: CompactCommerceRecord[] = [ - ...displayedPlanOrders.map((order: OemCloudOrder) => ({ - id: `plan-${order.id}`, - kind: "套餐", - title: order.planName || order.planKey || "套餐订单", - statusLabel: formatOrderStatusLabel(order.status), - statusTone: resolvePaidCommerceTone(order.status), - detail: `${formatMoneyCents(order.amountCents, billingCurrency)} · ${formatCreditAmount(order.creditsGranted)} credits · ${formatOemCloudDateTime(order.paidAt || order.createdAt)}`, - })), - ...displayedCreditOrders.map((order: OemCloudCreditTopupOrder) => ({ - id: `topup-${order.id}`, - kind: "充值", - title: order.packageName || "积分包", - statusLabel: formatOrderStatusLabel(order.status), - statusTone: resolvePaidCommerceTone(order.status), - detail: `${formatMoneyCents(order.amountCents, billingCurrency)} · ${formatCreditAmount(order.creditsGranted)} credits · ${formatOemCloudDateTime(order.paidAt || order.createdAt)}`, - })), - ...displayedCreditWallets.map((wallet: OemCloudCreditWallet) => ({ - id: `wallet-${wallet.id}`, - kind: "钱包", - title: wallet.packageName || wallet.sourceType || "积分包", - statusLabel: formatWalletStatusLabel(wallet.status), - statusTone: resolveActiveCommerceTone(wallet.status), - detail: `剩余 ${formatCreditAmount(wallet.remainingCredits)} / 共 ${formatCreditAmount(wallet.grantedCredits)} · 到期 ${formatOemCloudDateTime(wallet.expiresAt)}`, - })), - ].slice(0, 3); - const recentUsageRecords = usageDashboard?.usageRecords.slice(0, 3) ?? []; + const dailyUsageBuckets = useMemo( + () => buildDailyUsageBuckets(usageDashboard?.usageRecords ?? [], 14), + [usageDashboard?.usageRecords], + ); + const maxDailyUsageCredits = Math.max( + 1, + ...dailyUsageBuckets.map((item) => item.credits), + ); + const recentUsageRecords = usageDashboard?.usageRecords.slice(0, 8) ?? []; + const recentPlanOrders = displayedPlanOrders.slice(0, 8); + const recentCreditOrders = displayedCreditOrders.slice(0, 8); + const subscriptionPeriodLabel = formatSubscriptionPeriod( + subscription?.currentPeriodStart || + billingDashboard?.billingSummary.currentPeriodStart, + subscription?.currentPeriodEnd || + billingDashboard?.billingSummary.currentPeriodEnd, + billingDashboard?.billingSummary.renewalAt + ? `下次续费 ${formatOemCloudDateTime(billingDashboard.billingSummary.renewalAt)}` + : undefined, + ); + const accountIdentityLabel = + session?.user.displayName || + session?.user.email || + session?.user.username || + session?.user.id || + "已登录"; const pendingPaymentReference = pendingPayment?.paymentReference; const canOpenPendingPayment = Boolean( pendingPaymentReference && /^https?:\/\//i.test(pendingPaymentReference), @@ -1925,7 +1996,7 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { 当前版本未配置云端服务

- 本地功能可以继续使用;云端登录入口需要由品牌服务配置后才会显示。 + 本地功能可直接使用;连接入口由品牌服务下发后显示。

@@ -1946,21 +2017,19 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { 正在打开 {cloudBrandLabel} 登录

- 登录完成后会自动回到客户端,同步套餐、积分、API Key 和云端模型目录。 + 登录完成后自动同步套餐、积分、API Key 和模型目录。

-
- -
+ @@ -1971,526 +2040,607 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { ) : null}
-
-
-
-
- -
-
-
-

- {session.user.displayName || session.user.email || "已登录"} -

- - {loadingCommerce ? ( - - ) : null} -
-

- {session.user.email || - session.user.username || - session.user.id} -

-
-
-
- -
- - -
-
-
- - void handleRefresh()} - onCreateKey={() => void handleCreateAccessToken()} - onOpenPayment={() => { - if ( - pendingPayment?.paymentReference && - /^https?:\/\//i.test(pendingPayment.paymentReference) - ) { - void openUrl(pendingPayment.paymentReference); - } - }} - creatingKey={managingToken === "create"} - /> - -
-
-
-
+
+
+
-

- 云端额度 -

+ {loadingCommerce ? ( ) : null} + +
+
+

当前套餐

+

+ {currentPlanName} +

+

+ {subscriptionPeriodLabel} · {accountIdentityLabel} +

-

- 套餐、积分和账本记录统一收在这里。 -

- - - {canOpenPendingPayment ? ( - - ) : null} + +
-
+
+
+
+ 本月已用 + + {monthlyUsagePercent}% + +
+
+
+
+

+ {formatUsageRatio(monthlyUsedCredits, monthlyLimitCredits)} 积分 +

+
} - /> - } /> 0 - ? `${formatCreditAmount(monthlyUsedCredits)} / ${formatCreditAmount(monthlyLimitCredits)}` - : formatCreditAmount(monthlyUsedCredits) - } - hint="本月已消耗的免费、套餐和充值积分合计" - icon={} - /> - } />
+
+
-
- {paymentOptions.length > 0 ? ( - paymentOptions.map((option) => ( + void handleRefresh()} + onCreateKey={() => void handleCreateAccessToken()} + onOpenPayment={() => { + if (pendingPaymentReference) { + void openUrl(pendingPaymentReference); + } + }} + creatingKey={managingToken === "create"} + refreshing={refreshing} + /> + + {cloudCommerceView === "plans" ? ( +
+
+
+
- )) - ) : ( - - )} -
- -
-
-
-
-

套餐

- {plans.length > 3 ? ( - - 另有 {plans.length - 3} 个 - - ) : null} +
+

+ 选择适合你的套餐 +

+

+ 套餐、价格和权益全部来自服务端配置;购买后通过真实支付回调同步权益。 +

- {plans.length === 0 ? ( -
- 暂无可购买套餐 -
- ) : ( -
+
+ {availablePlanBillingCycles.map((cycle) => ( + + ))} +
+
- return ( -
-
-
-
-
- {plan.name} -
- {plan.recommended ? ( - - ) : plan.badge ? ( - - ) : null} - {isCurrentPlan ? ( - - ) : null} -
-

- {plan.tagline || plan.description || "套餐权益"} -

+
+ {paymentOptions.length > 0 ? ( + paymentOptions.map((option) => ( + + )) + ) : ( + + )} + {canOpenPendingPayment ? ( + + ) : null} +
+ + {plans.length === 0 ? ( +
+ 服务端暂未发布可购买套餐。 +
+ ) : ( +
+ {plans.map((plan) => { + const selectedCycle = resolvePlanCycleByKey( + plan, + selectedPlanBillingCycle, + ); + const lowerPlanKey = plan.key.toLowerCase(); + const isFreePlan = + lowerPlanKey.includes("free") || + (selectedCycle?.priceCents ?? plan.priceMonthly) <= 0; + const isCurrentPlan = + subscription?.planId === plan.id || + subscription?.planKey === plan.key || + (!subscription && isFreePlan); + const disabled = + isCurrentPlan || + !selectedPaymentConfig || + !selectedCycle || + orderingPlanId === plan.id; + const featureItems = ( + plan.featureSections.length > 0 + ? plan.featureSections.flatMap((section) => section.items) + : plan.quotaSummaries.length > 0 + ? plan.quotaSummaries.map((item) => item.value) + : plan.features + ).slice(0, 5); + const priceLabel = formatMoneyCents( + selectedCycle?.priceCents ?? plan.priceMonthly, + billingCurrency, + ); + const actionLabel = isCurrentPlan + ? "当前套餐" + : orderingPlanId === plan.id + ? "正在打开支付..." + : selectedPaymentConfig + ? subscription + ? "升级" + : "选择套餐" + : "暂不可购买"; + + return ( +
+
+
+ {isCurrentPlan ? ( + + ) : plan.recommended ? ( + + ) : plan.badge ? ( + + ) : null} +
+
+

+ {plan.name} +

+

+ {plan.tagline || + plan.description || + "服务端配置的套餐权益"} +

+
+
+ + {priceLabel} + + + / {formatBillingCycleLabel(selectedCycle?.key)} + +
+

+ 含{" "} + {formatCreditAmount( + selectedCycle?.credits ?? plan.creditsMonthly, + )}{" "} + 积分 +

+
+ +
+ {featureItems.length > 0 ? ( + featureItems.map((item) => ( +
+ + {item}
-

- {formatMoneyCents( - selectedCycle?.priceCents ?? plan.priceMonthly, - billingCurrency, - )} + )) + ) : ( +

+ 具体权益以服务端套餐配置为准。 +

+ )} +
+ + +
+ ); + })} +
+ )} + +
+
+
+

+ 补充积分 +

+

+ 余额不足时可按服务端积分包充值;没有积分包时不显示兜底套餐。 +

+
+ {topupPackages.length === 0 ? ( + + ) : null} +
+ {topupPackages.length > 0 ? ( +
+ {topupPackages.map((item) => { + const totalCredits = + item.credits + (item.bonusCredits ?? 0); + const disabled = + !selectedPaymentConfig || + creatingTopupPackageId === item.id; + return ( +
+
+
+

+ {item.name} +

+

+ {formatCreditAmount(totalCredits)} 积分 · 有效{" "} + {item.validDays} 天

+ + {formatMoneyCents(item.priceCents, billingCurrency)} + +
+ +
+ ); + })} +
+ ) : null} +
+
+
+ ) : ( +
+
+
+
+ {[ + ["usage", "使用详情"], + ["billing", "账单"], + ].map(([value, label]) => ( + + ))} +
+ +
-

- 含{" "} - {formatCreditAmount( - selectedCycle?.credits ?? plan.creditsMonthly, - )}{" "} - Token 积分 -

+ {cloudCommerceView === "usage" ? ( +
+
+ {creditBreakdownItems.map((item) => ( +
+
+

+ {item.label} +

+ + {buildUsagePercent(item.used, item.limit)}% + +
+

+ {formatUsageRatio(item.used, item.limit)} +

+
+
+
+
+ ))} +
- {plan.billingCycles.length > 1 ? ( -
- {plan.billingCycles.map((cycle) => ( - - ))} -
- ) : null} - - {features.length > 0 ? ( -
- {features.map((item) => ( -
- - {item} -
- ))} -
- ) : null} - - +
+
+
+

+ 近 14 天消耗 +

+

+ 从服务端用量记录聚合,不生成兜底数据。 +

+
+ +
+
+ {dailyUsageBuckets.map((bucket) => { + const height = + bucket.credits > 0 + ? Math.max( + 8, + Math.round( + (bucket.credits / maxDailyUsageCredits) * 100, + ), + ) + : 2; + return ( +
+
+
0 + ? "bg-slate-950" + : "bg-slate-200", + )} + style={{ height: `${height}%` }} + /> +
+ + {bucket.label} +
); })}
- )} -
- -
-
-

- 积分充值 -

- {topupPackages.length > 3 ? ( - - 另有 {topupPackages.length - 3} 个 - - ) : null}
- {topupPackages.length === 0 ? ( -
- 暂无积分包 -
- ) : ( -
- {topupPackages - .slice(0, 3) - .map((item: OemCloudTopupPackage) => { - const disabled = - !selectedPaymentConfig || - creatingTopupPackageId === item.id; - const totalCredits = - item.credits + (item.bonusCredits ?? 0); - return ( -
-
-
-
-
- {item.name} -
- {item.recommended ? ( - - ) : null} -
-

- {formatCreditAmount(totalCredits)} Token 积分 -

-
-

- {formatMoneyCents( - item.priceCents, - billingCurrency, - )} -

-
- +
+
+

+ 用量记录 +

+ +
+ {recentUsageRecords.length > 0 ? ( +
+ {recentUsageRecords.map((record) => ( +
+
+

+ {record.model || "未知模型"} +

+

+ {formatOemCloudDateTime(record.createdAt)} +

- ); - })} -
- )} + + {formatCreditAmount(record.tokens)} tokens + + + {formatCreditAmount(record.credits)} 积分 + + +
+ ))} +
+ ) : ( +
+ 暂无用量记录。 +
+ )} +
-
- -
-
-
-

- 账本记录 -

- +
+ -
-
- } /> } /> } />
-
- {commerceRecords.length > 0 ? ( - commerceRecords.map((record) => ( - - )) - ) : ( -
- 暂无订单记录 -
- )} -
-
-
-

- 最近用量 -

-
- {recentUsageRecords.length > 0 ? ( - recentUsageRecords.map((record) => ( -
-
- - {record.model || "未知模型"} - - - {formatOemCloudDateTime(record.createdAt)} - -
-

- {formatCreditAmount(record.tokens)} tokens ·{" "} - {formatCreditAmount(record.credits)} credits ·{" "} - {record.status} -

-
- )) - ) : ( -
- 暂无最近用量 -
- )} -
-
-
-
-
-
- -
-
-
-
-
-

- API Key -

-

- 用于 OpenAI-compatible / Anthropic-compatible - 协议调用,密钥明文只显示一次。 -

-
- -
- - {lastIssuedRawToken ? ( -
-

- 请立即保存,刷新后不会再次显示 -

- - {lastIssuedRawToken} - -
- - -
-
- ) : null} - - {activeAccessToken?.token ? ( -
-
-
-

- {activeAccessToken.token.name} -

-

- {activeAccessToken.token.tokenMasked} · 到期{" "} - {formatOemCloudDateTime( - activeAccessToken.token.expiresAt, - )} -

+
+
+

+ 套餐订单 +

- + {recentPlanOrders.length > 0 ? ( +
+ {recentPlanOrders.map((order: OemCloudOrder) => ( +
+
+

+ {order.planName || order.planKey} +

+

+ {formatOemCloudDateTime( + order.paidAt || order.createdAt, + )} +

+
+ + {formatBillingCycleLabel(order.billingCycle)} + + + {formatMoneyCents( + order.amountCents, + billingCurrency, + )} + + +
+ ))} +
+ ) : ( +
+ 暂无套餐订单。 +
+ )}
-
+ +
+
+
+

+ 充值订单 +

+
+ {recentCreditOrders.length > 0 ? ( +
+ {recentCreditOrders.map( + (order: OemCloudCreditTopupOrder) => ( +
+
+ + {order.packageName} + + +
+

+ {formatMoneyCents( + order.amountCents, + billingCurrency, + )}{" "} + · {formatCreditAmount(order.creditsGranted)}{" "} + 积分 ·{" "} + {formatOemCloudDateTime( + order.paidAt || order.createdAt, + )} +

+
+ ), + )} +
+ ) : ( +
+ 暂无充值订单。 +
+ )} +
+ +
+
+

+ 积分来源 +

+
+ {displayedCreditWallets.length > 0 ? ( +
+ {displayedCreditWallets + .slice(0, 6) + .map((wallet: OemCloudCreditWallet) => ( +
+
+ + {wallet.packageName || wallet.sourceType} + + +
+

+ 剩余{" "} + {formatCreditAmount(wallet.remainingCredits)} / + 共 {formatCreditAmount(wallet.grantedCredits)} · + 到期 {formatOemCloudDateTime(wallet.expiresAt)} +

+
+ ))} +
+ ) : ( +
+ 暂无积分来源。 +
+ )} +
+
+
+ )} +
+
+ )} + +
+ + API Key 与 SDK + + {activeAccessToken?.hasActive ? "已就绪" : "未创建"} + + +
+ {lastIssuedRawToken ? ( +
+

+ 请立即保存,刷新后不会再次显示 +

+ + {lastIssuedRawToken} + +
+ + +
+
+ ) : null} + + {activeAccessToken?.hasActive && activeAccessToken.token ? ( +
+
+
+

+ {activeAccessToken.token.name || "Desktop Key"} +

+

+ {activeAccessToken.token.tokenMasked || + activeAccessToken.token.tokenPrefix} +

+
+
- ) : ( -
- 当前账号还没有活跃 API Key。创建后可复制到 SDK - 或兼容客户端中使用。 -
- )} - -
- - - -
+ ) : null} - {canRenderSdkSnippets ? ( -
-
-
-

- SDK 调用 -

-

- 示例只使用服务端下发的 Base URL 和模型 ID;Key 请放到 - LIME_API_KEY 环境变量,并随请求携带租户 Header。 -

-
- {activeAccessToken?.hasActive ? ( - - ) : ( - - )} -
-
- - {canRenderAnthropicSnippet ? ( - - ) : null} -
+
+ + + + +
+ + {canRenderSdkSnippets ? ( +
+
+

+ SDK 调用 +

+ {activeAccessToken?.hasActive ? ( + + ) : ( + + )} +
+
+ {canRenderAnthropicSnippet ? ( + + ) : null}
- ) : ( -
- 服务端尚未下发可用于 SDK 示例的 Base URL - 或模型目录。客户端不会生成兜底模型。 -
- )} - - -
- -
- -
-
-
-
-
-

- 可用模型 -

-

- 模型和 Anthropic / coding - 能力以服务端目录为准,这里只做查看和设默认。 -

-
- +
+ ) : ( +
+ 服务端尚未下发可用于 SDK 示例的 Base URL 或模型目录。 +
+ )} - {offers.length === 0 ? ( -
- 当前租户还没有可用云端来源。请先在后台发布 Offer。 -
+ - ); - })} -
+ + )} + 创建 API Key + +
+
-
- {selectedOffer ? ( -
-
+
+ + 可用模型 + + {defaultProviderSummary || "未设默认来源"} + + +
+ {offers.length === 0 ? ( +
+ 当前租户还没有可用云端来源。请先在后台发布 Offer。 +
+ ) : ( +
+
+ {offers.map((offer) => { + const isDefaultCloudOffer = + preference?.providerSource === "oem_cloud" && + preference.providerKey === offer.providerKey; + const isFocused = selectedOfferKey === offer.providerKey; + const displayedOfferState = resolveDisplayOfferState( + session, + offer, + ); + return ( +
- - {selectedModels.length > 0 ? ( -
- {loadingDetail ? ( -
- - 正在加载模型目录... -
- ) : filteredSelectedModels.length > 0 ? ( -
- {filteredSelectedModels.map((model) => { - const metadata = createOemCloudModelMetadata(model); - const abilityTags = ( - metadata.task_families ?? [] - ).map(formatOemModelTaskFamilyLabel); - const upstreamMapping = - metadata.alias_source === "oem" - ? metadata.canonical_model_id - : null; - return ( -
-
-
-

- {model.displayName} -

-

- {model.modelId} -

-
- {model.recommended ? ( - - ) : null} -
-
- - {abilityTags.map((tag) => ( - - ))} -
- {model.description ? ( -

- {model.description} +

+ {selectedOffer ? ( +
+
+
+

+ {selectedOffer.displayName} +

+

+ {formatOemCloudAccessModeLabel( + selectedOffer.access.accessMode, + )}{" "} + · {selectedModels.length} 个模型 +

+
+ +
+ + {selectedModels.length > 0 ? ( + + ) : null} + + {loadingDetail ? ( +
+ + 正在加载模型目录... +
+ ) : filteredSelectedModels.length > 0 ? ( +
+ {filteredSelectedModels.map((model) => { + const metadata = createOemCloudModelMetadata(model); + const abilityTags = ( + metadata.task_families ?? [] + ).map(formatOemModelTaskFamilyLabel); + const upstreamMapping = + metadata.alias_source === "oem" + ? metadata.canonical_model_id + : null; + return ( +
+
+
+

+ {model.displayName}

- ) : null} - {upstreamMapping ? ( -

- 实际映射:{model.modelId} →{" "} - {upstreamMapping} +

+ {model.modelId}

+
+ {model.recommended ? ( + ) : null}
- ); - })} -
- ) : ( -
- {selectedModels.length > 0 - ? "没有匹配当前筛选条件的模型。" - : "当前来源还没有下发模型目录。"} -
- )} -
- ) : ( -
- -

- 选择云端来源查看模型 -

-

- 点击左侧来源后会展开模型、协议能力和默认来源操作。 -

-
- )} -
+
+ + {abilityTags.map((tag) => ( + + ))} +
+ {model.description ? ( +

+ {model.description} +

+ ) : null} + {upstreamMapping ? ( +

+ 实际映射:{model.modelId} → {upstreamMapping} +

+ ) : null} +
+ ); + })} +
+ ) : ( +
+ {selectedModels.length > 0 + ? "没有匹配当前筛选条件的模型。" + : "当前来源还没有下发模型目录。"} +
+ )} +
+ ) : ( +
+ +

+ 选择云端来源查看模型 +

+

+ 点击左侧来源后会展开模型、协议能力和默认来源操作。 +

+
+ )}
- )} -
- -
+
+ )} +
+ );