diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bb3270e2..b6f4b1964 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,9 +53,8 @@ jobs: NOTES_FILE="$RUNNER_TEMP/release-notes.md" if [[ "$TAG" == *-* ]]; then - IS_PRERELEASE="true" - else - IS_PRERELEASE="false" + echo "::error::Stable updater release does not accept prerelease tag: $TAG" + exit 1 fi if [ -f RELEASE_NOTES.md ]; then @@ -89,10 +88,6 @@ jobs: --notes-file "$NOTES_FILE" ) - if [ "$IS_PRERELEASE" = "true" ]; then - create_args+=(--prerelease) - fi - gh "${create_args[@]}" || gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null build: @@ -121,6 +116,7 @@ jobs: runs-on: ${{ matrix.platform }} env: LIME_UPDATER_PUBLIC_KEY: ${{ secrets.LIME_UPDATER_PUBLIC_KEY }} + LIME_UPDATES_BASE_URL: ${{ secrets.LIME_UPDATES_BASE_URL || vars.LIME_UPDATES_BASE_URL }} TAURI_SIGNING_PRIVATE_KEY_RAW: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} NODE_OPTIONS: --max-old-space-size=8192 @@ -172,12 +168,8 @@ jobs: else echo "enabled=false" >> "$GITHUB_OUTPUT" echo "LIME_ENABLE_UPDATER_ARTIFACTS=false" >> "$GITHUB_ENV" - echo "LIME_UPDATER_PUBLIC_KEY=" >> "$GITHUB_ENV" - echo "TAURI_SIGNING_PRIVATE_KEY_RAW=" >> "$GITHUB_ENV" - echo "TAURI_SIGNING_PRIVATE_KEY=" >> "$GITHUB_ENV" - echo "TAURI_SIGNING_PRIVATE_KEY_PATH=" >> "$GITHUB_ENV" - echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=" >> "$GITHUB_ENV" - echo "::warning::Updater signing secrets are missing. Release will skip updater artifacts and fall back to manual-download updates." + echo "::error::Updater signing secrets are missing. Release must produce signed updater artifacts for Cloudflare R2 distribution." + exit 1 fi - name: Sync version from tag to tauri configs @@ -201,14 +193,23 @@ jobs: echo "tauri.conf.headless.json version is now: $(node -e "console.log(JSON.parse(require('fs').readFileSync('tauri.conf.headless.json','utf8')).version)")" echo "createUpdaterArtifacts: $(node -e "console.log(JSON.parse(require('fs').readFileSync('tauri.conf.json','utf8')).bundle.createUpdaterArtifacts)")" + - name: Resolve updater endpoint + shell: bash + run: | + set -euo pipefail + BASE_URL="${LIME_UPDATES_BASE_URL:-https://updates.limecloud.com}" + BASE_URL="${BASE_URL%/}" + echo "LIME_UPDATER_ENDPOINT=${BASE_URL}/lime/stable/latest.json" >> "$GITHUB_ENV" + echo "Updater endpoint: ${BASE_URL}/lime/stable/latest.json" + - name: Resolve release channel id: release_meta shell: bash run: | TAG="${{ github.event.inputs.version || github.ref_name }}" if [[ "$TAG" == *-* ]]; then - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "Release channel: prerelease" + echo "::error::Stable updater release does not accept prerelease tag: $TAG" + exit 1 else echo "is_prerelease=false" >> "$GITHUB_OUTPUT" echo "Release channel: stable" @@ -392,11 +393,11 @@ jobs: exit 1 fi - - name: Build Tauri app (Windows installer, direct release upload) - if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'true' + - name: Build Tauri app (Windows installer, stage only) + id: build_windows_stage + if: matrix.platform == 'windows-2022' uses: tauri-apps/tauri-action@v0 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 禁用 LTO 加速编译(正式发布可改为 thin) CARGO_PROFILE_RELEASE_LTO: "off" # 增加并行编译单元 @@ -407,42 +408,17 @@ jobs: with: tauriScript: npx tauri projectPath: src-tauri - tagName: ${{ github.event.inputs.version || github.ref_name }} - releaseName: "Lime ${{ github.event.inputs.version || github.ref_name }}" - releaseBody: ${{ env.RELEASE_BODY }} - releaseDraft: false - prerelease: ${{ steps.release_meta.outputs.is_prerelease == 'true' }} includeUpdaterJson: ${{ steps.updater_mode.outputs.enabled == 'true' }} # tauri-action@v0 使用 assetNamePattern,Windows 只保留单一 setup 安装包 assetNamePattern: "[name]_[version]_[arch][setup][ext]" # 默认不启用 voice feature(包含 whisper-rs,编译很慢) args: --target ${{ matrix.target }} --config tauri.windows.conf.json - - name: Build Tauri app (Windows installer, stage only) - id: build_windows_stage - if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'false' - uses: tauri-apps/tauri-action@v0 - env: - # 禁用 LTO 加速编译(正式发布可改为 thin) - CARGO_PROFILE_RELEASE_LTO: "off" - # 增加并行编译单元 - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32 - CARGO_INCREMENTAL: 0 - SCCACHE_GHA_ENABLED: "true" - RUSTC_WRAPPER: sccache - with: - tauriScript: npx tauri - projectPath: src-tauri - # 默认不启用 voice feature(包含 whisper-rs,编译很慢) - args: --target ${{ matrix.target }} --config tauri.windows.conf.json - - - name: Stage Windows release asset - if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'false' + - name: Stage Windows release assets + if: matrix.platform == 'windows-2022' shell: bash env: ARTIFACT_PATHS_JSON: ${{ steps.build_windows_stage.outputs.artifactPaths }} - RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }} - TARGET_TRIPLE: ${{ matrix.target }} STAGING_DIR: release-assets/${{ matrix.target }} run: | set -euo pipefail @@ -452,48 +428,36 @@ jobs: const raw = (process.env.ARTIFACT_PATHS_JSON || '').trim(); if (!raw) { - throw new Error('Windows installer artifactPaths 输出为空'); + throw new Error('Windows artifactPaths 输出为空'); } let artifacts; try { artifacts = JSON.parse(raw); } catch (error) { - throw new Error(`Windows installer artifactPaths 不是合法 JSON: ${raw}\n${error}`); + throw new Error(`Windows artifactPaths 不是合法 JSON: ${raw}\n${error}`); } if (!Array.isArray(artifacts) || artifacts.length === 0) { - throw new Error(`Windows installer artifactPaths 为空数组: ${raw}`); + throw new Error(`Windows artifactPaths 为空数组: ${raw}`); } - const installerPath = artifacts.find( - (item) => typeof item === 'string' && item.toLowerCase().endsWith('.exe') - ); - if (!installerPath) { - throw new Error(`Windows installer 构建未找到 .exe 安装包: ${raw}`); - } - - const version = (process.env.RELEASE_TAG || '').replace(/^v/, ''); - if (!version) { - throw new Error('RELEASE_TAG 为空,无法生成 Windows release 文件名'); - } - - const targetTriple = process.env.TARGET_TRIPLE || ''; - const archLabel = - targetTriple.startsWith('x86_64-') ? 'x64' : - targetTriple.startsWith('aarch64-') ? 'aarch64' : - targetTriple.replace(/[^A-Za-z0-9._-]+/g, '-'); - const stagingDir = process.env.STAGING_DIR; fs.mkdirSync(stagingDir, { recursive: true }); - const stagedPath = path.join( - stagingDir, - `Lime_${version}_${archLabel}-setup.exe` - ); + for (const artifact of artifacts) { + if (typeof artifact !== 'string' || !fs.existsSync(artifact) || !fs.statSync(artifact).isFile()) { + continue; + } + const stagedPath = path.join(stagingDir, path.basename(artifact)); + fs.copyFileSync(artifact, stagedPath); + console.log(`Staged Windows asset: ${artifact} -> ${stagedPath}`); + } - fs.copyFileSync(installerPath, stagedPath); - console.log(`Staged Windows installer: ${installerPath} -> ${stagedPath}`); + const staged = fs.readdirSync(stagingDir).filter((name) => fs.statSync(path.join(stagingDir, name)).isFile()); + if (staged.length === 0) { + throw new Error(`Windows 构建未生成可发布文件: ${raw}`); + } NODE - name: Build macOS app (attempt 1, verbose) @@ -803,6 +767,17 @@ jobs: runs-on: ubuntu-22.04 steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.source_ref || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Download staged release assets uses: actions/download-artifact@v4 with: @@ -833,6 +808,60 @@ jobs: printf 'Downloaded staged release assets:\n' printf ' - %s\n' "${assets[@]}" + - name: Prepare updater release notes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }} + shell: bash + run: | + set -euo pipefail + NOTES_FILE="$RUNNER_TEMP/release-notes.md" + + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json body -q .body > "$NOTES_FILE" && [ -s "$NOTES_FILE" ]; then + echo "Loaded release notes from GitHub Release" + exit 0 + fi + + if [ -f RELEASE_NOTES.md ]; then + cp RELEASE_NOTES.md "$NOTES_FILE" + echo "Loaded release notes from RELEASE_NOTES.md" + exit 0 + fi + + PREV_TAG="$(git tag --sort=-v:refname | grep -v "^${RELEASE_TAG}$" | head -n 1)" + if [ -z "$PREV_TAG" ]; then + CHANGELOG="$(git log --pretty=format:"- %s (%h)" "${RELEASE_TAG}" 2>/dev/null || git log --pretty=format:"- %s (%h)")" + else + CHANGELOG="$(git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${RELEASE_TAG}" 2>/dev/null || git log --pretty=format:"- %s (%h)" "${PREV_TAG}..HEAD")" + fi + + { + echo "## Lime ${RELEASE_TAG}" + echo + echo "### Changes" + echo "${CHANGELOG}" + } > "$NOTES_FILE" + echo "Generated release notes from git history" + + - name: Generate stable updater manifest + env: + LIME_UPDATES_BASE_URL: ${{ secrets.LIME_UPDATES_BASE_URL || vars.LIME_UPDATES_BASE_URL }} + RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }} + shell: bash + run: | + set -euo pipefail + BASE_URL="${LIME_UPDATES_BASE_URL:-https://updates.limecloud.com}" + node scripts/release-updater-manifest.mjs \ + --assets-dir release-assets \ + --out-dir release-updater \ + --version "$RELEASE_TAG" \ + --base-url "$BASE_URL" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + --channel stable + + printf 'Generated updater manifest:\n' + cat release-updater/latest.json + - name: Upload staged release assets to GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -845,7 +874,8 @@ jobs: while IFS= read -r asset; do [ -n "$asset" ] || continue assets+=("$asset") - done < <(find "release-assets" -type f | sort) + done < <(find "release-assets" -type f ! -name "latest*.json" | sort) + assets+=("release-updater/latest.json") if [ "${#assets[@]}" -eq 0 ]; then echo "No staged release assets found" >&2 @@ -858,3 +888,71 @@ jobs: gh release upload "$TAG" "${assets[@]}" \ --repo "$GITHUB_REPOSITORY" \ --clobber + + - name: Upload updater assets to Cloudflare R2 + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + LIME_RELEASES_R2_BUCKET: ${{ secrets.LIME_RELEASES_R2_BUCKET }} + shell: bash + run: | + set -euo pipefail + BUCKET="${LIME_RELEASES_R2_BUCKET:-lime-releases}" + + BUCKET="$BUCKET" node - <<'NODE' > "$RUNNER_TEMP/r2-upload.sh" + const fs = require('fs'); + const plan = JSON.parse(fs.readFileSync('release-updater/r2-upload-plan.json', 'utf8')); + for (const item of plan) { + const args = [ + 'npx wrangler@latest r2 object put', + JSON.stringify(`${process.env.BUCKET}/${item.key}`), + '--file', + JSON.stringify(item.file), + '--content-type', + JSON.stringify(item.contentType), + '--cache-control', + JSON.stringify(item.cacheControl), + ]; + console.log(args.join(' ')); + } + NODE + + bash "$RUNNER_TEMP/r2-upload.sh" + + - name: Clean old updater assets from Cloudflare R2 + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + LIME_MINIMUM_SUPPORTED_VERSION: ${{ secrets.LIME_MINIMUM_SUPPORTED_VERSION }} + LIME_R2_KEEP_RELEASES: ${{ vars.LIME_R2_KEEP_RELEASES }} + LIME_RELEASES_R2_BUCKET: ${{ secrets.LIME_RELEASES_R2_BUCKET }} + RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }} + shell: bash + run: | + set -euo pipefail + BUCKET="${LIME_RELEASES_R2_BUCKET:-lime-releases}" + KEEP="${LIME_R2_KEEP_RELEASES:-3}" + + npx wrangler@latest r2 object list "$BUCKET" \ + --prefix "lime/stable/v" \ + --json > "$RUNNER_TEMP/r2-objects.json" + + node scripts/plan-r2-release-cleanup.mjs \ + --input "$RUNNER_TEMP/r2-objects.json" \ + --output "$RUNNER_TEMP/r2-delete-keys.txt" \ + --current "$RELEASE_TAG" \ + --keep "$KEEP" \ + --minimum "${LIME_MINIMUM_SUPPORTED_VERSION:-}" \ + --channel stable + + if [ ! -s "$RUNNER_TEMP/r2-delete-keys.txt" ]; then + echo "No old R2 updater assets to delete" + exit 0 + fi + + printf 'Deleting old R2 updater assets:\n' + cat "$RUNNER_TEMP/r2-delete-keys.txt" + while IFS= read -r key; do + [ -n "$key" ] || continue + npx wrangler@latest r2 object delete "${BUCKET}/${key}" + done < "$RUNNER_TEMP/r2-delete-keys.txt" diff --git a/README.md b/README.md index ee1e1fbb2..69c9acbdb 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ brew install --cask lime #### 手动下载 -从 [Releases](https://github.com/aiclientproxy/lime/releases) 下载对应平台安装包。 +从 [Releases](https://github.com/limecloud/lime/releases) 下载对应平台安装包。 - 当前仅提供 macOS 与 Windows 发布包,Linux 桌面端已暂停支持 - Windows 用户下载 `Lime_*_x64-setup.exe`(NSIS 安装器,内置 WebView2) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 09cdb6f0a..522c76254 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,150 +1,97 @@ -## Lime v1.20.0 +## Lime v1.21.0 -发布日期:`2026-04-27` +发布日期:`2026-04-28` ### 发布概览 -- 本次发布目标 tag 为 `v1.20.0`。 -- 本次发布聚焦工作台(Workbench)入口上线、任务中心切换收口、会话与线程运行时增强、图片工作台状态恢复、技能选择器优化、数据库性能提升,以及大量测试覆盖补充。 -- 本轮待递交内容覆盖 Rust 后端、DevBridge、前端 Workspace / Task Center、研究文档、版本锁文件与验证产物清理。 +- 本次发布目标 tag 为 `v1.21.0`。 +- 本次发布聚焦稳定版自动更新与 R2 分发链路、OEM 云端商业闭环、工作台首页与侧栏体验、资源管理器、Provider 模型管理收口、主题外观与设置页更新。 +- 本轮待递交内容覆盖 Rust 后端、Tauri update command、DevBridge / mock、发布工作流、前端 Workspace / Settings / Provider Pool / Resource Manager、测试覆盖、版本锁文件与临时产物清理。 ### 重点更新 -#### 1. 工作台(Workbench)入口上线 +#### 1. 稳定版更新与 R2 发布链路 -- 侧边栏新增「工作台」导航项,支持通过 `buildClawAgentParams` 进入 Claw Agent 工作台模式 (`sidebarNav.ts`, `AppSidebar.tsx`) -- 新增 `sessionIdentity.ts`,提供辅助 Agent 会话识别能力 (`sessionIdentity.ts`) -- WorkspaceShellScene 支持归档会话打开回调 (`WorkspaceShellScene.tsx`) +- `.github/workflows/release.yml` 补齐稳定版 updater 发布门禁,要求签名密钥与更新地址就绪后再生成 updater artifacts。 +- 新增 `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 与旧版本清理保护逻辑。 +- `src-tauri/src/commands/update_cmd.rs` 与 `src-tauri/crates/services/src/update_check_service.rs` 切到静态清单检查 + Tauri updater 安装主链,并补齐 semver 比较与缓存兜底。 -#### 2. 新增 Workbench 技能命令 +#### 2. 版本号同步到 v1.21.0 -- 新增文件阅读工作台命令:`fileReadWorkbenchCommand` (`fileReadWorkbenchCommand.ts`) -- 新增增长分析工作台命令:`growthWorkbenchCommand` (`growthWorkbenchCommand.ts`) -- 新增 Logo 分解工作台命令:`logoDecompositionWorkbenchCommand` (`logoDecompositionWorkbenchCommand.ts`) -- 新增写作工作台命令:`writingWorkbenchCommand` (`writingWorkbenchCommand.ts`) - -#### 3. 会话管理增强 - -- 会话列表支持归档筛选、工作目录过滤与分页加载 (`session_runtime.rs`, `agent.rs`) -- 新增 `SessionArchiveFilter` 枚举,支持灵活的归档会话查询 (`agent.rs`) -- `AsterAgentWrapper::list_sessions_sync` 支持 `archive_filter`、`workspace_id`、`limit` 参数 (`session_runtime.rs`) -- 侧边栏会话列表实现分页加载与"加载更多"机制 (`AppSidebar.tsx`) -- 会话状态管理支持 `allowDetachedCandidate` 选项,允许恢复已断开的候选会话 (`agentSessionState.ts`) -- 初始会话导航支持去重,防止重复导航 (`useWorkspaceInitialSessionNavigation.ts`) - -#### 4. Chat 侧栏归档会话支持 - -- ChatSidebar 新增 `onOpenArchivedTopic` 回调,支持在 Task Center 打开归档会话 (`ChatSidebar.tsx`) -- `handleOpenTaskItem` 根据 section 类型自动路由到归档或常规会话打开逻辑 - -#### 5. 技能选择器优化 - -- 优化输入能力分区逻辑 (`inputCapabilitySections.ts`) -- 改进技能选择绑定配置 (`skillSelectionBindings.ts`) - -#### 6. 数据库性能优化 - -- 新增 `idx_agent_sessions_working_dir_archived_updated_at` 复合索引,优化按工作目录和归档状态查询 (`schema.rs`) -- 新增 `idx_agent_sessions_session_type_updated_at` 索引,优化按会话类型查询 (`schema.rs`) -- Agent 会话仓储支持新的查询参数 (`agent_session_repository.rs`) - -#### 7. 测试覆盖大幅补充 - -- 新增/更新 42 个测试文件,约 3185 行测试代码 -- 覆盖工作台命令、会话状态、侧栏导航、技能选择、Workspace 导航等核心模块 -- `seededCommandPackage.test.ts` 大幅扩展测试用例 - -#### 8. 其他改进 - -- 导航偏好配置更新 (`preferences.ts`) -- 命令清单 schema 同步更新 (`agentRuntimeCommandSchema.json`) -- 会话客户端与类型定义完善 (`sessionClient.ts`, `types.ts`) -- Agent API 测试补充 (`agent.test.ts`) -- `limenext-progress.md` 进度日志同步更新 - -#### 9. 测试修复 - -- 修复 `runtime_project_hooks` 中 `persist_session_access_mode` 测试用例,解决测试 DB 与全局 LimeSessionStore DB 不一致导致 `recent_access_mode` 无法正确读取的问题 (`runtime_project_hooks.rs`) -- 测试修复后直接写入 extension_data 和测试消息到测试 DB,确保 `get_runtime_session_detail` 能正常构建 `execution_runtime` -- 修复 `useAgentSession.ts` 在 scoped session 恢复、手动切换 topic、跨工作区恢复失败与 detail mock 污染场景下的 sessionId / topicId 回写问题,避免发送、压缩和 confirmAction 落到空会话 -- 修复 `http-client.test.ts` 中预期 reject 被 Vitest 识别为 unhandled rejection 的问题 -- 修复 `chrome-relay` 消息自动清除定时器在测试环境 teardown 后仍触发 `window is not defined` 的问题 (`src/components/settings-v2/system/chrome-relay/index.tsx`) -- 修复 `agent-runtime-tool-surface-page-smoke.mjs` 在真实页面刷新后 composer 尚未挂载就写入 prompt 的竞态,GUI smoke 现在会先等待输入框与发送按钮就绪 - -#### 10. 任务中心与工作区切换收口 - -- 修复同一 `claw` 页面内从左侧“最近对话 / 归档”切换会话时,旧会话 Tab 被重新回灌到任务中心顶部的问题 (`AgentChatWorkspace.tsx`, `index.test.tsx`) -- 任务中心在路由切换中会先锁定目标会话预览态,避免“只打开一个会话却出现多个任务 Tab”的视觉错觉 -- `ChatNavbar`、`TaskCenterTabStrip`、`WorkspaceMainArea`、`WorkspaceStyles` 与 `taskCenterChromeTokens.ts` 继续收口任务中心 chrome、标签栏和工作区视觉状态 -- `SplashScreen`、`ProjectSelector`、`SceneAppsPage`、开发者设置页和侧边栏导航补齐对应入口状态与回归断言 - -#### 11. Harness Engine 与会话运行时 - -- 会话存储、DAO 与 timeline 查询补齐 thread / turn / item 级状态合并,减少重复 upsert 和过期 pending 状态残留 (`session_store.rs`, `agent.rs`, `agent_timeline.rs`, `agentThreadState.ts`) -- `agent_runtime_submit_turn` / runtime turn 链路继续补充真实 `session/thread/turn` 关联,让 evidence / telemetry / replay / review 能消费同一导出事实源 (`runtime_api.rs`, `runtime_turn.rs`, `dto.rs`) -- `AgentThreadTimelineArtifactCard`、`MessageList`、`useAgentRuntimeSyncEffects`、`useAsterAgentChat` 和相关测试覆盖工具时间线、轻卡与运行时同步回归 -- `agentSessionScopedStorage` 增加按工作区 / 会话作用域的 transient 与 persisted 存储封装,避免跨会话状态串扰 - -#### 12. 图片工作台状态恢复 - -- 新增 `imageWorkbenchStateCache.ts`,为图片工作台 tail 状态提供同标签页缓存、持久化回退、TTL / stale grace 和 LRU 裁剪 -- `useWorkspaceImageTaskPreviewRuntime.ts` 支持只恢复近期活跃的非终态图片任务,避免旧 running / queued 任务长期误回灌 -- 图片工作台恢复逻辑支持从消息里的 `imageWorkbenchPreview` 重建状态,并通过 `imageWorkbenchStateCache.test.ts` 与 `useWorkspaceImageTaskPreviewRuntime.test.tsx` 覆盖缓存、过期和消息回放路径 - -#### 13. DevBridge、模型命令与窗口 chrome - -- DevBridge 模型分发补齐 `get_provider_alias_config`、`get_all_models_by_provider`、`get_all_available_models`、`get_default_models_for_provider`,避免浏览器模式初始化模型时继续落入 unknown command (`dispatcher/models.rs`) -- `agent-runtime-tool-surface-page-smoke.mjs` 同步工具面页面冒烟逻辑,覆盖真实页面输入、发送、Harness 打开与 Runtime 能力摘要检查 -- 新增 `window_chrome.rs` 并在启动链路应用主窗口 macOS overlay 标题栏兜底,避免 headless/dev 配置绕过主窗口 chrome (`mod.rs`, `runner.rs`, `tauri.conf*.json`) -- `deepseek.json`、命令 manifest 与 runtime schema 同步模型 / 命令边界事实源 - -#### 14. 文档、研究资料与验证产物治理 - -- `docs/research/ribbi/` 新增 Ribbi 研究事实源,拆分产品意图、架构图、流程图、命令清单、Agent/tool 编排、Taste/Memory 演进与 Lime 差距分析 -- `.gitignore` 调整为允许递交 `docs/research/`,让研究资料成为 versioned artifact -- `docs/aiprompts/overview.md` 更新开发者中心 Harness 开关语义:关闭时仍保留 Harness 入口,但不采集工具库存与额外环境摘要 -- `docs/exec-plans/limenext-progress.md` 记录任务中心切换、DevBridge 模型命令补齐、GUI smoke 与定向测试结果 -- 根目录旧 Playwright 截图、网络日志和控制台快照已从发布递交面清理,包括 `advanced-settings-open.png`、`current-lime-page.png`、`typed-message.png`、`mimo-openai-doc.png`、`console-current.txt`、`network-3030.txt` 与 `playwright-*` 快照 -- 当前工作区仍包含 `network-before.md`、`patch_dark.js`、`patch_flare.js`、`patch_navbar.js` 与 `tmp-e2e-home.png` 这类临时调试辅助产物;如果按“全部递交”处理,这些也纳入本 Release Notes 的待递交范围 - -### 待递交范围确认 - -- 版本同步与锁文件:`package.json`、`src-tauri/Cargo.toml` 已确认是 `1.20.0`;本轮待递交包含 `package-lock.json`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 的同步更新 -- Rust 主链:Agent session store、session/runtime command API、timeline DAO、数据库 schema、DevBridge dispatcher、模型 dispatcher、主窗口 chrome 与 DeepSeek provider 元数据 -- 前端主链:Workspace / Task Center / ChatNavbar / EmptyState / MessageList / AgentThreadTimelineArtifactCard / AppSidebar / Splash / ProjectSelector / SceneApps / developer settings -- 会话与运行时状态:`agentSessionScopedStorage`、`agentSessionState`、`agentThreadState`、`useAgentSession`、`useAsterAgentChat`、runtime sync / stream handler / turn event binding -- 图片工作台:`imageWorkbenchStateCache.ts`、`useWorkspaceImageTaskPreviewRuntime.ts` 及对应缓存、过期、消息回放测试 -- 命令与契约事实源:`commandManifest.generated.ts`、`agentRuntimeCommandSchema.json`、DevBridge mock / HTTP client 测试与 runtime tool surface 页面 smoke -- 文档与研究资料:`docs/aiprompts/overview.md`、`docs/exec-plans/limenext-progress.md`、`docs/research/ribbi/*` -- 发布产物治理:删除根目录旧 Playwright / 网络 / 控制台临时快照,并显式标记当前仍保留的临时调试辅助文件 - -### 版本同步 - -- 应用版本已同步为 `1.20.0`: +- 应用版本已同步为 `1.21.0`: - `package.json` - `package-lock.json` - `src-tauri/Cargo.toml` + - `src-tauri/Cargo.lock` - `src-tauri/tauri.conf.json` - `src-tauri/tauri.conf.headless.json` +- `packages/lime-cli-npm/package.json` 与 `packages/lime-cli-npm/README.md` 已同步到 `1.21.0`,保持 CLI wrapper 与桌面 release 版本一致。 +- 浏览器模式默认 mock 的 update current version 已同步为 `1.21.0`。 + +#### 3. OEM 云端商业闭环 + +- 新增 `src/lib/oemCloudPaymentReturn.ts`,统一生成、解析、暂存并分发 `lime://payment/return` 支付回跳事件。 +- `useDeepLink` 识别支付回跳 deep link,直接分发云端商业刷新事件,不再走旧 `handle_deep_link` 命令分支。 +- `useOemCloudAccess` 接入云端激活、支付配置、套餐订单、充值订单、账本、积分余额与访问令牌刷新主链。 +- 套餐购买和积分充值 checkout 支持 HTTPS bridge 回跳 URL,支付完成后自动刷新云端权益、积分余额与订单 watcher。 +- `docs/exec-plans/oem-cloud-commerce-loop-progress.md` 记录当前阶段、已清退的旧支付配置入口与下一轮真实渠道沙箱验证计划。 + +#### 4. 工作台首页、侧栏与导航体验 + +- `AppSidebar` 增加最近对话 / 归档会话架、分页加载、归档切换、外观切换、账户菜单与折叠态细节。 +- 新增 `src/components/app-sidebar/AppSidebarConversationShelf.tsx`,把会话架从侧栏主体中拆出,降低侧栏单体复杂度。 +- 工作台首页空态升级为“先开始这一轮 / 继续这轮 / 直接开工”入口,强化任务起手、推荐模板与继续上下文。 +- Workspace / Task Center / ChatNavbar / Inputbar / EmptyState / Team Workspace 等主路径继续收口视觉状态、运行时状态与回归断言。 +- `src/lib/windowControls.ts`、窗口 chrome 与主窗口启动链路继续补齐 macOS / headless 场景下的窗口控制一致性。 + +#### 5. 资源管理器 + +- 新增 `src/features/resource-manager/`,提供资源管理器页面、侧栏、工具栏、预览面板、Inspector、搜索与导航意图。 +- 支持图片、文本、Markdown、PDF、Office、音视频、数据文件、压缩包与系统委托类型的分层预览渲染。 +- 支持资源下载、复制、系统打开、Finder 揭示、聊天位置与项目资源上下文回跳。 +- 补齐 `ResourceManagerPage`、资源预览搜索、会话状态和导航意图测试。 + +#### 6. Provider Pool 与设置页收口 + +- Provider 模型管理改为“启用的模型”左侧列表 + 添加模型面板,删除旧 API Key 列表 / Provider 表单 / 模型列表拆分组件。 +- 新增 `ModelProviderList`、`ModelAddPanel`、`providerConfigUtils` 与连接测试类型,统一 Provider 配置工具函数与 UI 入口。 +- 设置页 Provider、About、Developer、Experimental、Appearance、Channels 与 Automation 页面继续收口布局、状态展示和回归断言。 +- Prompt Cache 与 Anthropic-compatible 能力口径更新,避免把显式 `cache_control` 能力误显示为自动缓存。 + +#### 7. App Update 前端与 mock + +- `src/lib/api/appUpdate.ts` 扩展 release notes URL / pubDate / 错误信息字段。 +- `src/lib/tauri-mock/core.ts` 补齐 `check_update`、`check_for_updates` 与下载无更新态 mock,浏览器模式不再落入 unknown command。 +- About 设置页更新检查、下载失败、诊断错误和版本展示补齐测试覆盖。 + +#### 8. 文档、治理与临时产物 + +- `README.md` 更新产品定位文案:从“本地优先的 AI API Proxy 桌面应用”收敛为 AI Agent 创作工作台。 +- `src/lib/governance/legacySurfaceCatalog.json` 与测试补充新的 legacy surface 口径。 +- 删除根目录临时调试产物:`monitor.sh`、`network-before.md`、`post-hmr-state.png`、`tmp-e2e-home.png`、`knip.governance.json`。 +- 新增 `theme-scope-messages-ocean.png` 作为本轮主题视觉验证产物。 + +### 待递交范围确认 + +- 版本与发布:版本文件、lockfile、CLI wrapper、release workflow、R2 updater manifest / cleanup 脚本与测试。 +- Rust 主链:update command、update service、window chrome、runner/app 模块、DevBridge dispatcher、tray 事件与菜单处理。 +- 前端主链:AppSidebar、Workspace、Task Center、EmptyState、Inputbar、Team Workspace、Settings、Provider Pool、Resource Manager、MCP、Memory、SceneApps、Resources。 +- 商业闭环:OEM cloud control plane API、支付回跳 deep link、权益 / 积分 / 账本刷新、订单 watcher 与执行计划文档。 +- 验证与治理:新增/更新测试、legacy catalog、release updater contract、删除临时调试文件与旧 Provider Pool 组件。 ### 校验状态 -- 本会话已实际执行并通过: - - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` - - `cargo test --manifest-path "src-tauri/Cargo.toml"` — 1102 passed / 0 failed / 2 ignored - - `cargo clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features` +- 已通过: - `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 clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features` - `npm run lint` - - `npm test` + - `npm test` — 43 个 Vitest smart 批次通过 - `npm run test:contracts` - - `node --check "scripts/agent-runtime-tool-surface-page-smoke.mjs"` - - `npm run smoke:agent-runtime-tool-surface-page` - - `npm run verify:gui-smoke` - `git diff --check` -- 本会话定向补测已通过: - - `npm test -- "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx"` — 149 tests - - `npm test -- "src/lib/dev-bridge/http-client.test.ts"` — 11 tests - - `npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx"` — 12 tests - `cargo test` 通过,当前存在 1 条预存 warning: - `write_auxiliary_runtime_projection_fixture` 的 `dead_code` - `cargo clippy` 通过,当前存在 4 条预存 warning: @@ -152,8 +99,10 @@ - `crates/skills/src/lime_llm_provider.rs` 的 `too_many_arguments` - `crates/agent/src/session_execution_runtime.rs` 的 `needless_lifetimes` - `src/services/runtime_evidence_pack_service.rs` 的 `dead_code` -- GUI 主路径已验证:`DevBridge` 健康检查、默认 workspace 准备态、browser runtime、site adapters、service skill entry、runtime tool surface 与真实页面 Harness summary smoke 均通过 +- GUI 主路径校验未完全通过: + - `npm run verify:gui-smoke` 已通过 `bridge:health`、`smoke:workspace-ready`、`smoke:browser-runtime`、`smoke:site-adapters`、`smoke:agent-service-skill-entry`、`smoke:agent-runtime-tool-surface`。 + - `smoke:agent-runtime-tool-surface-page` 在等待 Harness 按钮出现时超时;单独复测时托管 Chrome 会话在提交后丢失,需继续定位真实页面发送后的会话保持 / Harness 入口暴露问题。 --- -**完整变更**: `v1.19.0` -> `v1.20.0` +**完整变更**: `v1.20.0` -> `v1.21.0` diff --git a/docs/exec-plans/oem-cloud-commerce-loop-progress.md b/docs/exec-plans/oem-cloud-commerce-loop-progress.md new file mode 100644 index 000000000..c068f0cb3 --- /dev/null +++ b/docs/exec-plans/oem-cloud-commerce-loop-progress.md @@ -0,0 +1,29 @@ +# OEM 云端商业闭环推进记录 + +## 主目标 + +把 Lime 客户端与 LimeCore 的云端购买链路收敛为单一 current 主链:套餐/充值下单 -> 真实支付 checkout -> 支付渠道 webhook -> 权益/积分/账本刷新 -> API Key 与网关使用。 + +## 当前阶段 + +- `current`:`useOemCloudAccess` 消费 `/client/cloud-activation`、订单、充值单、账本、支付配置与访问令牌,客户端不再本地兜底套餐、渠道或模型目录。 +- `current`:支付完成后优先通过 LimeCore HTTPS `payments/{provider}/return` bridge 回到客户端,再 302 到 `lime://payment/return`,触发云端状态刷新和订单 watcher。 +- `dead`:旧的客户端支付后台密钥配置入口已从设置页主链移除,管理型支付配置只保留在服务端管理面。 + +## 2026-04-28 进度 + +- 新增 `src/lib/oemCloudPaymentReturn.ts`,统一生成、解析、暂存和分发云端支付回跳事件。 +- `useDeepLink` 识别 `lime://payment/return`,不走旧 `handle_deep_link` 命令,直接分发 current 云端商业刷新事件。 +- `useOemCloudAccess` 在套餐购买和积分充值 checkout 中传入 `successUrl` / `cancelUrl`,支付页回跳后自动刷新云端权益、积分余额与账本,并重新接上订单 watcher。 +- 增加回归覆盖 deep link 分发、checkout 回跳 URL、支付回跳后刷新商业状态。 + +## 2026-04-28 追加进度 + +- LimeCore 新增公开 HTTPS bridge:`/api/v1/public/tenants/{tenantId}/payments/{provider}/return`,支持 GET 与 form POST。 +- bridge 只组装并 302 到 `lime://payment/return`,不在回跳页确认支付;真实权益发放仍只由 provider webhook 驱动。 +- Lime 客户端 checkout 回跳 URL 改为 HTTPS bridge,保留客户端 deep link 消费和状态刷新逻辑。 +- Creem checkout 已接入官方 `success_url` 参数,成功回跳进入 HTTPS bridge;取消回跳不伪造本地结果,继续依赖 watcher 与 webhook 后的服务端状态。 + +## 下一刀 + +继续用真实渠道沙箱串一次 EPay / Stripe / Creem / Waffo,确认各渠道回跳是否都能回到 `lime://payment/return`,同时验证 webhook finalize 后客户端账本、积分和权益刷新一致。 diff --git a/knip.governance.json b/knip.governance.json deleted file mode 100644 index eb904950c..000000000 --- a/knip.governance.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://unpkg.com/knip@latest/schema.json", - "entry": ["src/main.tsx"], - "project": [ - "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", - "!src/**/*.test.{ts,tsx,js,jsx,mjs,cjs}", - "!src/**/*.spec.{ts,tsx,js,jsx,mjs,cjs}", - "!src/**/__tests__/**", - "!src/**/__mocks__/**", - "!src/**/*.d.ts" - ] -} diff --git a/monitor.sh b/monitor.sh deleted file mode 100755 index e4e0f47ff..000000000 --- a/monitor.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -# 性能监控脚本 - -echo "📊 统一记忆系统 - 性能监控" -echo "" - -# 数据库大小 -DB_SIZE=$(du -h ~/.lime/lime.db 2>/dev/null | cut -f1) -echo "数据库大小: ${DB_SIZE:-未知}" - -# 记忆数量 -MEMORY_COUNT=$(sqlite3 ~/.lime/lime.db "SELECT COUNT(*) FROM unified_memory WHERE archived = 0;" 2>/dev/null) -echo "记忆数量: ${MEMORY_COUNT:-0}" - -# 反馈数量 -FEEDBACK_COUNT=$(sqlite3 ~/.lime/lime.db "SELECT COUNT(*) FROM memory_feedback;" 2>/dev/null) -echo "反馈数量: ${FEEDBACK_COUNT:-0}" - -# 批准率 -if [ "$FEEDBACK_COUNT" -gt 0 ]; then - APPROVE_COUNT=$(sqlite3 ~/.lime/lime.db "SELECT COUNT(*) FROM memory_feedback WHERE action LIKE '%approve%';" 2>/dev/null) - RATE=$(echo "scale=1; $APPROVE_COUNT * 100 / $FEEDBACK_COUNT" | bc 2>/dev/null) - echo "批准率: ${RATE:-0}%" -fi - -echo "" -echo "✅ 监控完成" diff --git a/network-before.md b/network-before.md deleted file mode 100644 index bd7e17919..000000000 --- a/network-before.md +++ /dev/null @@ -1,42 +0,0 @@ -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_config"} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"agent_runtime_list_sessions","args":{"request":{"limit":37}}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"workspace_list"} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"workspace_get_default"} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"sceneapp_list_catalog"} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"workspace_ensure_ready","args":{"id":"849e36ff-8f64-45ed-ba51-aab6b8e182e4"}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"workspace_get","args":{"id":"849e36ff-8f64-45ed-ba51-aab6b8e182e4"}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_api_key_providers"} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_provider_ui_state","args":{"key":"collapsed_groups"}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_local_skills_for_app","args":{"app":"lime"}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_automation_jobs"} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_provider_ui_state","args":{"key":"selected_provider"}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"project_memory_get","args":{"projectId":"849e36ff-8f64-45ed-ba51-aab6b8e182e4"}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"workspace_ensure_ready","args":{"id":"849e36ff-8f64-45ed-ba51-aab6b8e182e4"}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"agent_runtime_list_sessions","args":{"request":{"workspace_id":"849e36ff-8f64-45ed-ba51-aab6b8e182e4","limit":60}}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"aster_agent_init"} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_provider_ui_state","args":{"key":"collapsed_groups"}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_provider_ui_state","args":{"key":"selected_provider"}} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_provider_pool_overview"} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"get_model_registry"} -[POST] http://127.0.0.1:3030/invoke => [200] OK - Request body: {"cmd":"fetch_provider_models_auto","args":{"providerId":"deepseek"}} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bfb689f04..ac9a8356e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.20.0", + "version": "1.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.20.0", + "version": "1.21.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index f152bc103..87d283c79 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.20.0", + "version": "1.21.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index f236f281d..13209c7f4 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -112,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.19.0" \ + --version "1.21.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 2941224ad..d0141c84e 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.19.0", + "version": "1.21.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/post-hmr-state.png b/post-hmr-state.png deleted file mode 100644 index 853bddc7e..000000000 Binary files a/post-hmr-state.png and /dev/null differ diff --git a/scripts/plan-r2-release-cleanup.mjs b/scripts/plan-r2-release-cleanup.mjs new file mode 100644 index 000000000..3676bf234 --- /dev/null +++ b/scripts/plan-r2-release-cleanup.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + if (!item.startsWith("--")) { + continue; + } + const key = item.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + args[key] = "true"; + continue; + } + args[key] = next; + index += 1; + } + return args; +} + +function normalizeVersion(value) { + return String(value || "") + .trim() + .replace(/^v/, ""); +} + +function compareStableVersionsDesc(left, right) { + const leftParts = normalizeVersion(left) + .split(".") + .map((part) => Number(part)); + const rightParts = normalizeVersion(right) + .split(".") + .map((part) => Number(part)); + for (let index = 0; index < 3; index += 1) { + const diff = (rightParts[index] || 0) - (leftParts[index] || 0); + if (diff !== 0) { + return diff; + } + } + return normalizeVersion(right).localeCompare(normalizeVersion(left)); +} + +function extractKeys(value) { + if (Array.isArray(value)) { + return value.flatMap(extractKeys); + } + if (!value || typeof value !== "object") { + return []; + } + if (typeof value.key === "string") { + return [value.key]; + } + if (Array.isArray(value.objects)) { + return value.objects.flatMap(extractKeys); + } + if (Array.isArray(value.result)) { + return value.result.flatMap(extractKeys); + } + return []; +} + +function releaseVersionFromKey(key, channel = "stable") { + const escapedChannel = channel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = key.match(new RegExp(`^lime/${escapedChannel}/v([^/]+)/`)); + return match ? match[1] : null; +} + +function planR2ReleaseCleanup(options) { + const channel = options.channel || "stable"; + const keep = Math.max(1, Number(options.keep || 3)); + const keys = Array.from(new Set(options.keys || [])).sort(); + const current = normalizeVersion(options.currentVersion); + const minimum = normalizeVersion(options.minimumSupportedVersion); + const versions = Array.from( + new Set( + keys.map((key) => releaseVersionFromKey(key, channel)).filter(Boolean), + ), + ).sort(compareStableVersionsDesc); + const protectedVersions = new Set( + versions.slice(0, keep).map(normalizeVersion), + ); + + if (current) { + protectedVersions.add(current); + } + if (minimum) { + protectedVersions.add(minimum); + } + + const deleteKeys = keys.filter((key) => { + const version = releaseVersionFromKey(key, channel); + return version && !protectedVersions.has(normalizeVersion(version)); + }); + + return { + deleteKeys, + protectedVersions: Array.from(protectedVersions).sort( + compareStableVersionsDesc, + ), + versions, + }; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const inputPath = args.input; + if (!inputPath) { + throw new Error("--input is required"); + } + const raw = JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8")); + const plan = planR2ReleaseCleanup({ + channel: args.channel || process.env.LIME_RELEASE_CHANNEL || "stable", + currentVersion: + args.current || process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME, + keep: args.keep || process.env.LIME_R2_KEEP_RELEASES || "3", + keys: extractKeys(raw), + minimumSupportedVersion: + args.minimum || process.env.LIME_MINIMUM_SUPPORTED_VERSION || "", + }); + + const outputPath = args.output ? path.resolve(args.output) : ""; + if (outputPath) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync( + outputPath, + `${plan.deleteKeys.join("\n")}${plan.deleteKeys.length ? "\n" : ""}`, + ); + } else { + process.stdout.write( + `${plan.deleteKeys.join("\n")}${plan.deleteKeys.length ? "\n" : ""}`, + ); + } +} + +const isCli = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isCli) { + main(); +} + +export { extractKeys, planR2ReleaseCleanup, releaseVersionFromKey }; diff --git a/scripts/release-updater-manifest.mjs b/scripts/release-updater-manifest.mjs new file mode 100644 index 000000000..e9660b98c --- /dev/null +++ b/scripts/release-updater-manifest.mjs @@ -0,0 +1,358 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_REQUIRED_PLATFORMS = [ + "darwin-aarch64", + "darwin-x86_64", + "windows-x86_64", +]; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + if (!item.startsWith("--")) { + continue; + } + const key = item.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + args[key] = "true"; + continue; + } + args[key] = next; + index += 1; + } + return args; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJson(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`); +} + +function walkFiles(rootDir) { + if (!fs.existsSync(rootDir)) { + return []; + } + const result = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile()) { + result.push(fullPath); + } + } + } + return result.sort(); +} + +function normalizeVersionTag(version) { + const normalized = String(version || "").trim(); + if (!normalized) { + throw new Error("release version is required"); + } + const withoutPrefix = normalized.replace(/^v/, ""); + if (withoutPrefix.includes("-")) { + throw new Error( + `stable updater manifest does not accept prerelease version: ${normalized}`, + ); + } + if (!/^\d+\.\d+\.\d+(?:\+\S+)?$/.test(withoutPrefix)) { + throw new Error(`release version must be stable semver: ${normalized}`); + } + return `v${withoutPrefix.split("+")[0]}`; +} + +function normalizeStableVersion(version) { + return normalizeVersionTag(version).slice(1); +} + +function normalizeBaseUrl(baseUrl) { + const normalized = String(baseUrl || "") + .trim() + .replace(/\/+$/, ""); + if (!normalized) { + throw new Error("base URL is required"); + } + return normalized; +} + +function encodePathSegment(value) { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +function basenameFromUrl(url) { + try { + const parsed = new URL(url); + return decodeURIComponent(path.posix.basename(parsed.pathname)); + } catch { + return path.basename(String(url || "")); + } +} + +function collectFilesByBasename(files) { + const byName = new Map(); + for (const file of files) { + const name = path.basename(file); + if (!byName.has(name)) { + byName.set(name, []); + } + byName.get(name).push(file); + } + return byName; +} + +function findLatestManifestFiles(files) { + return files.filter((file) => /^latest.*\.json$/i.test(path.basename(file))); +} + +function resolveReferencedAssetFile(manifestFile, candidates) { + const manifestDir = path.dirname(manifestFile); + const assetCandidates = candidates.filter( + (candidate) => !/^latest.*\.json$/i.test(path.basename(candidate)), + ); + return ( + assetCandidates.find( + (candidate) => path.dirname(candidate) === manifestDir, + ) || assetCandidates[0] + ); +} + +function buildPlatformAssetPath(platformKey, assetName) { + return `${encodePathSegment(platformKey)}/${encodePathSegment(assetName)}`; +} + +function collectUpdaterManifest(options) { + const assetsDir = path.resolve(options.assetsDir); + const baseUrl = normalizeBaseUrl(options.baseUrl); + const channel = options.channel || "stable"; + const versionTag = normalizeVersionTag(options.version); + const version = versionTag.slice(1); + const files = walkFiles(assetsDir); + const filesByBasename = collectFilesByBasename(files); + const latestFiles = findLatestManifestFiles(files); + const requiredPlatforms = ( + options.requiredPlatforms || DEFAULT_REQUIRED_PLATFORMS + ) + .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 (!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, + }); + } + } + + const missing = requiredPlatforms.filter((platform) => !platforms[platform]); + if (missing.length > 0) { + throw new Error( + `updater manifest missing required platforms: ${missing.join(", ")}`, + ); + } + + const manifest = { + version, + notes, + pub_date: pubDate || new Date().toISOString(), + platforms, + }; + + const r2UploadPlan = [ + ...Array.from(referencedFiles.values()).map((item) => ({ + cacheControl: "public, max-age=31536000, immutable", + contentType: "application/octet-stream", + file: item.file, + key: `lime/${channel}/${versionTag}/${item.targetPath}`, + })), + ].sort((a, b) => a.key.localeCompare(b.key)); + + return { + manifest, + referencedFiles: Array.from(referencedFiles.values()) + .map((item) => item.file) + .sort(), + r2UploadPlan, + version, + versionTag, + }; +} + +function writeOutputs(result, outDir, channel) { + const outputDir = path.resolve(outDir); + const latestPath = path.join(outputDir, "latest.json"); + const versionedLatestPath = path.join( + outputDir, + result.versionTag, + "latest.json", + ); + const uploadPlanPath = path.join(outputDir, "r2-upload-plan.json"); + const metadataPath = path.join(outputDir, "manifest-meta.json"); + + writeJson(latestPath, result.manifest); + writeJson(versionedLatestPath, result.manifest); + writeJson(uploadPlanPath, [ + ...result.r2UploadPlan, + { + cacheControl: "public, max-age=60, stale-while-revalidate=300", + contentType: "application/json", + file: versionedLatestPath, + key: `lime/${channel}/${result.versionTag}/latest.json`, + }, + { + cacheControl: "public, max-age=60, stale-while-revalidate=300", + contentType: "application/json", + file: latestPath, + key: `lime/${channel}/latest.json`, + }, + ]); + writeJson(metadataPath, { + platformCount: Object.keys(result.manifest.platforms).length, + version: result.version, + versionTag: result.versionTag, + }); + + return { + latestPath, + metadataPath, + uploadPlanPath, + versionedLatestPath, + }; +} + +function loadNotes(args) { + if (args["notes-file"]) { + return fs.readFileSync(path.resolve(args["notes-file"]), "utf8").trim(); + } + return process.env.RELEASE_BODY || ""; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const requiredPlatforms = ( + args["required-platforms"] || + process.env.REQUIRED_UPDATE_PLATFORMS || + "" + ) + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const channel = args.channel || process.env.LIME_RELEASE_CHANNEL || "stable"; + const result = collectUpdaterManifest({ + assetsDir: args["assets-dir"] || "release-assets", + baseUrl: + args["base-url"] || + process.env.LIME_UPDATES_BASE_URL || + "https://updates.limecloud.com", + channel, + notes: loadNotes(args), + pubDate: args["pub-date"] || process.env.RELEASE_PUB_DATE || "", + requiredPlatforms: + requiredPlatforms.length > 0 + ? requiredPlatforms + : DEFAULT_REQUIRED_PLATFORMS, + version: + args.version || process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME, + }); + const output = writeOutputs( + result, + args["out-dir"] || "release-updater", + channel, + ); + console.log( + JSON.stringify( + { + ...output, + platformCount: Object.keys(result.manifest.platforms).length, + version: result.version, + }, + null, + 2, + ), + ); +} + +const isCli = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isCli) { + main(); +} + +export { collectUpdaterManifest, normalizeVersionTag, writeOutputs }; diff --git a/scripts/release-updater-manifest.test.mjs b/scripts/release-updater-manifest.test.mjs new file mode 100644 index 000000000..f7b903c84 --- /dev/null +++ b/scripts/release-updater-manifest.test.mjs @@ -0,0 +1,220 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { planR2ReleaseCleanup } from "./plan-r2-release-cleanup.mjs"; +import { + collectUpdaterManifest, + writeOutputs, +} from "./release-updater-manifest.mjs"; + +function writeJson(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`); +} + +function writeFile(filePath, content = "asset") { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +describe("release updater manifest", () => { + it("聚合平台 latest.json 并改写为 R2 自域名 URL", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "lime-release-")); + const assetsDir = path.join(root, "release-assets"); + + writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz")); + writeFile( + path.join(assetsDir, "x86_64-apple-darwin", "Lime-x64.app.tar.gz"), + ); + writeFile(path.join(assetsDir, "x86_64-pc-windows-msvc", "Lime.nsis.zip")); + + writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), { + version: "1.20.0", + notes: "notes", + pub_date: "2026-04-28T00:00:00Z", + platforms: { + "darwin-aarch64": { + signature: "sig-arm", + url: "https://github.com/limecloud/lime/releases/download/v1.20.0/Lime.app.tar.gz", + }, + }, + }); + writeJson(path.join(assetsDir, "x86_64-apple-darwin", "latest.json"), { + version: "1.20.0", + platforms: { + "darwin-x86_64": { + signature: "sig-x64", + url: "https://github.com/limecloud/lime/releases/download/v1.20.0/Lime-x64.app.tar.gz", + }, + }, + }); + writeJson(path.join(assetsDir, "x86_64-pc-windows-msvc", "latest.json"), { + version: "1.20.0", + platforms: { + "windows-x86_64": { + signature: "sig-win", + url: "https://github.com/limecloud/lime/releases/download/v1.20.0/Lime.nsis.zip", + }, + }, + }); + + const result = collectUpdaterManifest({ + assetsDir, + baseUrl: "https://updates.limecloud.com/", + channel: "stable", + requiredPlatforms: ["darwin-aarch64", "darwin-x86_64", "windows-x86_64"], + version: "v1.20.0", + }); + + expect(result.manifest.version).toBe("1.20.0"); + expect(Object.keys(result.manifest.platforms).sort()).toEqual([ + "darwin-aarch64", + "darwin-x86_64", + "windows-x86_64", + ]); + expect(result.manifest.platforms["darwin-aarch64"].url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz", + ); + expect(result.r2UploadPlan).toHaveLength(3); + + const output = writeOutputs(result, path.join(root, "out"), "stable"); + expect(fs.existsSync(output.latestPath)).toBe(true); + expect(fs.existsSync(output.versionedLatestPath)).toBe(true); + }); + + it("缺少必需平台时失败,避免发布半可用清单", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "lime-release-missing-"), + ); + const assetsDir = path.join(root, "release-assets"); + writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz")); + writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), { + version: "1.20.0", + platforms: { + "darwin-aarch64": { + signature: "sig-arm", + url: "https://example.com/Lime.app.tar.gz", + }, + }, + }); + + expect(() => + collectUpdaterManifest({ + assetsDir, + baseUrl: "https://updates.limecloud.com", + requiredPlatforms: ["darwin-aarch64", "windows-x86_64"], + version: "v1.20.0", + }), + ).toThrow(/missing required platforms/); + }); + + it("平台 latest.json 版本必须和发布 tag 一致", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "lime-release-version-"), + ); + const assetsDir = path.join(root, "release-assets"); + writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz")); + writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), { + version: "1.19.0", + platforms: { + "darwin-aarch64": { + signature: "sig-arm", + url: "https://example.com/Lime.app.tar.gz", + }, + }, + }); + + expect(() => + collectUpdaterManifest({ + assetsDir, + baseUrl: "https://updates.limecloud.com", + requiredPlatforms: ["darwin-aarch64"], + version: "v1.20.0", + }), + ).toThrow(/does not match release/); + }); + + it("同名跨平台 updater 包应写入独立 R2 路径", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "lime-release-same-name-"), + ); + const assetsDir = path.join(root, "release-assets"); + + writeFile( + path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"), + "arm", + ); + writeFile( + path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz"), + "x64", + ); + + writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), { + version: "1.20.0", + platforms: { + "darwin-aarch64": { + signature: "sig-arm", + url: "https://example.com/Lime.app.tar.gz", + }, + }, + }); + writeJson(path.join(assetsDir, "x86_64-apple-darwin", "latest.json"), { + version: "1.20.0", + platforms: { + "darwin-x86_64": { + signature: "sig-x64", + url: "https://example.com/Lime.app.tar.gz", + }, + }, + }); + + const result = collectUpdaterManifest({ + assetsDir, + baseUrl: "https://updates.limecloud.com", + channel: "stable", + requiredPlatforms: ["darwin-aarch64", "darwin-x86_64"], + version: "v1.20.0", + }); + + expect(result.manifest.platforms["darwin-aarch64"].url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz", + ); + expect(result.manifest.platforms["darwin-x86_64"].url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz", + ); + 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", + ]); + expect( + result.r2UploadPlan + .map((item) => fs.readFileSync(item.file, "utf8")) + .sort(), + ).toEqual(["arm", "x64"]); + }); +}); + +describe("R2 release cleanup", () => { + it("只删除超过保留窗口且未受保护的旧版本", () => { + const keys = [ + "lime/stable/v1.20.0/latest.json", + "lime/stable/v1.20.0/Lime.nsis.zip", + "lime/stable/v1.19.0/Lime.nsis.zip", + "lime/stable/v1.18.0/Lime.nsis.zip", + "lime/stable/v1.17.0/Lime.nsis.zip", + "lime/stable/v1.16.0/Lime.nsis.zip", + ]; + + const plan = planR2ReleaseCleanup({ + currentVersion: "v1.20.0", + keep: 3, + keys, + minimumSupportedVersion: "v1.16.0", + }); + + expect(plan.deleteKeys).toEqual(["lime/stable/v1.17.0/Lime.nsis.zip"]); + expect(plan.protectedVersions).toContain("1.16.0"); + }); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d44ffcf13..8d012b36f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.20.0" +version = "1.21.0" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.20.0" +version = "1.21.0" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.20.0" +version = "1.21.0" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.20.0" +version = "1.21.0" dependencies = [ "clap", "lime-core", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.20.0" +version = "1.21.0" dependencies = [ "async-trait", "lime-core", @@ -5279,7 +5279,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.20.0" +version = "1.21.0" dependencies = [ "aster-models", "async-trait", @@ -5319,7 +5319,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.20.0" +version = "1.21.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5354,7 +5354,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.20.0" +version = "1.21.0" dependencies = [ "aes", "axum 0.7.9", @@ -5384,7 +5384,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.20.0" +version = "1.21.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5404,7 +5404,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.20.0" +version = "1.21.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.20.0" +version = "1.21.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5451,7 +5451,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.20.0" +version = "1.21.0" dependencies = [ "async-trait", "lime-core", @@ -5470,7 +5470,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.20.0" +version = "1.21.0" dependencies = [ "anyhow", "async-stream", @@ -5525,7 +5525,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.20.0" +version = "1.21.0" dependencies = [ "aster-core", "async-stream", @@ -5570,7 +5570,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.20.0" +version = "1.21.0" dependencies = [ "axum 0.7.9", "futures", @@ -5585,7 +5585,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.20.0" +version = "1.21.0" dependencies = [ "anyhow", "aster-core", @@ -5606,6 +5606,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "rusqlite", + "semver 1.0.27", "serde", "serde_json", "serde_yaml", @@ -5627,7 +5628,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.20.0" +version = "1.21.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5645,7 +5646,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.20.0" +version = "1.21.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f397bdb66..c4cdfab94 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"] resolver = "2" [workspace.package] -version = "1.20.0" +version = "1.21.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -81,6 +81,7 @@ cron = "0.15" # 工具库 dirs = "5" regex = "1" +semver = "1" md5 = "0.7" jsonwebtoken = "9.3.1" urlencoding = "2" @@ -188,7 +189,7 @@ version = "2.4" [package] name = "lime" -version = "1.20.0" +version = "1.21.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/services/Cargo.toml b/src-tauri/crates/services/Cargo.toml index cb8c367f5..b21e04154 100644 --- a/src-tauri/crates/services/Cargo.toml +++ b/src-tauri/crates/services/Cargo.toml @@ -48,6 +48,7 @@ glob.workspace = true sysinfo.workspace = true whoami.workspace = true regex.workspace = true +semver.workspace = true sha2.workspace = true base64.workspace = true md5.workspace = true diff --git a/src-tauri/crates/services/src/update_check_service.rs b/src-tauri/crates/services/src/update_check_service.rs index 76e20b477..fd3412a96 100644 --- a/src-tauri/crates/services/src/update_check_service.rs +++ b/src-tauri/crates/services/src/update_check_service.rs @@ -1,3 +1,4 @@ +use semver::{BuildMetadata, Version}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; @@ -103,29 +104,29 @@ impl UpdateCheckService { } /// 版本比较:返回 true 如果 latest > current pub fn version_compare(current: &str, latest: &str) -> bool { - let current = current.trim_start_matches('v'); - let latest = latest.trim_start_matches('v'); + let Some(mut current) = parse_semver(current) else { + return false; + }; + let Some(mut latest) = parse_semver(latest) else { + return false; + }; - let current_parts: Vec = current.split('.').filter_map(|s| s.parse().ok()).collect(); - let latest_parts: Vec = latest.split('.').filter_map(|s| s.parse().ok()).collect(); - - let max_len = current_parts.len().max(latest_parts.len()); - - for i in 0..max_len { - let current_part = current_parts.get(i).unwrap_or(&0); - let latest_part = latest_parts.get(i).unwrap_or(&0); - - if latest_part > current_part { - return true; - } else if latest_part < current_part { - return false; - } + if !latest.pre.is_empty() { + return false; } - false + current.build = BuildMetadata::EMPTY; + latest.build = BuildMetadata::EMPTY; + + latest > current } } +fn parse_semver(value: &str) -> Option { + let trimmed = value.trim().trim_start_matches('v'); + Version::parse(trimmed).ok() +} + impl Default for UpdateCheckService { fn default() -> Self { Self::new() @@ -160,6 +161,15 @@ mod tests { assert!(!UpdateCheckService::version_compare("0.14.1", "0.14.0")); assert!(!UpdateCheckService::version_compare("0.14.0", "0.14.0")); assert!(!UpdateCheckService::version_compare("1.0.0", "0.14.0")); + assert!(!UpdateCheckService::version_compare( + "1.0.0", + "1.1.0-beta.1" + )); + assert!(UpdateCheckService::version_compare("1.1.0-beta.1", "1.1.0")); + assert!(!UpdateCheckService::version_compare( + "1.0.0+build.1", + "1.0.0+build.2" + )); } #[test] diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index a38decc94..494620008 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -17,7 +17,7 @@ pub mod scheduler_service; mod state; mod types; mod utils; -mod window_chrome; +pub(crate) mod window_chrome; pub use runner::run; pub use scheduler_service::{SchedulerService, SchedulerServiceConfig}; diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 5f1b9e1ed..1fb54c2d3 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -51,19 +51,6 @@ fn should_minimize_to_tray(window_label: &str, minimize_to_tray: bool) -> bool { minimize_to_tray && window_label == MAIN_WINDOW_LABEL } -fn reveal_main_window(window: &tauri::WebviewWindow) { - let run_action = |action: &str, operation: &dyn Fn() -> tauri::Result<()>| { - if let Err(error) = operation() { - tracing::warn!("[启动] 主窗口{}失败: {}", action, error); - } - }; - - run_action("取消最小化", &|| window.unminimize()); - run_action("最大化", &|| window.maximize()); - run_action("显示", &|| window.show()); - run_action("聚焦", &|| window.set_focus()); -} - fn report_fatal_startup_error(stage: &str, error: &str) { let message = format!("Lime 启动失败({stage})\n\n{error}"); tracing::error!("{message}"); @@ -209,6 +196,11 @@ pub fn run() { builder = builder.plugin(tauri_plugin_deep_link::init()); } + #[cfg(target_os = "macos")] + { + builder = builder.menu(super::window_chrome::build_lime_app_menu); + } + if should_enable_single_instance() { builder = builder // 单实例插件:当第二个实例启动时,将 URL 传递给第一个实例 @@ -218,7 +210,7 @@ pub fn run() { // 将窗口带到前台 if should_reveal_main_window_on_startup() { if let Some(window) = app.get_webview_window("main") { - reveal_main_window(&window); + super::window_chrome::reveal_main_window(&window, "启动"); } } else { tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)"); @@ -327,7 +319,7 @@ pub fn run() { super::window_chrome::apply_main_window_chrome(&main_window); if should_reveal_main_window_on_startup() { - reveal_main_window(&main_window); + super::window_chrome::reveal_main_window(&main_window, "启动"); } else { tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)"); } diff --git a/src-tauri/src/app/window_chrome.rs b/src-tauri/src/app/window_chrome.rs index 8e8757c66..4b4cb3fdc 100644 --- a/src-tauri/src/app/window_chrome.rs +++ b/src-tauri/src/app/window_chrome.rs @@ -1,13 +1,150 @@ //! 主窗口系统 chrome 收口。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WindowRevealAction { + Unminimize, + Maximize, + Show, + Focus, +} + +const WINDOW_REVEAL_ACTIONS: [WindowRevealAction; 4] = [ + WindowRevealAction::Unminimize, + WindowRevealAction::Maximize, + WindowRevealAction::Show, + WindowRevealAction::Focus, +]; + +#[cfg(any(target_os = "macos", test))] +const LIME_MACOS_APP_ICON_BYTES: &[u8] = include_bytes!("../../../public/logo.png"); +#[cfg(any(target_os = "macos", test))] +const LIME_MACOS_ABOUT_COPYRIGHT: &str = "Copyright © 2026 Lime"; + +impl WindowRevealAction { + fn label(self) -> &'static str { + match self { + Self::Unminimize => "取消最小化", + Self::Maximize => "最大化", + Self::Show => "显示", + Self::Focus => "聚焦", + } + } +} + /// 应用主窗口的 macOS 标题栏样式,避免启动配置被 headless/dev 链路绕过。 pub fn apply_main_window_chrome(window: &tauri::WebviewWindow) { apply_macos_titlebar_overlay(window); + apply_macos_application_icon(); +} + +/// 最大化、显示并聚焦主窗口,保持启动和托盘打开时的铺满体验。 +pub fn reveal_main_window(window: &tauri::WebviewWindow, source: &str) { + for action in WINDOW_REVEAL_ACTIONS { + let result = match action { + WindowRevealAction::Unminimize => window.unminimize(), + WindowRevealAction::Maximize => window.maximize(), + WindowRevealAction::Show => window.show(), + WindowRevealAction::Focus => window.set_focus(), + }; + + if let Err(error) = result { + tracing::warn!("[{}] 主窗口{}失败: {}", source, action.label(), error); + } + } +} + +#[cfg(target_os = "macos")] +pub fn build_lime_app_menu( + app_handle: &tauri::AppHandle, +) -> tauri::Result> { + use tauri::image::Image; + use tauri::menu::{ + AboutMetadata, Menu, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, + }; + + let package_info = app_handle.package_info(); + let version = package_info.version.to_string(); + let icon = Image::from_bytes(LIME_MACOS_APP_ICON_BYTES)?; + let about_metadata = AboutMetadata { + name: Some(package_info.name.clone()), + version: Some(version.clone()), + short_version: Some(version), + copyright: Some(LIME_MACOS_ABOUT_COPYRIGHT.to_string()), + icon: Some(icon), + ..Default::default() + }; + + let window_menu = Submenu::with_id_and_items( + app_handle, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app_handle, None)?, + &PredefinedMenuItem::maximize(app_handle, None)?, + &PredefinedMenuItem::separator(app_handle)?, + &PredefinedMenuItem::close_window(app_handle, None)?, + ], + )?; + + let help_menu = Submenu::with_id_and_items(app_handle, HELP_SUBMENU_ID, "Help", true, &[])?; + + Menu::with_items( + app_handle, + &[ + &Submenu::with_items( + app_handle, + package_info.name.clone(), + true, + &[ + &PredefinedMenuItem::about(app_handle, None, Some(about_metadata))?, + &PredefinedMenuItem::separator(app_handle)?, + &PredefinedMenuItem::services(app_handle, None)?, + &PredefinedMenuItem::separator(app_handle)?, + &PredefinedMenuItem::hide(app_handle, None)?, + &PredefinedMenuItem::hide_others(app_handle, None)?, + &PredefinedMenuItem::separator(app_handle)?, + &PredefinedMenuItem::quit(app_handle, None)?, + ], + )?, + &Submenu::with_items( + app_handle, + "File", + true, + &[&PredefinedMenuItem::close_window(app_handle, None)?], + )?, + &Submenu::with_items( + app_handle, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app_handle, None)?, + &PredefinedMenuItem::redo(app_handle, None)?, + &PredefinedMenuItem::separator(app_handle)?, + &PredefinedMenuItem::cut(app_handle, None)?, + &PredefinedMenuItem::copy(app_handle, None)?, + &PredefinedMenuItem::paste(app_handle, None)?, + &PredefinedMenuItem::select_all(app_handle, None)?, + ], + )?, + &Submenu::with_items( + app_handle, + "View", + true, + &[&PredefinedMenuItem::fullscreen(app_handle, None)?], + )?, + &window_menu, + &help_menu, + ], + ) } #[cfg(not(target_os = "macos"))] fn apply_macos_titlebar_overlay(_window: &tauri::WebviewWindow) {} +#[cfg(not(target_os = "macos"))] +fn apply_macos_application_icon() {} + #[cfg(target_os = "macos")] #[allow(deprecated)] fn apply_macos_titlebar_overlay(window: &tauri::WebviewWindow) { @@ -30,7 +167,7 @@ fn apply_macos_titlebar_overlay(window: &tauri::WebviewWindow) { ns_window.styleMask() | NSWindowStyleMask::NSFullSizeContentViewWindowMask; ns_window.setStyleMask_(style_mask); - hide_standard_window_buttons(ns_window); + show_standard_window_buttons(ns_window); } } Err(error) => { @@ -44,9 +181,38 @@ fn apply_macos_titlebar_overlay(window: &tauri::WebviewWindow) { #[cfg(target_os = "macos")] #[allow(deprecated)] -unsafe fn hide_standard_window_buttons(ns_window: cocoa::base::id) { +fn apply_macos_application_icon() { + use cocoa::appkit::{NSApp, NSApplication, NSImage}; + use cocoa::base::{id, nil}; + use cocoa::foundation::{NSData, NSUInteger}; + use std::ffi::c_void; + + unsafe { + let data = ::dataWithBytes_length_( + nil, + LIME_MACOS_APP_ICON_BYTES.as_ptr() as *const c_void, + LIME_MACOS_APP_ICON_BYTES.len() as NSUInteger, + ); + if data == nil { + tracing::warn!("[窗口] 创建 Lime logo 数据失败,无法设置 macOS About 图标"); + return; + } + + let image = ::initWithData_(NSImage::alloc(nil), data); + if image == nil { + tracing::warn!("[窗口] 解析 Lime logo 图片失败,无法设置 macOS About 图标"); + return; + } + + NSApp().setApplicationIconImage_(image); + } +} + +#[cfg(target_os = "macos")] +#[allow(deprecated)] +unsafe fn show_standard_window_buttons(ns_window: cocoa::base::id) { use cocoa::appkit::{NSWindow, NSWindowButton}; - use cocoa::base::{nil, YES}; + use cocoa::base::{nil, NO}; use objc::{msg_send, sel, sel_impl}; for button_kind in [ @@ -56,7 +222,46 @@ unsafe fn hide_standard_window_buttons(ns_window: cocoa::base::id) { ] { let button = ns_window.standardWindowButton_(button_kind); if button != nil { - let _: () = msg_send![button, setHidden: YES]; + let _: () = msg_send![button, setHidden: NO]; } } } + +#[cfg(test)] +mod tests { + use super::{ + WindowRevealAction, LIME_MACOS_ABOUT_COPYRIGHT, LIME_MACOS_APP_ICON_BYTES, + WINDOW_REVEAL_ACTIONS, + }; + + #[test] + fn reveal_actions_should_keep_maximized_startup_order() { + assert_eq!( + WINDOW_REVEAL_ACTIONS, + [ + WindowRevealAction::Unminimize, + WindowRevealAction::Maximize, + WindowRevealAction::Show, + WindowRevealAction::Focus, + ], + ); + } + + #[test] + fn macos_about_icon_should_use_lime_logo_png() { + assert!(LIME_MACOS_APP_ICON_BYTES.starts_with(b"\x89PNG\r\n\x1a\n")); + assert!( + LIME_MACOS_APP_ICON_BYTES.len() > 1024, + "Lime logo 图标资源不应为空或退回占位图" + ); + assert!( + tauri::image::Image::from_bytes(LIME_MACOS_APP_ICON_BYTES).is_ok(), + "macOS About 菜单必须能把 Lime logo 解码成原生菜单图标" + ); + } + + #[test] + fn macos_about_metadata_should_stay_minimal() { + assert_eq!(LIME_MACOS_ABOUT_COPYRIGHT, "Copyright © 2026 Lime"); + } +} diff --git a/src-tauri/src/commands/update_cmd.rs b/src-tauri/src/commands/update_cmd.rs index d044f4ffb..38e391a66 100644 --- a/src-tauri/src/commands/update_cmd.rs +++ b/src-tauri/src/commands/update_cmd.rs @@ -18,13 +18,12 @@ use tauri_plugin_updater::UpdaterExt; const DAY_SECONDS: u64 = 24 * 3600; const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60; -const FALLBACK_RELEASES_URL: &str = "https://github.com/aiclientproxy/lime/releases"; -const DEFAULT_UPDATE_MANIFEST_URL: &str = - "https://github.com/aiclientproxy/lime/releases/latest/download/latest.json"; +const FALLBACK_RELEASES_URL: &str = "https://github.com/limecloud/lime/releases"; +const DEFAULT_UPDATE_MANIFEST_URL: &str = "https://updates.limecloud.com/lime/stable/latest.json"; /// 编译期注入 updater 公钥;开发环境可为空,此时仅保留手动下载兜底。 const COMPILED_UPDATER_PUBLIC_KEY: Option<&str> = option_env!("LIME_UPDATER_PUBLIC_KEY"); -/// 编译期注入 updater manifest 地址;未配置时使用 GitHub Releases latest.json。 +/// 编译期注入 updater manifest 地址;未配置时使用 Cloudflare R2 稳定清单。 const COMPILED_UPDATER_ENDPOINT: Option<&str> = option_env!("LIME_UPDATER_ENDPOINT"); /// 更新检查配置(前端可见) @@ -62,6 +61,8 @@ pub struct VersionCheckResult { pub download_url: Option, #[serde(rename = "releaseNotes")] pub release_notes: Option, + #[serde(rename = "releaseNotesUrl")] + pub release_notes_url: Option, #[serde(rename = "pubDate")] pub pub_date: Option, pub error: Option, @@ -97,7 +98,6 @@ struct StaticUpdateManifest { #[derive(Debug, Deserialize)] struct StaticUpdatePlatform { url: String, - #[allow(dead_code)] signature: Option, } @@ -131,7 +131,7 @@ fn updater_public_key() -> Option<&'static str> { fn release_tag_url(version: &str) -> String { format!( - "https://github.com/aiclientproxy/lime/releases/tag/v{}", + "https://github.com/limecloud/lime/releases/tag/v{}", version.trim_start_matches('v') ) } @@ -195,16 +195,18 @@ fn build_update_info( latest: Option, release_notes: Option, pub_date: Option, + download_url: Option, error: Option, ) -> UpdateInfo { let current_version = UpdateCheckService::current_version().to_string(); let latest_version = latest .as_deref() .map(|value| value.trim_start_matches('v').to_string()); - let download_url = latest_version + let release_page_url = latest_version .as_deref() .map(release_tag_url) .or_else(|| Some(FALLBACK_RELEASES_URL.to_string())); + let download_url = download_url.or_else(|| release_page_url.clone()); let has_update = latest_version .as_deref() .map(|latest_version| UpdateCheckService::version_compare(¤t_version, latest_version)) @@ -215,7 +217,7 @@ fn build_update_info( latest_version, has_update, download_url: download_url.clone(), - release_notes_url: download_url, + release_notes_url: release_page_url, release_notes, pub_date, checked_at: current_unix_timestamp(), @@ -228,20 +230,16 @@ fn build_update_info_from_cache_or_default( error: Option, ) -> UpdateInfo { if let Some(cached) = cache { - let mut info = build_update_info( + return build_update_info( cached.latest.clone(), cached.release_notes.clone(), cached.pub_date.clone(), + cached.download_url.clone(), error, ); - if cached.download_url.is_some() { - info.download_url = cached.download_url.clone(); - info.release_notes_url = cached.download_url.clone(); - } - return info; } - build_update_info(None, None, None, error) + build_update_info(None, None, None, None, error) } fn build_version_check_result(info: UpdateInfo) -> VersionCheckResult { @@ -251,6 +249,7 @@ fn build_version_check_result(info: UpdateInfo) -> VersionCheckResult { has_update: info.has_update, download_url: info.download_url, release_notes: info.release_notes, + release_notes_url: info.release_notes_url, pub_date: info.pub_date, error: info.error, } @@ -259,26 +258,49 @@ fn build_version_check_result(info: UpdateInfo) -> VersionCheckResult { fn manifest_to_cache(manifest: &StaticUpdateManifest, checked_at: u64) -> UpdateCheckCache { UpdateCheckCache { latest: Some(manifest.version.trim_start_matches('v').to_string()), - download_url: Some(release_tag_url(&manifest.version)), + download_url: manifest_platform_download_url(manifest) + .or_else(|| Some(release_tag_url(&manifest.version))), release_notes: manifest.notes.clone(), pub_date: manifest.pub_date.clone(), last_checked_unix: checked_at, } } +fn manifest_platform_download_url(manifest: &StaticUpdateManifest) -> Option { + current_platform_key() + .and_then(|platform_key| manifest.platforms.get(platform_key)) + .filter(|platform| { + !platform.url.trim().is_empty() + && platform + .signature + .as_deref() + .is_some_and(|signature| !signature.trim().is_empty()) + }) + .map(|platform| platform.url.trim()) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) +} + fn build_update_info_from_manifest(manifest: StaticUpdateManifest) -> UpdateInfo { let latest_version = manifest.version.trim_start_matches('v').to_string(); + let download_url = manifest_platform_download_url(&manifest); let platform_error = match current_platform_key() { Some(platform_key) if manifest .platforms .get(platform_key) - .is_some_and(|platform| !platform.url.trim().is_empty()) => + .is_some_and(|platform| { + !platform.url.trim().is_empty() + && platform + .signature + .as_deref() + .is_some_and(|signature| !signature.trim().is_empty()) + }) => { None } Some(platform_key) if manifest.platforms.contains_key(platform_key) => Some(format!( - "已检测到新版本,但当前平台 {} 的安装包地址为空,请前往发布页手动下载", + "已检测到新版本,但当前平台 {} 的更新包地址或签名为空,请前往发布页手动下载", platform_key )), Some(platform_key) => Some(format!( @@ -292,6 +314,7 @@ fn build_update_info_from_manifest(manifest: StaticUpdateManifest) -> UpdateInfo Some(latest_version), manifest.notes, manifest.pub_date, + download_url, platform_error, ) } @@ -840,6 +863,7 @@ mod tests { fn test_build_update_info_from_manifest() { let next_version_tag = next_patch_version_tag(); let next_version = next_version_tag.trim_start_matches('v').to_string(); + let platform_url = "https://updates.limecloud.com/lime/stable/v9.9.9/lime.app.tar.gz"; let manifest = StaticUpdateManifest { version: next_version_tag, notes: Some("bug fixes".to_string()), @@ -849,7 +873,7 @@ mod tests { .unwrap_or("windows-x86_64") .to_string(), StaticUpdatePlatform { - url: "https://example.com/lime.nsis.zip".to_string(), + url: platform_url.to_string(), signature: Some("sig".to_string()), }, )]), @@ -858,6 +882,51 @@ mod tests { let info = build_update_info_from_manifest(manifest); assert_eq!(info.latest_version.as_deref(), Some(next_version.as_str())); assert!(info.has_update); - assert_eq!(info.error, None); + if current_platform_key().is_some() { + assert_eq!(info.download_url.as_deref(), Some(platform_url)); + assert_eq!(info.error, None); + } else { + assert_eq!( + info.download_url.as_deref(), + Some(release_tag_url(&next_version).as_str()) + ); + assert!(info + .error + .as_deref() + .is_some_and(|message| message.contains("暂不支持"))); + } + } + + #[test] + fn test_build_update_info_from_manifest_requires_signature() { + let Some(platform_key) = current_platform_key() else { + return; + }; + let manifest = StaticUpdateManifest { + version: next_patch_version_tag(), + notes: None, + pub_date: None, + platforms: HashMap::from([( + platform_key.to_string(), + StaticUpdatePlatform { + url: "https://updates.limecloud.com/lime/stable/v9.9.9/lime.app.tar.gz" + .to_string(), + signature: None, + }, + )]), + }; + + let info = build_update_info_from_manifest(manifest); + assert!(info.has_update); + let expected_release_url = + release_tag_url(info.latest_version.as_deref().expect("应存在最新版本")); + assert_eq!( + info.download_url.as_deref(), + Some(expected_release_url.as_str()) + ); + assert!(info + .error + .as_deref() + .is_some_and(|message| message.contains("更新包地址或签名为空"))); } } diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 5e810acdf..a07c7a147 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -783,6 +783,24 @@ mod tests { } } + #[tokio::test] + async fn agent_generate_title_bridge_requires_app_handle_in_test_state() { + let state = make_test_state(); + + let error = handle_command( + &state, + "agent_generate_title", + Some(serde_json::json!({ + "sessionId": "session-1", + "titleKind": "session" + })), + ) + .await + .expect_err("agent_generate_title without app handle should fail after routing"); + + assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle")); + } + #[tokio::test] async fn fetch_provider_models_auto_is_bridged() { let state = make_test_state(); diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs index 80023467e..f1fe75291 100644 --- a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs +++ b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs @@ -41,7 +41,8 @@ pub(super) async fn try_handle( ) -> Result, DynError> { if !matches!( cmd, - "agent_runtime_submit_turn" + "agent_generate_title" + | "agent_runtime_submit_turn" | "agent_runtime_interrupt_turn" | "agent_runtime_compact_session" | "agent_runtime_resume_thread" @@ -65,6 +66,40 @@ pub(super) async fn try_handle( let app_handle = require_app_handle(state)?; let result = match cmd { + "agent_generate_title" => { + let args = args_or_default(args); + let session_id = args + .get("sessionId") + .or_else(|| args.get("session_id")) + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let preview_text = args + .get("previewText") + .or_else(|| args.get("preview_text")) + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let title_kind = args + .get("titleKind") + .or_else(|| args.get("title_kind")) + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let config_manager = app_handle.state::(); + + serde_json::to_value( + crate::commands::agent_cmd::agent_generate_title( + app_handle.clone(), + aster_state, + db, + config_manager, + session_id, + preview_text, + title_kind, + ) + .await?, + )? + } "agent_runtime_submit_turn" => { let request = parse_request::< crate::commands::aster_agent_cmd::AgentRuntimeSubmitTurnRequest, diff --git a/src-tauri/src/tray/events.rs b/src-tauri/src/tray/events.rs index a811494f6..7c65fc820 100644 --- a/src-tauri/src/tray/events.rs +++ b/src-tauri/src/tray/events.rs @@ -110,27 +110,8 @@ fn show_and_focus_main_window(app: &AppHandle) { /// 显示并聚焦窗口的辅助函数 fn show_and_focus_window(window: &tauri::WebviewWindow) { - // 取消最小化 - if let Err(e) = window.unminimize() { - tracing::error!("取消最小化窗口失败: {}", e); - } - - // 保持与主窗口默认行为一致:显示时尽量最大化 - if let Err(e) = window.maximize() { - tracing::error!("最大化主窗口失败: {}", e); - } - - // 显示窗口 - if let Err(e) = window.show() { - tracing::error!("显示主窗口失败: {}", e); - } else { - info!("主窗口已显示"); - } - - // 聚焦窗口 - if let Err(e) = window.set_focus() { - tracing::error!("聚焦主窗口失败: {}", e); - } + crate::app::window_chrome::reveal_main_window(window, "托盘"); + info!("主窗口已显示"); } #[cfg(test)] diff --git a/src-tauri/src/tray/menu_handler.rs b/src-tauri/src/tray/menu_handler.rs index 31e8b0253..b6873442e 100644 --- a/src-tauri/src/tray/menu_handler.rs +++ b/src-tauri/src/tray/menu_handler.rs @@ -128,26 +128,7 @@ fn handle_open_window(app: &AppHandle) { info!("[托盘] 用户请求打开主窗口"); if let Some(window) = app.get_webview_window("main") { - // 取消最小化 - if let Err(e) = window.unminimize() { - error!("[托盘] 取消最小化窗口失败: {}", e); - } - - // 保持与主窗口默认行为一致:显示时尽量最大化 - if let Err(e) = window.maximize() { - error!("[托盘] 最大化主窗口失败: {}", e); - } - - // 显示窗口 - if let Err(e) = window.show() { - error!("[托盘] 显示主窗口失败: {}", e); - } - - // 聚焦窗口 - if let Err(e) = window.set_focus() { - error!("[托盘] 聚焦主窗口失败: {}", e); - } - + crate::app::window_chrome::reveal_main_window(&window, "托盘"); info!("[托盘] 主窗口已显示并聚焦"); } else { warn!("[托盘] 未找到主窗口"); diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index d907587a6..ef50f0ea0 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.20.0", + "version": "1.21.0", "identifier": "com.limecloud.lime.headless", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 309e89d97..3da69b9f2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.20.0", + "version": "1.21.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", @@ -84,7 +84,9 @@ }, "deep-link": { "desktop": { - "schemes": ["lime"] + "schemes": [ + "lime" + ] } } } diff --git a/src/App.tsx b/src/App.tsx index 78f758356..aaaf951c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import React, { Suspense, lazy, useState, useCallback } from "react"; import styled from "styled-components"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { withI18nPatch } from "./i18n/withI18nPatch"; import { AppPageContent } from "./components/AppPageContent"; import { SplashScreen } from "./components/SplashScreen"; @@ -35,6 +36,11 @@ import { useOemLimeHubProviderSync } from "./hooks/useOemLimeHubProviderSync"; import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; import { SoundProvider } from "./contexts/SoundProvider"; import { ComponentDebugOverlay } from "./components/dev"; +import { + useResourceManagerNavigationIntents, + type ResourceManagerNavigationDestination, + type ResourceManagerNavigationIntent, +} from "./features/resource-manager"; import type { OpenDeepLinkPayload } from "./hooks/useDeepLink"; import { buildClawAgentParams } from "./lib/workspace/navigation"; import { resolveWebsiteOpenNavigation } from "./lib/deepLink/websiteLaunch"; @@ -46,7 +52,7 @@ const AppContainer = styled.div` display: flex; height: 100vh; width: 100vw; - background-color: hsl(var(--background)); + background: var(--lime-app-bg, hsl(var(--background))); overflow: hidden; `; @@ -57,6 +63,19 @@ const MainContent = styled.main<{ $withSidebarGap?: boolean }>` flex-direction: column; min-height: 0; padding-left: ${(props) => (props.$withSidebarGap ? "10px" : "0")}; + background: var(--lime-app-bg, hsl(var(--background))); +`; + +const WindowDragRegion = styled.div` + position: fixed; + top: 0; + left: 160px; + right: 0; + height: 28px; + z-index: 1000; + background: transparent; + user-select: none; + -webkit-app-region: drag; `; const RecentImageInsertFloating = lazy(() => @@ -121,6 +140,29 @@ function AppContent() { useServiceSkillCatalogBootstrap(); useSiteAdapterCatalogBootstrap(); useOemLimeHubProviderSync(); + const handleResourceManagerNavigationHandled = useCallback( + ({ + destination, + }: { + intent: ResourceManagerNavigationIntent; + destination: ResourceManagerNavigationDestination; + }) => { + toast.success(destination.noticeTitle, { + description: destination.noticeDescription, + }); + }, + [], + ); + const handleResourceManagerNavigationUnsupported = useCallback(() => { + toast.info("当前资源暂时不能自动回跳", { + description: "已记录来源信息,但主窗口还没有对应的业务入口。", + }); + }, []); + useResourceManagerNavigationIntents({ + onNavigate: handleNavigate, + onHandled: handleResourceManagerNavigationHandled, + onUnsupported: handleResourceManagerNavigationUnsupported, + }); useGlobalTrayModelSync({ currentPage, pageParams, @@ -208,7 +250,8 @@ function AppContent() { if (!resolved) { toast.error("无法打开这个官网入口", { - description: "当前 slug 没有对应到桌面端可用能力,请同步官网与客户端目录。", + description: + "当前 slug 没有对应到桌面端可用能力,请同步官网与客户端目录。", }); return; } @@ -253,6 +296,24 @@ function AppContent() { completeOnboarding(); }, [completeOnboarding]); + const handleWindowDragStart = useCallback( + async (event: React.MouseEvent) => { + if (!hasTauriDesktopRuntime || event.button !== 0) { + return; + } + + event.preventDefault(); + + try { + const currentWindow = getCurrentWindow(); + await currentWindow.startDragging(); + } catch (error) { + console.warn("[窗口] 主窗口拖拽启动失败:", error); + } + }, + [hasTauriDesktopRuntime], + ); + if (showSplash) { return ; } @@ -273,6 +334,12 @@ function AppContent() { + {hasTauriDesktopRuntime ? ( + + ) : null} {shouldShowAppSidebar && ( + + + + ); + } + // 默认渲染主应用 return ( diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index cc1cb7e9b..943b9987d 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -18,6 +18,7 @@ import type { OpenClawPageParams, Page, PageParams, + ResourcesPageParams, SceneAppsPageParams, SettingsPageParams, SkillsPageParams, @@ -262,7 +263,10 @@ export function AppPageContent({ if (activePage === "resources") { const content = (
- +
); diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 95aefeb1f..a13349ca7 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -5,31 +5,62 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentPageParams, Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; import { AppSidebar } from "./AppSidebar"; +import { LIME_COLOR_SCHEME_STORAGE_KEY } from "@/lib/appearance/colorSchemes"; +import { LIME_THEME_STORAGE_KEY } from "@/lib/appearance/themeMode"; +import { + getStoredOemCloudSessionState, + setOemCloudBootstrapSnapshot, + setStoredOemCloudSessionState, +} from "@/lib/oemCloudSession"; const { mockGetConfig, + mockSaveConfig, mockGetPluginsForSurface, mockSubscribeAppConfigChanged, mockListAgentRuntimeSessions, mockUpdateAgentRuntimeSession, + mockSetI18nLanguage, mockScheduleMinimumDelayIdleTask, + mockLogoutClient, + mockGetClientReferralDashboard, + mockClearSiteAdapterCatalogCache, + mockToastSuccess, + mockToastError, + mockToastInfo, } = vi.hoisted(() => ({ mockGetConfig: vi.fn(), + mockSaveConfig: vi.fn(), mockGetPluginsForSurface: vi.fn(), mockSubscribeAppConfigChanged: vi.fn(), mockListAgentRuntimeSessions: vi.fn(), mockUpdateAgentRuntimeSession: vi.fn(), + mockSetI18nLanguage: vi.fn(), mockScheduleMinimumDelayIdleTask: vi.fn((task: () => void) => { task(); return () => undefined; }), + mockLogoutClient: vi.fn(), + mockGetClientReferralDashboard: vi.fn(), + mockClearSiteAdapterCatalogCache: vi.fn(), + mockToastSuccess: vi.fn(), + mockToastError: vi.fn(), + mockToastInfo: vi.fn(), })); vi.mock("@/lib/api/appConfig", () => ({ getConfig: mockGetConfig, + saveConfig: mockSaveConfig, subscribeAppConfigChanged: mockSubscribeAppConfigChanged, })); +vi.mock("@/i18n/I18nPatchProvider", () => ({ + useI18nPatch: () => ({ + language: "zh", + setLanguage: mockSetI18nLanguage, + }), +})); + vi.mock("@/lib/api/pluginUI", () => ({ getPluginsForSurface: mockGetPluginsForSurface, })); @@ -39,6 +70,23 @@ vi.mock("@/lib/api/agentRuntime", () => ({ updateAgentRuntimeSession: mockUpdateAgentRuntimeSession, })); +vi.mock("@/lib/api/oemCloudControlPlane", () => ({ + logoutClient: mockLogoutClient, + getClientReferralDashboard: mockGetClientReferralDashboard, +})); + +vi.mock("sonner", () => ({ + toast: { + success: mockToastSuccess, + error: mockToastError, + info: mockToastInfo, + }, +})); + +vi.mock("@/lib/siteAdapterCatalogBootstrap", () => ({ + clearSiteAdapterCatalogCache: mockClearSiteAdapterCatalogCache, +})); + vi.mock("@/lib/utils/scheduleMinimumDelayIdleTask", () => ({ scheduleMinimumDelayIdleTask: mockScheduleMinimumDelayIdleTask, })); @@ -92,14 +140,88 @@ async function flushEffects(times = 1) { } } +async function openAccountMenu(container: HTMLElement) { + await act(async () => { + container + .querySelector( + '[data-testid="app-sidebar-account-button"]', + ) + ?.click(); + await Promise.resolve(); + }); +} + +async function clickAccountMenuItem(container: HTMLElement, label: string) { + await openAccountMenu(container); + + await act(async () => { + container + .querySelector(`button[aria-label="${label}"]`) + ?.click(); + await Promise.resolve(); + }); +} + describe("AppSidebar", () => { beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); localStorage.clear(); + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + delete window.__LIME_SESSION_TOKEN__; + document.documentElement.classList.remove("dark"); + document.documentElement.removeAttribute("data-lime-theme"); + document.documentElement.removeAttribute("data-lime-color-scheme"); + document.documentElement.removeAttribute("style"); mockGetConfig.mockResolvedValue({}); + mockSaveConfig.mockResolvedValue(undefined); mockGetPluginsForSurface.mockResolvedValue([]); mockListAgentRuntimeSessions.mockResolvedValue([]); mockUpdateAgentRuntimeSession.mockResolvedValue(undefined); + mockLogoutClient.mockResolvedValue(undefined); + mockGetClientReferralDashboard.mockResolvedValue({ + code: { + id: "refcode-001", + tenantId: "tenant-0001", + userId: "user-001", + code: "LIME-2026", + landingUrl: "https://limeai.run/invite?code=LIME-2026", + status: "active", + createdAt: "2026-04-28T00:00:00.000Z", + updatedAt: "2026-04-28T00:00:00.000Z", + }, + policy: { + enabled: true, + rewardCredits: 600, + referrerRewardCredits: 480, + inviteeRewardCredits: 120, + claimWindowDays: 30, + autoClaimEnabled: true, + allowManualClaimFallback: true, + riskReviewEnabled: false, + }, + summary: { + totalInvites: 0, + successfulInvites: 0, + totalRewardCredits: 0, + referrerRewardCreditsTotal: 0, + inviteeRewardCreditsTotal: 0, + }, + events: [], + rewards: [], + invitedBy: {}, + share: { + brandName: "Lime", + code: "LIME-2026", + landingUrl: "https://limeai.run/invite?code=LIME-2026", + downloadUrl: "https://limeai.run", + shareText: + "邀请你体验Lime,让AI做牛做马,我们来做牛人!前往 https://limeai.run 下载客户端,复制邀请码 LIME-2026 激活并注册账号参与内测", + headline: "登录后自动领取奖励", + rules: "复制邀请码后完成注册即可参与内测。", + }, + }); + mockClearSiteAdapterCatalogCache.mockResolvedValue(null); mockScheduleMinimumDelayIdleTask.mockImplementation((task: () => void) => { task(); return () => undefined; @@ -133,6 +255,13 @@ describe("AppSidebar", () => { vi.clearAllMocks(); vi.unstubAllGlobals(); + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + delete window.__LIME_SESSION_TOKEN__; + document.documentElement.classList.remove("dark"); + document.documentElement.removeAttribute("data-lime-theme"); + document.documentElement.removeAttribute("data-lime-color-scheme"); + document.documentElement.removeAttribute("style"); ( globalThis as typeof globalThis & { __appConfigListener?: () => void; @@ -176,7 +305,7 @@ describe("AppSidebar", () => { ); }); - it("默认应渲染一级主导航和底部系统入口", async () => { + it("默认应渲染一级主导航,并将系统入口收进用户弹框", async () => { const container = mountSidebarContainer({ currentPageParams: { agentEntry: "new-task", @@ -190,9 +319,9 @@ describe("AppSidebar", () => { expect(container.textContent).not.toContain("生成"); expect(container.textContent).toContain("我的方法"); expect(container.textContent).toContain("灵感库"); - expect(container.textContent).toContain("设置"); - expect(container.textContent).toContain("持续流程"); - expect(container.textContent).toContain("消息渠道"); + expect(container.textContent).not.toContain("设置"); + expect(container.textContent).not.toContain("持续流程"); + expect(container.textContent).not.toContain("消息渠道"); expect(container.textContent).not.toContain("插件中心"); expect(container.textContent).not.toContain("OpenClaw"); expect(container.textContent).not.toContain("桌宠"); @@ -205,23 +334,433 @@ describe("AppSidebar", () => { const mainNavButtons = Array.from( container.querySelectorAll('[data-testid="app-sidebar-main-nav"] button'), ).map((button) => button.getAttribute("aria-label")); - const footerNavButtons = Array.from( - container.querySelectorAll( - '[data-testid="app-sidebar-footer-nav"] button', - ), - ).map((button) => button.getAttribute("aria-label")); const footerArea = container.querySelector( '[data-testid="app-sidebar-footer-area"]', ); - expect(mainNavButtons).toEqual([ - "新建任务", - "我的方法", - "灵感库", - ]); - expect(footerNavButtons).toEqual(["设置", "持续流程", "消息渠道"]); + expect(mainNavButtons).toEqual(["新建任务", "我的方法", "灵感库"]); + expect( + container.querySelector('[data-testid="app-sidebar-footer-nav"]'), + ).toBeNull(); expect(footerArea).not.toBeNull(); expect(getComputedStyle(footerArea as Element).paddingBottom).toBe("16px"); + + await act(async () => { + container + .querySelector( + '[data-testid="app-sidebar-account-button"]', + ) + ?.click(); + await Promise.resolve(); + }); + + const accountMenu = container.querySelector( + '[data-testid="app-sidebar-account-menu"]', + ); + expect(accountMenu?.textContent).toContain("设置"); + expect(accountMenu?.textContent).toContain("持续流程"); + expect(accountMenu?.textContent).toContain("消息渠道"); + }); + + it("Lime 首页入口应保持在左侧栏顶部,并在 macOS 预留系统按钮安全区", async () => { + vi.stubGlobal("navigator", { + platform: "MacIntel", + userAgent: "Mac OS X", + }); + + const onNavigate = vi.fn(); + const container = mountSidebarContainer({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + onNavigate, + }); + await flushEffects(2); + + const sidebar = container.querySelector('[data-testid="app-sidebar"]'); + const header = container.querySelector( + '[data-testid="app-sidebar-header"]', + ); + const homeButton = container.querySelector( + 'button[aria-label="返回 Lime 首页"]', + ); + const mainNav = container.querySelector( + '[data-testid="app-sidebar-main-nav"]', + ); + + expect(sidebar?.getAttribute("data-window-controls-reserved")).toBe("true"); + expect(header).not.toBeNull(); + expect(homeButton).not.toBeNull(); + expect(header?.contains(homeButton)).toBe(true); + expect( + Boolean( + header && + mainNav && + (header.compareDocumentPosition(mainNav) & + Node.DOCUMENT_POSITION_FOLLOWING) !== + 0, + ), + ).toBe(true); + + await act(async () => { + homeButton?.click(); + await Promise.resolve(); + }); + + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate.mock.calls[0]?.[0]).toBe("agent"); + }); + + it("首页侧边栏底部应展示紧凑用户弹框与 OEM 云端入口", async () => { + const onNavigate = vi.fn(); + setStoredOemCloudSessionState({ + token: "session-token", + tenant: { id: "tenant-0001" }, + user: { + id: "user-001", + displayName: "zhong feng shan", + email: "user@example.com", + }, + session: { id: "session-001" }, + }); + + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + onNavigate, + }); + await flushEffects(2); + + const accountButton = container.querySelector( + '[data-testid="app-sidebar-account-button"]', + ); + expect(accountButton).not.toBeNull(); + expect(container.textContent).toContain("zhong feng shan"); + expect(container.textContent).toContain("OEM"); + + await act(async () => { + accountButton?.click(); + await Promise.resolve(); + }); + + const accountMenu = container.querySelector( + '[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("OEM 已连接"); + 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("模型设置"); + expect(accountMenu?.textContent).toContain("关于"); + expect(accountMenu?.textContent).toContain("退出登录"); + expect(accountMenu?.textContent).not.toContain("连接 OEM 云端"); + expect(accountMenu?.textContent).not.toContain("主题"); + expect(accountMenu?.textContent).not.toContain("帮助中心"); + + const settingsButton = Array.from( + accountMenu?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent?.includes("设置")); + + await act(async () => { + settingsButton?.click(); + await Promise.resolve(); + }); + + expect(onNavigate).toHaveBeenCalledWith("settings", { + tab: SettingsTabs.Home, + }); + }); + + it("云端开启邀请时应在头部展示入口并读取 share 事实源", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + 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: true, + }, + }); + + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + const header = container.querySelector( + '[data-testid="app-sidebar-header"]', + ); + const inviteButton = container.querySelector( + '[data-testid="app-sidebar-invite-button"]', + ); + expect(inviteButton).not.toBeNull(); + expect(header?.contains(inviteButton)).toBe(true); + + await act(async () => { + inviteButton?.click(); + await Promise.resolve(); + }); + await flushEffects(4); + + expect(mockGetClientReferralDashboard).toHaveBeenCalledWith("tenant-0001"); + const dialog = document.body.querySelector( + '[data-testid="app-sidebar-invite-dialog"]', + ); + expect(dialog).not.toBeNull(); + expect(dialog?.textContent).toContain("LIME-2026"); + expect(dialog?.textContent).toContain("https://limeai.run"); + expect(dialog?.textContent).toContain("480 积分"); + expect(dialog?.textContent).toContain("120 积分"); + + const copyShareButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("复制邀请文案")); + + await act(async () => { + copyShareButton?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith( + "邀请你体验Lime,让AI做牛做马,我们来做牛人!前往 https://limeai.run 下载客户端,复制邀请码 LIME-2026 激活并注册账号参与内测", + ); + expect(mockToastSuccess).toHaveBeenCalledWith("已复制邀请文案"); + }); + + it("未连接 OEM 云端时应保持开源使用口径", async () => { + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + expect(container.textContent).toContain("开源使用"); + expect(container.textContent).toContain("开源"); + expect(container.textContent).not.toContain("升级"); + + await act(async () => { + container + .querySelector( + '[data-testid="app-sidebar-account-button"]', + ) + ?.click(); + await Promise.resolve(); + }); + + const accountMenu = container.querySelector( + '[data-testid="app-sidebar-account-menu"]', + ); + expect(accountMenu?.textContent).toContain("开源版"); + expect(accountMenu?.textContent).toContain("OEM 云端可选"); + expect(accountMenu?.textContent).toContain("模型设置"); + expect(accountMenu?.textContent).toContain("连接 OEM 云端"); + expect(accountMenu?.textContent).not.toContain("退出登录"); + }); + + it("OEM 登录完成后侧边栏应从开源态刷新为账号信息", async () => { + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + expect(container.textContent).toContain("开源使用"); + + await act(async () => { + setStoredOemCloudSessionState({ + token: "session-token", + tenant: { + id: "tenant-0001", + name: "Lime Cloud", + }, + user: { + id: "user-001", + displayName: "晚风", + email: "wanfeng@example.com", + avatarUrl: "https://example.com/avatar.png", + }, + session: { + id: "session-001", + provider: "google", + }, + }); + await Promise.resolve(); + }); + await flushEffects(2); + + expect(container.textContent).toContain("晚风"); + expect(container.textContent).toContain("OEM"); + + await openAccountMenu(container); + const accountMenu = container.querySelector( + '[data-testid="app-sidebar-account-menu"]', + ); + expect(accountMenu?.textContent).toContain("wanfeng@example.com"); + expect(accountMenu?.textContent).toContain("Lime Cloud"); + expect(accountMenu?.textContent).toContain("登录方式:Google"); + }); + + it("用户弹框的语言入口应使用二级弹框并保存真实语言设置", async () => { + mockGetConfig.mockResolvedValue({ language: "zh" }); + + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + await act(async () => { + container + .querySelector( + '[data-testid="app-sidebar-account-button"]', + ) + ?.click(); + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="app-sidebar-language-menu"]'), + ).toBeNull(); + + await act(async () => { + container + .querySelector('button[aria-label="语言"]') + ?.click(); + await Promise.resolve(); + }); + + const languageMenu = container.querySelector( + '[data-testid="app-sidebar-language-menu"]', + ); + expect(languageMenu).not.toBeNull(); + expect(languageMenu?.textContent).toContain("中文"); + expect(languageMenu?.textContent).toContain("English"); + + await act(async () => { + container + .querySelector( + 'button[aria-label="切换语言为English"]', + ) + ?.click(); + await Promise.resolve(); + }); + await flushEffects(2); + + expect(mockSetI18nLanguage).toHaveBeenCalledWith("en"); + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.objectContaining({ language: "en" }), + ); + expect( + container.querySelector('[data-testid="app-sidebar-language-menu"]'), + ).toBeNull(); + }); + + it("用户弹框中的收缩入口应导航到真实页面", async () => { + const onNavigate = vi.fn(); + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + onNavigate, + }); + await flushEffects(2); + + await clickAccountMenuItem(container, "设置"); + expect(onNavigate).toHaveBeenLastCalledWith("settings", { + tab: SettingsTabs.Home, + }); + + await clickAccountMenuItem(container, "持续流程"); + expect(onNavigate).toHaveBeenLastCalledWith("automation", undefined); + + await clickAccountMenuItem(container, "消息渠道"); + expect(onNavigate).toHaveBeenLastCalledWith("channels", undefined); + + await clickAccountMenuItem(container, "模型设置"); + expect(onNavigate).toHaveBeenLastCalledWith("settings", { + tab: SettingsTabs.Providers, + providerView: "settings", + }); + + await clickAccountMenuItem(container, "OEM 云端"); + expect(onNavigate).toHaveBeenLastCalledWith("settings", { + tab: SettingsTabs.Providers, + providerView: "cloud", + }); + + await clickAccountMenuItem(container, "关于"); + expect(onNavigate).toHaveBeenLastCalledWith("settings", { + tab: SettingsTabs.About, + }); + }); + + it("用户弹框退出登录应清理个人中心会话", async () => { + setStoredOemCloudSessionState({ + token: "session-token", + tenant: { id: "tenant-0001" }, + user: { + id: "user-001", + displayName: "zhong feng shan", + }, + session: { id: "session-001" }, + }); + + const container = mountSidebarContainer(); + await flushEffects(2); + + await act(async () => { + container + .querySelector( + '[data-testid="app-sidebar-account-button"]', + ) + ?.click(); + await Promise.resolve(); + }); + + const logoutButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("退出登录"), + ); + + await act(async () => { + logoutButton?.click(); + await Promise.resolve(); + }); + await flushEffects(2); + + expect(mockLogoutClient).toHaveBeenCalledWith("tenant-0001"); + expect(getStoredOemCloudSessionState()).toBeNull(); + expect( + container.querySelector('[data-testid="app-sidebar-account-menu"]'), + ).toBeNull(); }); it("新建任务页应高亮新建任务入口", async () => { @@ -265,9 +804,7 @@ describe("AppSidebar", () => { }); await flushEffects(2); - expect( - container.querySelector('button[aria-label="工作台"]'), - ).toBeNull(); + expect(container.querySelector('button[aria-label="工作台"]')).toBeNull(); expect(onNavigate).not.toHaveBeenCalled(); }); @@ -284,6 +821,7 @@ describe("AppSidebar", () => { }); it("一级导航下方应继续展示最近对话与归档,并对归档列表懒加载", async () => { + localStorage.setItem("agent_last_project_id", JSON.stringify("project-1")); mockListAgentRuntimeSessions.mockImplementation( async (options?: { archivedOnly?: boolean; @@ -326,7 +864,7 @@ describe("AppSidebar", () => { expect(mockListAgentRuntimeSessions).toHaveBeenCalledTimes(1); expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({ limit: 37, - workspaceId: undefined, + workspaceId: "project-1", }); const mainNav = container.querySelector( @@ -375,11 +913,25 @@ describe("AppSidebar", () => { ).toBe("auto"); expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({ archivedOnly: true, - limit: 25, - workspaceId: undefined, + limit: 17, + workspaceId: "project-1", }); }); + it("没有当前工作区时不应加载全局最近对话", async () => { + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + expect(container.textContent).toContain("最近对话"); + expect(container.textContent).toContain("还没有开始对话"); + expect(mockListAgentRuntimeSessions).not.toHaveBeenCalled(); + }); + it("最近对话应限制初始渲染数量,并保留当前会话可见", async () => { mockListAgentRuntimeSessions.mockResolvedValue( Array.from({ length: 25 }, (_, index) => { @@ -426,7 +978,7 @@ describe("AppSidebar", () => { }); await flushEffects(2); - expect(container.querySelector('button[title="会话 24"]')).not.toBeNull(); + expect(container.querySelector('button[title="会话 19"]')).not.toBeNull(); }); it("切换人物或项目上下文时不应把已有最近对话重置成加载态", async () => { @@ -478,7 +1030,7 @@ describe("AppSidebar", () => { expect(mounted.container.textContent).toContain("最近会话"); expect(mounted.container.textContent).not.toContain("正在加载对话"); expect(mockListAgentRuntimeSessions).toHaveBeenCalledTimes(2); - expect(mockListAgentRuntimeSessions).toHaveBeenLastCalledWith({ + expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({ limit: 37, workspaceId: "project-2", }); @@ -502,6 +1054,7 @@ describe("AppSidebar", () => { currentPage: "agent", currentPageParams: { agentEntry: "new-task", + projectId: "project-1", } as AgentPageParams, }); await flushEffects(2); @@ -601,11 +1154,28 @@ describe("AppSidebar", () => { }); await flushEffects(2); - expect(container.textContent).toContain("持续流程"); - expect(container.textContent).toContain("消息渠道"); - expect(container.textContent).toContain("插件中心"); - expect(container.textContent).toContain("OpenClaw"); - expect(container.textContent).toContain("桌宠"); + expect(container.textContent).not.toContain("插件中心"); + expect(container.textContent).not.toContain("OpenClaw"); + expect(container.textContent).not.toContain("桌宠"); + + await act(async () => { + container + .querySelector( + '[data-testid="app-sidebar-account-button"]', + ) + ?.click(); + await Promise.resolve(); + }); + + 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("OpenClaw"); + expect(accountMenu?.textContent).toContain("桌宠"); }); it("配置变更后应重新读取可选入口并刷新侧栏", async () => { @@ -628,11 +1198,27 @@ describe("AppSidebar", () => { }); await flushEffects(2); - expect(container.textContent).toContain("持续流程"); - expect(container.textContent).toContain("消息渠道"); expect(container.textContent).not.toContain("插件中心"); expect(container.textContent).not.toContain("桌宠"); + await act(async () => { + container + .querySelector( + '[data-testid="app-sidebar-account-button"]', + ) + ?.click(); + await Promise.resolve(); + }); + + let accountMenu = container.querySelector( + '[data-testid="app-sidebar-account-menu"]', + ); + expect(accountMenu?.textContent).toContain("设置"); + expect(accountMenu?.textContent).toContain("持续流程"); + expect(accountMenu?.textContent).toContain("消息渠道"); + expect(accountMenu?.textContent).not.toContain("插件中心"); + expect(accountMenu?.textContent).not.toContain("桌宠"); + await act(async () => { ( globalThis as typeof globalThis & { @@ -643,8 +1229,11 @@ describe("AppSidebar", () => { }); await flushEffects(2); - expect(container.textContent).toContain("插件中心"); - expect(container.textContent).toContain("桌宠"); + accountMenu = container.querySelector( + '[data-testid="app-sidebar-account-menu"]', + ); + expect(accountMenu?.textContent).toContain("插件中心"); + expect(accountMenu?.textContent).toContain("桌宠"); }); it("sceneapps 页面不应再在侧栏展示独立主入口", async () => { @@ -701,6 +1290,61 @@ describe("AppSidebar", () => { expect(container.textContent).not.toContain("桌宠"); }); + it("底部外观入口应弹出轻量快捷面板并同步主题与配色", async () => { + const container = mountSidebarContainer({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + const trigger = container.querySelector( + 'button[aria-label="快速切换外观"]', + ); + + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.click(); + await Promise.resolve(); + }); + + const popover = container.querySelector( + '[data-testid="app-sidebar-appearance-popover"]', + ); + expect(popover).not.toBeNull(); + expect(popover?.textContent).toContain("浅色"); + expect(popover?.textContent).toContain("深色"); + expect(popover?.textContent).toContain("跟随系统"); + expect(popover?.textContent).toContain("Lime 经典"); + expect(popover?.textContent).toContain("森林"); + expect(popover?.textContent).toContain("海雾"); + expect(popover?.textContent).toContain("砂岩"); + + await act(async () => { + container + .querySelector('button[aria-label="切换配色为海雾"]') + ?.click(); + await Promise.resolve(); + }); + + expect(localStorage.getItem(LIME_COLOR_SCHEME_STORAGE_KEY)).toBe( + "lime-ocean", + ); + expect(document.documentElement.dataset.limeColorScheme).toBe("lime-ocean"); + + await act(async () => { + container + .querySelector('button[aria-label="切换主题为深色"]') + ?.click(); + await Promise.resolve(); + }); + + expect(localStorage.getItem(LIME_THEME_STORAGE_KEY)).toBe("dark"); + expect(document.documentElement.dataset.limeTheme).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + it("桌宠入口开启后,进入 companion 视图应高亮桌宠", async () => { mockGetConfig.mockResolvedValue({ navigation: { @@ -717,6 +1361,15 @@ describe("AppSidebar", () => { }); await flushEffects(2); + await act(async () => { + container + .querySelector( + '[data-testid="app-sidebar-account-button"]', + ) + ?.click(); + await Promise.resolve(); + }); + expect( container.querySelector('button[aria-label="桌宠"][aria-current="page"]'), ).not.toBeNull(); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 71cb3e8d0..aba57e895 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,9 +1,8 @@ /** * 全局应用侧边栏 * - * 当前导航收口为一级主入口 + 底部系统入口。 - * 默认只暴露主线入口;底部固定保留设置、持续流程、消息渠道, - * 可选项只剩插件中心、OpenClaw、桌宠。 + * 当前导航收口为一级主入口 + 底部用户菜单。 + * 默认只暴露主线入口;系统入口统一收进左下角用户弹窗。 */ import { @@ -17,22 +16,38 @@ import { import styled from "styled-components"; import { ChevronDown, - Clock3, - MessageSquarePlus, - Archive, + ChevronRight, + Check, + Cloud, + Copy, + ExternalLink, + Gift, + Info, + KeyRound, + Languages, + LogIn, + LogOut, + Monitor, Moon, + Palette, + RefreshCw, Sun, Search, PanelLeftClose, PanelLeftOpen, - Undo2, Activity, LucideIcon, } from "lucide-react"; +import { toast } from "sonner"; import * as LucideIcons from "lucide-react"; import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI"; import { AgentPageParams, Page, PageParams } from "@/types/page"; -import { getConfig, subscribeAppConfigChanged } from "@/lib/api/appConfig"; +import { SettingsTabs } from "@/types/settings"; +import { + getConfig, + saveConfig, + subscribeAppConfigChanged, +} from "@/lib/api/appConfig"; import { buildClawAgentParams, buildHomeAgentParams, @@ -56,9 +71,60 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { Modal } from "@/components/Modal"; import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; import { LIME_BRAND_LOGO_SRC, LIME_BRAND_NAME } from "@/lib/branding"; import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; +import { AppSidebarConversationShelf } from "@/components/app-sidebar/AppSidebarConversationShelf"; +import { shouldReserveMacWindowControls } from "@/lib/windowControls"; +import { + clearStoredOemCloudSessionState, + clearOemCloudBootstrapSnapshot, + getOemCloudBootstrapSnapshot, + getStoredOemCloudSessionState, + subscribeOemCloudBootstrapChanged, + subscribeOemCloudSessionChanged, + type OemCloudStoredSessionState, +} from "@/lib/oemCloudSession"; +import { clearSkillCatalogCache } from "@/lib/api/skillCatalog"; +import { clearServiceSkillCatalogCache } from "@/lib/api/serviceSkills"; +import { + getClientReferralDashboard, + logoutClient, + type OemCloudBootstrapResponse, + type OemCloudReferralDashboard, +} from "@/lib/api/oemCloudControlPlane"; +import { clearSiteAdapterCatalogCache } from "@/lib/siteAdapterCatalogBootstrap"; +import { + LAST_PROJECT_ID_KEY, + loadPersistedProjectId, + PERSISTED_PROJECT_ID_CHANGED_EVENT, +} from "@/components/agent/chat/hooks/agentProjectStorage"; +import { + LIME_COLOR_SCHEME_CHANGED_EVENT, + LIME_COLOR_SCHEMES, + LIME_COLOR_SCHEME_STORAGE_KEY, + applyLimeColorScheme, + getLimeColorScheme, + loadLimeColorSchemeId, + persistLimeColorScheme, + type LimeColorSchemeChangedEventDetail, + type LimeColorSchemeId, +} from "@/lib/appearance/colorSchemes"; +import { + LIME_THEME_CHANGED_EVENT, + LIME_THEME_MODE_OPTIONS, + LIME_THEME_STORAGE_KEY, + applyLimeThemeMode, + getEffectiveLimeThemeMode, + loadLimeThemeMode, + persistLimeThemeMode, + type LimeEffectiveThemeMode, + type LimeThemeChangedEventDetail, + type LimeThemeMode, +} from "@/lib/appearance/themeMode"; +import { useI18nPatch } from "@/i18n/I18nPatchProvider"; +import type { Language } from "@/i18n/text-map"; interface AppSidebarProps { currentPage: Page; @@ -74,10 +140,28 @@ const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; const SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID = "plugins"; const SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS = 1200; const SIDEBAR_PLUGIN_BROWSER_IDLE_TIMEOUT_MS = 6000; -const SIDEBAR_RECENT_SESSION_PAGE_SIZE = 18; -const SIDEBAR_ARCHIVED_SESSION_PAGE_SIZE = 12; +const SIDEBAR_RECENT_SESSION_PAGE_SIZE = 10; +const SIDEBAR_RECENT_SESSION_PREFETCH_LIMIT = 37; +const SIDEBAR_ARCHIVED_SESSION_PAGE_SIZE = 8; const SIDEBAR_SESSION_ENTRY_REFRESH_DEFER_MS = 12_000; +const APP_SIDEBAR_LANGUAGE_OPTIONS: Array<{ + id: Language; + label: string; + hint: string; +}> = [ + { + id: "zh", + label: "中文", + hint: "中文界面", + }, + { + id: "en", + label: "English", + hint: "English UI", + }, +]; + function buildSidebarSessionRequestLimit( visibleCount: number, pageSize: number, @@ -175,63 +259,87 @@ function isSameSidebarNavigationTarget( const Container = styled.aside<{ $collapsed?: boolean; $themeMode: "light" | "dark"; + $reserveWindowControls?: boolean; }>` + --sidebar-window-control-safe-top: ${({ $reserveWindowControls }) => + $reserveWindowControls ? "34px" : "0px"}; --sidebar-surface-top: ${({ $themeMode }) => - $themeMode === "dark" ? "#15202b" : "#f6fbf4"}; + $themeMode === "dark" + ? "#15202b" + : "var(--lime-sidebar-surface-top, #f6fbf4)"}; --sidebar-surface-middle: ${({ $themeMode }) => - $themeMode === "dark" ? "#17232d" : "#f9fcf6"}; + $themeMode === "dark" + ? "#17232d" + : "var(--lime-sidebar-surface-middle, #f9fcf6)"}; --sidebar-surface-bottom: ${({ $themeMode }) => - $themeMode === "dark" ? "#1a2530" : "#fbfff5"}; + $themeMode === "dark" + ? "#1a2530" + : "var(--lime-sidebar-surface-bottom, #fbfff5)"}; + --sidebar-surface: ${({ $themeMode }) => + $themeMode === "dark" + ? "linear-gradient(180deg, #15202b 0%, #17232d 48%, #1a2530 100%)" + : "var(--lime-sidebar-surface, linear-gradient(180deg, var(--sidebar-surface-top) 0%, var(--sidebar-surface-middle) 46%, var(--sidebar-surface-bottom) 100%))"}; --sidebar-foreground: ${({ $themeMode }) => - $themeMode === "dark" ? "#eef4f7" : "#1a3b2b"}; + $themeMode === "dark" ? "#eef4f7" : "var(--lime-text, #1a3b2b)"}; --sidebar-muted: ${({ $themeMode }) => - $themeMode === "dark" ? "#a1afbd" : "#6b826b"}; + $themeMode === "dark" ? "#a1afbd" : "var(--lime-text-muted, #6b826b)"}; --sidebar-border: ${({ $themeMode }) => - $themeMode === "dark" ? "#2d3a46" : "#e2f0e2"}; + $themeMode === "dark" ? "#2d3a46" : "var(--lime-sidebar-border, #e2f0e2)"}; + --sidebar-card-border: ${({ $themeMode }) => + $themeMode === "dark" + ? "rgba(55, 68, 81, 0.76)" + : "var(--lime-sidebar-card-border, var(--lime-sidebar-border, #e2f0e2))"}; --sidebar-divider: ${({ $themeMode }) => $themeMode === "dark" ? "rgba(148, 163, 184, 0.14)" - : "rgba(132, 204, 22, 0.15)"}; + : "var(--lime-sidebar-divider, rgba(132, 204, 22, 0.15))"}; --sidebar-hover: ${({ $themeMode }) => - $themeMode === "dark" ? "#22303c" : "#eef7ee"}; + $themeMode === "dark" ? "#22303c" : "var(--lime-sidebar-hover, #eef7ee)"}; --sidebar-active: ${({ $themeMode }) => - $themeMode === "dark" ? "#2a3e3b" : "#e6f8ea"}; + $themeMode === "dark" ? "#2a3e3b" : "var(--lime-sidebar-active, #e6f8ea)"}; --sidebar-active-foreground: ${({ $themeMode }) => - $themeMode === "dark" ? "#dff4ea" : "#166534"}; + $themeMode === "dark" + ? "#dff4ea" + : "var(--lime-sidebar-active-text, #166534)"}; --sidebar-search-bg: ${({ $themeMode }) => - $themeMode === "dark" ? "#1f2b36" : "#fcfff9"}; + $themeMode === "dark" + ? "#1f2b36" + : "var(--lime-sidebar-search-bg, #fcfff9)"}; --sidebar-search-hover: ${({ $themeMode }) => - $themeMode === "dark" ? "#24313d" : "#f4fdf4"}; + $themeMode === "dark" + ? "#24313d" + : "var(--lime-sidebar-search-hover, #f4fdf4)"}; --sidebar-search-border-hover: ${({ $themeMode }) => - $themeMode === "dark" ? "#3a4a57" : "#bbf7d0"}; + $themeMode === "dark" + ? "#3a4a57" + : "var(--lime-sidebar-search-border-hover, #bbf7d0)"}; --sidebar-card-surface: ${({ $themeMode }) => $themeMode === "dark" ? "linear-gradient(180deg, rgba(24, 34, 44, 0.96) 0%, rgba(19, 29, 38, 0.98) 100%)" - : "linear-gradient(180deg, rgba(255, 255, 255, 0.76) 0%, rgba(249, 252, 246, 0.94) 100%)"}; + : "var(--lime-sidebar-card-surface, linear-gradient(180deg, rgba(255, 255, 255, 0.76) 0%, rgba(249, 252, 246, 0.94) 100%))"}; --sidebar-card-shadow: ${({ $themeMode }) => $themeMode === "dark" ? "0 18px 34px -28px rgba(2, 8, 23, 0.7)" - : "0 16px 30px -28px rgba(15, 23, 42, 0.4)"}; + : "var(--lime-sidebar-card-shadow, 0 14px 28px -26px rgba(15, 23, 42, 0.32))"}; --sidebar-card-highlight: ${({ $themeMode }) => $themeMode === "dark" ? "rgba(255, 255, 255, 0.08)" - : "rgba(255, 255, 255, 0.72)"}; + : "var(--lime-sidebar-card-highlight, rgba(255, 255, 255, 0.72))"}; display: flex; flex-direction: column; width: ${({ $collapsed }) => ($collapsed ? "72px" : "272px")}; min-width: ${({ $collapsed }) => ($collapsed ? "72px" : "272px")}; height: 100vh; - padding: ${({ $collapsed }) => ($collapsed ? "12px 6px" : "14px 14px 12px")}; + padding: ${({ $collapsed }) => + $collapsed + ? "calc(12px + var(--sidebar-window-control-safe-top)) 6px 12px" + : "calc(14px + var(--sidebar-window-control-safe-top)) 14px 12px"}; position: relative; isolation: isolate; - background: linear-gradient( - 180deg, - var(--sidebar-surface-top) 0%, - var(--sidebar-surface-middle) 38%, - var(--sidebar-surface-bottom) 100% - ); + z-index: 30; + background: var(--sidebar-surface); border-right: 1px solid var(--sidebar-border); - box-shadow: 16px 0 36px -34px rgba(15, 23, 42, 0.42); + box-shadow: 10px 0 26px -28px rgba(15, 23, 42, 0.38); transition: width 180ms ease, min-width 180ms ease, @@ -244,19 +352,20 @@ const Container = styled.aside<{ background: radial-gradient( circle at top left, - rgba(132, 204, 22, 0.14) 0%, - rgba(132, 204, 22, 0) 46% + var(--lime-sidebar-glow-primary, rgba(132, 204, 22, 0.14)) 0%, + transparent 54% ), radial-gradient( circle at 18% 18%, - rgba(16, 185, 129, 0.12) 0%, - rgba(16, 185, 129, 0) 30% + var(--lime-sidebar-glow-secondary, rgba(16, 185, 129, 0.12)) 0%, + transparent 42% ), radial-gradient( circle at bottom left, - rgba(186, 230, 253, 0.12) 0%, - rgba(186, 230, 253, 0) 34% + var(--lime-sidebar-glow-tertiary, rgba(186, 230, 253, 0.12)) 0%, + transparent 46% ); + opacity: 0.82; pointer-events: none; z-index: 0; } @@ -290,7 +399,9 @@ const UserButton = styled.button<{ $collapsed?: boolean }>` display: flex; align-items: center; gap: 10px; - width: 100%; + width: ${({ $collapsed }) => ($collapsed ? "38px" : "100%")}; + min-width: 0; + flex: ${({ $collapsed }) => ($collapsed ? "0 0 auto" : "1 1 auto")}; border: none; background: transparent; border-radius: 14px; @@ -341,7 +452,7 @@ const SearchButton = styled.button<{ $collapsed?: boolean }>` width: 100%; height: 44px; border-radius: 16px; - border: 1px solid var(--sidebar-border); + border: 1px solid var(--sidebar-card-border); background: var(--sidebar-search-bg); color: var(--sidebar-muted); padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 14px")}; @@ -353,7 +464,7 @@ const SearchButton = styled.button<{ $collapsed?: boolean }>` background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.56); &:hover { border-color: var(--sidebar-search-border-hover); @@ -421,10 +532,9 @@ const NavButton = styled.button<{ $active?: boolean; $collapsed?: boolean }>` border: none; border-radius: 14px; padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 12px")}; + position: relative; background: ${({ $active }) => - $active - ? "linear-gradient(180deg, rgba(230, 248, 234, 0.98), rgba(221, 247, 230, 0.92))" - : "transparent"}; + $active ? "var(--sidebar-active)" : "transparent"}; color: ${({ $active }) => $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-muted)"}; cursor: pointer; @@ -436,7 +546,23 @@ const NavButton = styled.button<{ $active?: boolean; $collapsed?: boolean }>` justify-content: ${({ $collapsed }) => $collapsed ? "center" : "flex-start"}; box-shadow: ${({ $active }) => - $active ? "0 12px 24px -22px rgba(22, 101, 52, 0.34)" : "none"}; + $active ? "inset 0 1px 0 rgba(255, 255, 255, 0.48)" : "none"}; + + &::before { + content: ""; + position: absolute; + left: ${({ $collapsed }) => ($collapsed ? "8px" : "7px")}; + top: 50%; + width: 3px; + height: ${({ $active }) => ($active ? "18px" : "0")}; + border-radius: 999px; + background: var(--sidebar-active-foreground); + opacity: ${({ $active }) => ($active ? 0.72 : 0)}; + transform: translateY(-50%); + transition: + height 0.18s ease, + opacity 0.18s ease; + } &:hover { background: ${({ $active }) => @@ -466,253 +592,6 @@ const NavLabel = styled.span<{ $collapsed?: boolean }>` display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")}; `; -const ConversationShelf = styled.div` - display: flex; - flex-direction: column; - gap: 8px; - margin: 2px 0 12px; -`; - -const ConversationSection = styled.div` - display: flex; - flex-direction: column; - gap: 6px; - min-height: 140px; - max-height: 208px; - padding: 10px; - border-radius: 24px; - border: 1px solid var(--sidebar-border); - background: var(--sidebar-card-surface), var(--sidebar-search-bg); - box-shadow: - inset 0 1px 0 var(--sidebar-card-highlight), - var(--sidebar-card-shadow); - overflow: hidden; -`; - -const ConversationSectionHeader = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 0 4px; - color: var(--sidebar-muted); -`; - -const ConversationSectionTitle = styled.button` - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0; - margin: 0; - border: none; - background: transparent; - color: inherit; - font-size: 13px; - font-weight: 600; - cursor: pointer; - - svg { - width: 14px; - height: 14px; - } -`; - -const ConversationActionButton = styled.button` - width: 28px; - height: 28px; - border: none; - border-radius: 9px; - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - color: var(--sidebar-muted); - cursor: pointer; - transition: - background-color 0.18s ease, - color 0.18s ease; - - &:hover { - background: var(--sidebar-hover); - color: var(--sidebar-foreground); - } - - svg { - width: 16px; - height: 16px; - } -`; - -const ConversationList = styled.div` - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - min-height: 0; - max-height: 132px; - overflow-y: auto; - overflow-x: hidden; - padding-right: 2px; - - &::-webkit-scrollbar { - width: 4px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: var(--sidebar-border); - border-radius: 9999px; - } -`; - -const ConversationListMoreButton = styled.button` - width: 100%; - min-height: 34px; - border: 1px solid var(--sidebar-border); - border-radius: 12px; - background: rgba(255, 255, 255, 0.16); - color: var(--sidebar-muted); - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: - background-color 0.18s ease, - border-color 0.18s ease, - color 0.18s ease; - - &:hover { - background: var(--sidebar-hover); - border-color: var(--sidebar-search-border-hover); - color: var(--sidebar-foreground); - } -`; - -const ConversationItemRow = styled.div<{ - $active?: boolean; -}>` - display: flex; - align-items: center; - gap: 4px; - width: 100%; - border-radius: 12px; - background: ${({ $active }) => - $active - ? "linear-gradient(180deg, rgba(230, 248, 234, 0.98), rgba(221, 247, 230, 0.92))" - : "transparent"}; - transition: - background-color 0.18s ease, - color 0.18s ease; - - &:hover { - background: ${({ $active }) => - $active ? "var(--sidebar-active)" : "var(--sidebar-hover)"}; - } -`; - -const ConversationItemButton = styled.button<{ - $active?: boolean; -}>` - display: flex; - align-items: center; - gap: 10px; - flex: 1; - min-width: 0; - min-height: 38px; - border: none; - border-radius: 12px; - padding: 0 10px; - background: transparent; - color: ${({ $active }) => - $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-foreground)"}; - cursor: pointer; - transition: color 0.18s ease; -`; - -const ConversationItemDot = styled.span<{ $active?: boolean }>` - width: 8px; - height: 8px; - flex-shrink: 0; - border-radius: 999px; - background: ${({ $active }) => - $active ? "var(--sidebar-active-foreground)" : "rgba(148, 163, 184, 0.72)"}; -`; - -const ConversationItemLabel = styled.span` - flex: 1; - min-width: 0; - overflow: hidden; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 13px; - font-weight: 500; -`; - -const ConversationItemMeta = styled.span` - flex-shrink: 0; - font-size: 11px; - color: var(--sidebar-muted); -`; - -const ConversationItemActionButton = styled.button` - width: 30px; - min-width: 30px; - height: 38px; - border: none; - border-radius: 12px; - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - color: var(--sidebar-muted); - cursor: pointer; - opacity: 0; - pointer-events: none; - transition: - opacity 0.18s ease, - background-color 0.18s ease, - color 0.18s ease; - - &:hover { - background: var(--sidebar-hover); - color: var(--sidebar-foreground); - } - - &:disabled { - cursor: wait; - opacity: 0.6; - } - - ${ConversationItemRow}:hover &, - ${ConversationItemRow}[data-active="true"] & { - opacity: 1; - pointer-events: auto; - } - - svg { - width: 15px; - height: 15px; - } -`; - -const ConversationEmptyState = styled.div` - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - flex: 1; - min-height: 0; - border-radius: 16px; - padding: 12px; - color: var(--sidebar-muted); - font-size: 13px; - background: rgba(255, 255, 255, 0.28); - text-align: center; -`; - const FooterArea = styled.div<{ $collapsed?: boolean }>` padding-top: 10px; padding-bottom: 16px; @@ -730,6 +609,12 @@ const ActionRow = styled.div<{ $collapsed?: boolean }>` padding: 0 2px; `; +const AppearanceActionSlot = styled.div<{ $collapsed?: boolean }>` + position: relative; + display: inline-flex; + justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-end")}; +`; + const IconActionButton = styled.button<{ $active?: boolean }>` width: 30px; height: 30px; @@ -762,6 +647,981 @@ const IconActionButton = styled.button<{ $active?: boolean }>` } `; +const HeaderInviteButton = styled.button<{ + $collapsed?: boolean; + $active?: boolean; +}>` + height: 30px; + min-width: ${({ $collapsed }) => ($collapsed ? "30px" : "88px")}; + border: none; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: ${({ $collapsed }) => ($collapsed ? "0" : "6px")}; + padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 9px")}; + background: ${({ $active }) => + $active ? "var(--sidebar-hover)" : "transparent"}; + color: ${({ $active }) => + $active ? "var(--sidebar-foreground)" : "var(--sidebar-muted)"}; + opacity: ${({ $active }) => ($active ? 0.92 : 0.76)}; + cursor: pointer; + flex: 0 0 auto; + transition: + background-color 0.18s ease, + color 0.18s ease, + opacity 0.18s ease; + + &:hover { + background: var(--sidebar-hover); + color: var(--sidebar-foreground); + opacity: 0.92; + } + + svg { + width: 15px; + height: 15px; + flex-shrink: 0; + } + + span { + display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")}; + font-size: 14px; + font-weight: 700; + white-space: nowrap; + } +`; + +const InviteDialogSurface = styled.div` + --invite-surface: var(--lime-surface, #ffffff); + --invite-surface-soft: var(--lime-surface-soft, #f8fcf9); + --invite-surface-muted: var(--lime-surface-muted, #f2f7f3); + --invite-surface-hover: var(--lime-surface-hover, #f4fdf4); + --invite-border: var(--lime-surface-border, #e2f0e2); + --invite-border-strong: var(--lime-surface-border-strong, #c7e7d1); + --invite-text: var(--lime-text, #1a3b2b); + --invite-text-strong: var(--lime-text-strong, #0f172a); + --invite-text-muted: var(--lime-text-muted, #6b826b); + --invite-brand: var(--lime-brand, #10b981); + --invite-brand-strong: var(--lime-brand-strong, #166534); + --invite-brand-soft: var(--lime-brand-soft, #ecfdf5); + background: var(--invite-surface); + color: var(--invite-text); +`; + +const InviteDialogHeader = styled.div` + display: grid; + gap: 8px; + padding: 24px 24px 18px; + border-bottom: 1px solid var(--invite-border); + background: + radial-gradient( + circle at 18% 0%, + color-mix(in srgb, var(--invite-brand) 12%, transparent), + transparent 34% + ), + linear-gradient( + 135deg, + var(--invite-surface-soft) 0%, + var(--invite-surface) 58%, + var(--invite-surface-muted) 100% + ); +`; + +const InviteDialogEyebrow = styled.span` + width: fit-content; + border-radius: 999px; + border: 1px solid var(--invite-border-strong); + background: var(--invite-brand-soft); + color: var(--invite-brand-strong); + padding: 4px 9px; + font-size: 12px; + font-weight: 700; +`; + +const InviteDialogTitle = styled.h2` + margin: 0; + color: var(--invite-text-strong); + font-size: 22px; + line-height: 1.25; + font-weight: 800; +`; + +const InviteDialogDescription = styled.p` + margin: 0; + color: var(--invite-text-muted); + font-size: 13px; + line-height: 1.7; +`; + +const InviteDialogBody = styled.div` + display: grid; + gap: 14px; + padding: 18px 24px 22px; +`; + +const InviteStatusCard = styled.div<{ $tone?: "error" | "muted" }>` + border-radius: 16px; + border: 1px solid + ${({ $tone }) => + $tone === "error" + ? "var(--lime-danger-border, #fecdd3)" + : "var(--invite-border)"}; + background: ${({ $tone }) => + $tone === "error" + ? "var(--lime-danger-soft, #fff1f2)" + : "var(--invite-surface-soft)"}; + color: ${({ $tone }) => + $tone === "error" ? "var(--lime-danger, #be123c)" : "var(--invite-text)"}; + padding: 14px 15px; + font-size: 13px; + line-height: 1.6; +`; + +const InviteShareCard = styled.div` + display: grid; + gap: 14px; + border-radius: 18px; + border: 1px solid var(--lime-card-subtle-border, var(--invite-border)); + background: var(--invite-surface); + padding: 16px; + box-shadow: var( + --lime-sidebar-card-shadow, + 0 18px 36px -32px rgba(15, 23, 42, 0.32) + ); +`; + +const InviteCodeBlock = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-radius: 16px; + border: 1px dashed + color-mix(in srgb, var(--invite-brand) 42%, var(--invite-border)); + background: color-mix( + in srgb, + var(--invite-brand-soft) 64%, + var(--invite-surface) 36% + ); + padding: 14px; +`; + +const InviteCodeMeta = styled.span` + display: grid; + gap: 4px; + min-width: 0; +`; + +const InviteCodeLabel = styled.span` + color: var(--invite-text-muted); + font-size: 12px; + font-weight: 700; +`; + +const InviteCodeValue = styled.strong` + color: var(--invite-text-strong); + font-size: 24px; + letter-spacing: 0.02em; + line-height: 1.1; + word-break: break-all; +`; + +const InviteMetaGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + + @media (max-width: 560px) { + grid-template-columns: 1fr; + } +`; + +const InviteMetaItem = styled.div` + display: grid; + gap: 5px; + border-radius: 14px; + background: var(--invite-surface-soft); + padding: 12px; + min-width: 0; + + span { + color: var(--invite-text-muted); + font-size: 12px; + font-weight: 700; + } + + strong { + color: var(--invite-text-strong); + font-size: 14px; + font-weight: 800; + word-break: break-word; + } +`; + +const InviteActionBar = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px; +`; + +const InviteDialogActionButton = styled.button<{ $primary?: boolean }>` + min-height: 38px; + border-radius: 12px; + border: 1px solid + ${({ $primary }) => + $primary ? "var(--invite-brand-strong)" : "var(--invite-border-strong)"}; + background: ${({ $primary }) => + $primary ? "var(--invite-brand-strong)" : "var(--invite-surface)"}; + color: ${({ $primary }) => + $primary ? "var(--lime-surface, #ffffff)" : "var(--invite-text)"}; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 13px; + font-size: 13px; + font-weight: 800; + cursor: pointer; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + transform 0.18s ease; + + &:hover { + transform: translateY(-1px); + background: ${({ $primary }) => + $primary + ? "var(--invite-brand)" + : "var(--invite-surface-hover, var(--lime-surface-hover, #f4fdf4))"}; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.58; + transform: none; + } + + svg { + width: 15px; + height: 15px; + } +`; + +const AppearancePopover = styled.div` + position: absolute; + left: calc(100% + 12px); + bottom: -2px; + z-index: 70; + width: 228px; + max-width: min(228px, calc(100vw - 24px)); + border-radius: 18px; + border: 1px solid var(--lime-card-subtle-border, rgba(226, 240, 226, 0.92)); + background: var(--lime-card-subtle, var(--lime-surface, #ffffff)); + box-shadow: + 0 20px 40px -32px rgba(15, 23, 42, 0.32), + inset 0 1px 0 rgba(255, 255, 255, 0.72); + color: var(--lime-text, #1a3b2b); + padding: 9px; + transform-origin: left bottom; + animation: appearancePopoverIn 150ms ease-out both; + + &::after { + content: ""; + position: absolute; + left: -5px; + bottom: 15px; + border-left: 1px solid var(--lime-surface-border, rgba(226, 240, 226, 0.92)); + border-bottom: 1px solid + var(--lime-surface-border, rgba(226, 240, 226, 0.92)); + width: 10px; + height: 10px; + transform: rotate(45deg); + background: var(--lime-card-subtle, var(--lime-surface, #ffffff)); + } + + @keyframes appearancePopoverIn { + from { + opacity: 0; + transform: translateY(6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @media (max-width: 760px) { + left: auto; + right: 0; + bottom: calc(100% + 10px); + transform-origin: right bottom; + + &::after { + left: auto; + right: 13px; + bottom: -5px; + border-left: none; + border-right: 1px solid + var(--lime-surface-border, rgba(226, 240, 226, 0.92)); + } + } +`; + +const AppearancePopoverHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 2px 2px 8px; +`; + +const AppearancePopoverTitle = styled.div` + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 700; + color: var(--lime-text-strong, #0f172a); + + svg { + width: 15px; + height: 15px; + color: var(--lime-brand-strong, #166534); + } +`; + +const AppearancePopoverSummary = styled.div` + max-width: 132px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-weight: 600; + color: var(--lime-text-muted, #6b826b); +`; + +const AppearanceGroup = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + padding: 7px 0; + border-top: 1px solid var(--lime-divider-subtle, rgba(226, 240, 226, 0.82)); +`; + +const AppearanceGroupLabel = styled.div` + padding: 0 2px; + font-size: 11px; + font-weight: 700; + color: var(--lime-text-muted, #6b826b); +`; + +const ThemeModeGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +`; + +const ThemeModeButton = styled.button<{ $active?: boolean }>` + min-width: 0; + border-radius: 13px; + border: 1px solid + ${({ $active }) => + $active + ? "var(--lime-card-subtle-border, #bbf7d0)" + : "var(--lime-card-subtle-border, rgba(226, 240, 226, 0.82))"}; + background: ${({ $active }) => + $active + ? "var(--lime-chrome-tab-active-surface, var(--lime-surface, #ffffff))" + : "var(--lime-surface, #ffffff)"}; + color: ${({ $active }) => + $active + ? "var(--lime-text-strong, #0f172a)" + : "var(--lime-text-muted, #6b826b)"}; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 7px 6px; + font-size: 11px; + font-weight: 700; + box-shadow: ${({ $active }) => + $active ? "0 10px 22px -20px var(--lime-shadow-color)" : "none"}; + transition: + border-color 0.16s ease, + background 0.16s ease, + color 0.16s ease, + transform 0.16s ease; + + &:hover { + border-color: var(--lime-card-subtle-border, #bbf7d0); + background: var( + --lime-chrome-tab-hover, + var(--lime-surface-hover, #f4fdf4) + ); + color: var(--lime-text-strong, #0f172a); + } + + svg { + width: 13px; + height: 13px; + flex-shrink: 0; + } +`; + +const ColorSchemeList = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +`; + +const ColorSchemeButton = styled.button<{ $active?: boolean }>` + display: flex; + position: relative; + min-height: 58px; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 5px; + width: 100%; + min-width: 0; + border-radius: 13px; + border: 1px solid + ${({ $active }) => + $active + ? "var(--lime-card-subtle-border, #bbf7d0)" + : "var(--lime-card-subtle-border, rgba(226, 240, 226, 0.82))"}; + background: ${({ $active }) => + $active + ? "var(--lime-chrome-tab-active-surface, var(--lime-surface, #ffffff))" + : "var(--lime-surface, #ffffff)"}; + color: var(--lime-text, #1a3b2b); + cursor: pointer; + padding: 7px; + text-align: left; + transition: + border-color 0.16s ease, + background 0.16s ease, + color 0.16s ease, + transform 0.16s ease; + + &:hover { + border-color: var(--lime-card-subtle-border, #bbf7d0); + background: var( + --lime-chrome-tab-hover, + var(--lime-surface-hover, #f4fdf4) + ); + } +`; + +const ColorSchemeSwatches = styled.span` + display: inline-flex; + flex-shrink: 0; + overflow: hidden; + width: 42px; + height: 14px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.72); + box-shadow: 0 0 0 1px var(--lime-surface-border, rgba(226, 240, 226, 0.82)); + + span { + flex: 1; + } +`; + +const ColorSchemeText = styled.span` + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 6px; +`; + +const ColorSchemeLabel = styled.span` + min-width: 0; + max-width: calc(100% - 22px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 700; + color: var(--lime-text-strong, #0f172a); +`; + +const ColorSchemeCheck = styled.span<{ $active?: boolean }>` + display: inline-flex; + position: absolute; + right: 7px; + bottom: 7px; + width: 16px; + height: 16px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 999px; + color: var(--lime-brand-strong, #166534); + opacity: ${({ $active }) => ($active ? 1 : 0)}; + + svg { + width: 13px; + height: 13px; + } +`; + +const AccountActionSlot = styled.div<{ $collapsed?: boolean }>` + position: relative; + margin-top: 4px; + display: flex; + justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "stretch")}; +`; + +const AccountButton = styled.button<{ + $collapsed?: boolean; + $active?: boolean; +}>` + width: 100%; + min-height: ${({ $collapsed }) => ($collapsed ? "38px" : "42px")}; + border: none; + border-radius: 15px; + background: ${({ $active }) => + $active ? "var(--sidebar-active)" : "transparent"}; + color: var(--sidebar-foreground); + display: flex; + align-items: center; + justify-content: ${({ $collapsed }) => + $collapsed ? "center" : "space-between"}; + gap: 8px; + padding: ${({ $collapsed }) => ($collapsed ? "0" : "5px 8px")}; + cursor: pointer; + transition: + background-color 0.18s ease, + color 0.18s ease; + + &:hover { + background: var(--sidebar-hover); + } +`; + +const AccountIdentity = styled.span<{ $collapsed?: boolean }>` + min-width: 0; + display: ${({ $collapsed }) => ($collapsed ? "none" : "inline-flex")}; + align-items: center; + gap: 9px; +`; + +const AccountAvatar = styled.span` + width: 30px; + height: 30px; + flex-shrink: 0; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--lime-brand, #10b981); + color: white; + font-size: 13px; + font-weight: 800; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24); + overflow: hidden; + + img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + } +`; + +const AccountName = styled.span` + min-width: 0; + max-width: 116px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + text-align: left; +`; + +const AccountTrailing = styled.span<{ $collapsed?: boolean }>` + display: ${({ $collapsed }) => ($collapsed ? "none" : "inline-flex")}; + align-items: center; + gap: 7px; + color: var(--sidebar-muted); + + svg { + width: 15px; + height: 15px; + } +`; + +const AccountStateBadge = styled.span<{ $connected?: boolean }>` + border-radius: 999px; + padding: 4px 9px; + border: 1px solid + ${({ $connected }) => + $connected + ? "var(--lime-brand-soft-border, #bbf7d0)" + : "var(--lime-card-subtle-border, #d9eadf)"}; + background: ${({ $connected }) => + $connected + ? "var(--lime-brand-soft, #ecfdf5)" + : "var(--lime-surface-soft, #f8fcf9)"}; + color: ${({ $connected }) => + $connected + ? "var(--lime-brand-strong, #166534)" + : "var(--lime-text-muted, #6b826b)"}; + font-size: 11px; + font-weight: 800; + line-height: 1; +`; + +const AccountMenuPopover = styled.div<{ $collapsed?: boolean }>` + position: absolute; + left: ${({ $collapsed }) => ($collapsed ? "calc(100% + 12px)" : "0")}; + bottom: ${({ $collapsed }) => ($collapsed ? "0" : "calc(100% + 12px)")}; + z-index: 80; + width: ${({ $collapsed }) => ($collapsed ? "284px" : "304px")}; + max-width: min(304px, calc(100vw - 24px)); + border-radius: 18px; + border: 1px solid var(--lime-card-subtle-border, rgba(226, 240, 226, 0.92)); + background: var(--lime-card-subtle, var(--lime-surface, #ffffff)); + box-shadow: + 0 24px 52px -32px rgba(15, 23, 42, 0.36), + inset 0 1px 0 rgba(255, 255, 255, 0.72); + color: var(--lime-text, #1a3b2b); + padding: 10px; + transform-origin: ${({ $collapsed }) => + $collapsed ? "left bottom" : "left bottom"}; + animation: accountMenuPopoverIn 150ms ease-out both; + + @keyframes accountMenuPopoverIn { + from { + opacity: 0; + transform: translateY(6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +`; + +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); + color: var(--lime-text, #1a3b2b); + padding: 11px 12px; + text-align: left; + display: flex; + flex-direction: column; + gap: 9px; +`; + +const AccountPlanActions = styled.div` + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 8px; +`; + +const AccountPlanActionButton = styled.button<{ $primary?: boolean }>` + min-height: 36px; + border-radius: 12px; + border: 1px solid + ${({ $primary }) => + $primary + ? "var(--lime-brand-strong, #166534)" + : "var(--lime-card-subtle-border, #d9eadf)"}; + background: ${({ $primary }) => + $primary ? "var(--lime-brand-strong, #166534)" : "var(--lime-surface)"}; + color: ${({ $primary }) => + $primary ? "#ffffff" : "var(--lime-text, #1a3b2b)"}; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 10px; + font-size: 12px; + font-weight: 800; + transition: + background-color 0.16s ease, + border-color 0.16s ease, + transform 0.16s ease; + + &:hover { + transform: translateY(-1px); + background: ${({ $primary }) => + $primary + ? "var(--lime-brand, #10b981)" + : "var(--lime-surface-hover, #f4fdf4)"}; + } + + svg { + width: 14px; + height: 14px; + flex-shrink: 0; + } +`; + +const AccountPlanHeader = styled.span` + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 14px; + font-weight: 800; + color: var(--lime-text-strong, #0f172a); +`; + +const AccountPlanBadge = styled.span<{ $connected?: boolean }>` + flex-shrink: 0; + border-radius: 999px; + border: 1px solid + ${({ $connected }) => + $connected + ? "var(--lime-brand-soft-border, #bbf7d0)" + : "var(--lime-card-subtle-border, #d9eadf)"}; + background: ${({ $connected }) => + $connected ? "var(--lime-brand-soft, #ecfdf5)" : "var(--lime-surface)"}; + color: ${({ $connected }) => + $connected + ? "var(--lime-brand-strong, #166534)" + : "var(--lime-text-muted, #6b826b)"}; + padding: 4px 8px; + font-size: 11px; + font-weight: 800; +`; + +const AccountCloudIdentity = 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); +`; + +const AccountCloudEmail = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 650; + color: var(--lime-text-muted, #6b826b); +`; + +const AccountPlanDetail = styled.span` + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 12px; + font-weight: 700; + 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; + gap: 2px; + padding-top: 9px; +`; + +const AccountMenuItemGroup = styled.div` + position: relative; +`; + +const AccountMenuItem = styled.button<{ $danger?: boolean; $active?: boolean }>` + width: 100%; + min-height: 40px; + border: none; + border-radius: 13px; + background: ${({ $active }) => + $active ? "var(--sidebar-active)" : "transparent"}; + color: ${({ $danger }) => + $danger ? "var(--lime-danger, #ef4444)" : "var(--lime-text, #1a3b2b)"}; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 10px; + font-size: 14px; + font-weight: 700; + text-align: left; + transition: + background-color 0.16s ease, + color 0.16s ease; + + &:hover { + background: ${({ $danger }) => + $danger + ? "var(--lime-danger-soft, #fff1f2)" + : "var(--lime-surface-hover, #f4fdf4)"}; + } + + &:disabled { + cursor: progress; + opacity: 0.62; + } + + svg { + width: 16px; + height: 16px; + flex-shrink: 0; + } +`; + +const AccountMenuItemLeading = styled.span` + min-width: 0; + display: inline-flex; + align-items: center; + gap: 10px; +`; + +const AccountMenuItemTrailing = styled.span` + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--lime-text-muted, #6b826b); + font-size: 12px; + font-weight: 700; +`; + +const AccountSubmenuPopover = styled.div` + position: absolute; + left: calc(100% + 8px); + top: 0; + z-index: 90; + width: 188px; + border-radius: 16px; + border: 1px solid var(--lime-card-subtle-border, rgba(226, 240, 226, 0.92)); + background: var(--lime-card-subtle, var(--lime-surface, #ffffff)); + box-shadow: + 0 20px 44px -30px rgba(15, 23, 42, 0.34), + inset 0 1px 0 rgba(255, 255, 255, 0.72); + color: var(--lime-text, #1a3b2b); + padding: 7px; + animation: accountSubmenuPopoverIn 140ms ease-out both; + + @keyframes accountSubmenuPopoverIn { + from { + opacity: 0; + transform: translateX(-4px) scale(0.98); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } + } + + @media (max-width: 760px) { + left: 0; + top: calc(100% + 6px); + } +`; + +const AccountSubmenuTitle = styled.div` + padding: 5px 8px 7px; + font-size: 11px; + font-weight: 800; + color: var(--lime-text-muted, #6b826b); +`; + +const AccountSubmenuItem = styled.button<{ $active?: boolean }>` + width: 100%; + min-height: 38px; + border: none; + border-radius: 12px; + background: ${({ $active }) => + $active ? "var(--sidebar-active)" : "transparent"}; + color: ${({ $active }) => + $active ? "var(--sidebar-active-foreground)" : "var(--lime-text, #1a3b2b)"}; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 9px; + font-size: 13px; + font-weight: 750; + text-align: left; + transition: + background-color 0.16s ease, + color 0.16s ease; + + &:hover { + background: var(--lime-surface-hover, #f4fdf4); + color: var(--lime-text-strong, #0f172a); + } + + svg { + width: 15px; + height: 15px; + flex-shrink: 0; + } +`; + +const AccountSubmenuItemText = styled.span` + display: flex; + min-width: 0; + flex-direction: column; + gap: 1px; +`; + +const AccountSubmenuItemLabel = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const AccountSubmenuItemHint = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-weight: 650; + color: var(--lime-text-muted, #6b826b); +`; + +const AccountMenuDivider = styled.div` + height: 1px; + margin: 5px 0; + background: var(--lime-divider-subtle, rgba(226, 240, 226, 0.82)); +`; + function getIconByName(iconName: string): LucideIcon { const IconComponent = ( LucideIcons as unknown as Record @@ -818,40 +1678,92 @@ function buildVisibleSidebarSessions(params: { return [...visibleSessions.slice(0, Math.max(limit - 1, 0)), currentSession]; } -function formatSidebarSessionTime(updatedAt: number): string { - const diffMs = Date.now() - updatedAt * 1000; - const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60))); - - if (diffMinutes < 60) { - return `${diffMinutes}分`; - } - - const diffHours = Math.floor(diffMinutes / 60); - if (diffHours < 24) { - return `${diffHours}时`; - } - - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 30) { - return `${diffDays}天`; - } - - return new Date(updatedAt * 1000).toLocaleDateString("zh-CN", { - month: "numeric", - day: "numeric", - }); +function resolveAccountDisplayName( + sessionState: OemCloudStoredSessionState | null, +): string { + const user = sessionState?.session.user; + const fallbackEmailName = user?.email?.split("@")[0]?.trim(); + return ( + user?.displayName?.trim() || + user?.username?.trim() || + fallbackEmailName || + "开源使用" + ); } -function formatSidebarSessionMeta(session: AsterSessionInfo): string { - if (typeof session.archived_at === "number" && session.archived_at > 0) { - return `归档 ${formatSidebarSessionTime(session.archived_at)}`; - } - - return formatSidebarSessionTime(session.updated_at); +function resolveAccountEmail( + sessionState: OemCloudStoredSessionState | null, +): string | null { + return sessionState?.session.user.email?.trim() || null; } -function resolveSidebarSessionTitle(session: AsterSessionInfo): string { - return session.name?.trim() || "未命名对话"; +function resolveAccountTenantLabel( + sessionState: OemCloudStoredSessionState | null, +): string | null { + const tenant = sessionState?.session.tenant; + return ( + tenant?.name?.trim() || tenant?.slug?.trim() || tenant?.id?.trim() || null + ); +} + +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) { + return null; + } + + const modelSuffix = preference.defaultModel + ? ` · ${preference.defaultModel}` + : ""; + if (preference.providerSource === "local") { + return `本地 Key${modelSuffix}`; + } + + const matchedOffer = bootstrap.providerOffersSummary.find( + (offer) => offer.providerKey === preference.providerKey, + ); + return `${matchedOffer?.displayName?.trim() || "OEM 云端"}${modelSuffix}`; +} + +function resolveAccountInitial(name: string): string { + const normalized = name.trim(); + if (!normalized) { + return "L"; + } + + return normalized.slice(0, 1).toUpperCase(); +} + +function formatReferralCredits(value: number | undefined): string { + if (typeof value !== "number" || value <= 0) { + return "按当前策略发放"; + } + + return `${value.toLocaleString("zh-CN")} 积分`; +} + +function normalizeSidebarLanguage(language?: string): Language { + return language === "en" ? "en" : "zh"; +} + +function resolveSidebarLanguageLabel(language: Language): string { + return ( + APP_SIDEBAR_LANGUAGE_OPTIONS.find((option) => option.id === language) + ?.label ?? "中文" + ); } export function AppSidebar({ @@ -876,7 +1788,14 @@ export function AppSidebar({ const activeAgentPageParams = activePageParams as AgentPageParams | undefined; const isClawTaskCenter = activePage === "agent" && agentEntry === "claw"; const isNewTaskHome = activePage === "agent" && agentEntry === "new-task"; - const currentProjectId = activeAgentPageParams?.projectId?.trim() || null; + const [rememberedProjectId, setRememberedProjectId] = useState( + () => + typeof window === "undefined" + ? null + : loadPersistedProjectId(LAST_PROJECT_ID_KEY), + ); + const currentProjectId = + activeAgentPageParams?.projectId?.trim() || rememberedProjectId; const currentSessionId = activeAgentPageParams?.initialSessionId?.trim() || null; const [collapsed, setCollapsed] = useState(() => { @@ -888,14 +1807,42 @@ export function AppSidebar({ window.localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY) === "true" ); }); - const [theme, setTheme] = useState<"light" | "dark">(() => { - if (typeof window !== "undefined") { - return document.documentElement.classList.contains("dark") - ? "dark" - : "light"; - } - return "light"; + const [themeState, setThemeState] = useState<{ + themeMode: LimeThemeMode; + effectiveThemeMode: LimeEffectiveThemeMode; + }>(() => { + const themeMode = + typeof window === "undefined" ? "system" : loadLimeThemeMode(); + return { + themeMode, + effectiveThemeMode: getEffectiveLimeThemeMode(themeMode), + }; }); + const [colorSchemeId, setColorSchemeId] = useState(() => + typeof window === "undefined" ? "lime-classic" : loadLimeColorSchemeId(), + ); + const [appearancePopoverOpen, setAppearancePopoverOpen] = useState(false); + const [accountMenuOpen, setAccountMenuOpen] = useState(false); + const [languageMenuOpen, setLanguageMenuOpen] = useState(false); + const [language, setLanguageState] = useState("zh"); + const [cloudSessionState, setCloudSessionState] = + useState(() => + typeof window === "undefined" ? null : getStoredOemCloudSessionState(), + ); + const [cloudBootstrapState, setCloudBootstrapState] = + useState(() => + typeof window === "undefined" + ? null + : getOemCloudBootstrapSnapshot(), + ); + const [accountLogoutPending, setAccountLogoutPending] = useState(false); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const [inviteDashboard, setInviteDashboard] = + useState(null); + const [inviteLoading, setInviteLoading] = useState(false); + const [inviteError, setInviteError] = useState(null); + const [inviteReloadKey, setInviteReloadKey] = useState(0); + const { setLanguage: setI18nLanguage } = useI18nPatch(); const [enabledNavItems, setEnabledNavItems] = useState( DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, @@ -919,16 +1866,21 @@ export function AppSidebar({ const [sidebarSessionActionId, setSidebarSessionActionId] = useState< string | null >(null); - const [recentSessionsCollapsed, setRecentSessionsCollapsed] = useState(false); - const [archivedSessionsCollapsed, setArchivedSessionsCollapsed] = - useState(true); const [recentSessionsVisibleCount, setRecentSessionsVisibleCount] = useState( SIDEBAR_RECENT_SESSION_PAGE_SIZE, ); const [archivedSessionsVisibleCount, setArchivedSessionsVisibleCount] = useState(SIDEBAR_ARCHIVED_SESSION_PAGE_SIZE); + const [archivedSessionsCollapsed, setArchivedSessionsCollapsed] = + useState(true); + const appearanceControlRef = useRef(null); + const accountControlRef = useRef(null); + const reserveWindowControls = shouldReserveMacWindowControls(); const hasCachedCurrentSessionSidebarEntry = - hasCachedSidebarSessionEntry(sidebarSessionsRef.current, currentSessionId) || + hasCachedSidebarSessionEntry( + sidebarSessionsRef.current, + currentSessionId, + ) || hasCachedSidebarSessionEntry( archivedSidebarSessionsRef.current, currentSessionId, @@ -942,6 +1894,7 @@ export function AppSidebar({ config.navigation?.enabled_items, ); setEnabledNavItems(resolvedItems); + setLanguageState(normalizeSidebarLanguage(config.language)); } catch (error) { console.error("加载配置失败:", error); } @@ -1027,13 +1980,277 @@ export function AppSidebar({ }, []); useEffect(() => { - if (theme === "dark") { - document.documentElement.classList.add("dark"); - } else { - document.documentElement.classList.remove("dark"); + if (typeof window === "undefined") { + return; } - localStorage.setItem("theme", theme); - }, [theme]); + + setCloudSessionState(getStoredOemCloudSessionState()); + return subscribeOemCloudSessionChanged(setCloudSessionState); + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + setCloudBootstrapState( + getOemCloudBootstrapSnapshot(), + ); + return subscribeOemCloudBootstrapChanged((payload) => { + setCloudBootstrapState((payload as OemCloudBootstrapResponse) ?? null); + }); + }, []); + + const inviteTenantId = cloudSessionState?.session.tenant.id; + const canLoadReferralDashboard = + Boolean(cloudSessionState) && + cloudBootstrapState?.features.referralEnabled !== false; + + useEffect(() => { + if (!inviteDialogOpen || !inviteTenantId || !canLoadReferralDashboard) { + return; + } + + let cancelled = false; + setInviteLoading(true); + setInviteError(null); + + getClientReferralDashboard(inviteTenantId) + .then((dashboard) => { + if (!cancelled) { + setInviteDashboard(dashboard); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + + const message = + error instanceof Error && error.message.trim() + ? error.message.trim() + : "加载邀请信息失败"; + setInviteError(message); + }) + .finally(() => { + if (!cancelled) { + setInviteLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ + inviteDialogOpen, + inviteReloadKey, + inviteTenantId, + canLoadReferralDashboard, + ]); + + useEffect(() => { + if ( + cloudSessionState && + cloudBootstrapState?.features.referralEnabled === false && + inviteDialogOpen + ) { + setInviteDialogOpen(false); + } + }, [ + cloudBootstrapState?.features.referralEnabled, + cloudSessionState, + inviteDialogOpen, + ]); + + useEffect(() => { + if (!accountMenuOpen || typeof window === "undefined") { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if ( + target instanceof Node && + accountControlRef.current?.contains(target) + ) { + return; + } + + setAccountMenuOpen(false); + setLanguageMenuOpen(false); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setAccountMenuOpen(false); + setLanguageMenuOpen(false); + } + }; + + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [accountMenuOpen]); + + useEffect(() => { + if (!accountMenuOpen) { + setLanguageMenuOpen(false); + } + }, [accountMenuOpen]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const syncThemeFromStorage = () => { + const themeMode = loadLimeThemeMode(); + const effectiveThemeMode = applyLimeThemeMode(themeMode); + setThemeState({ themeMode, effectiveThemeMode }); + }; + + const syncColorSchemeFromStorage = () => { + const nextColorSchemeId = loadLimeColorSchemeId(); + applyLimeColorScheme(nextColorSchemeId); + setColorSchemeId(nextColorSchemeId); + }; + + const handleThemeChanged = (event: Event) => { + const detail = (event as CustomEvent).detail; + const themeMode = detail?.themeMode ?? loadLimeThemeMode(); + const effectiveThemeMode = + detail?.effectiveThemeMode ?? getEffectiveLimeThemeMode(themeMode); + setThemeState({ themeMode, effectiveThemeMode }); + }; + + const handleColorSchemeChanged = (event: Event) => { + const detail = (event as CustomEvent) + .detail; + setColorSchemeId(detail?.colorSchemeId ?? loadLimeColorSchemeId()); + }; + + const handleStorageChange = (event: StorageEvent) => { + if (event.key === null || event.key === LIME_THEME_STORAGE_KEY) { + syncThemeFromStorage(); + } + if (event.key === null || event.key === LIME_COLOR_SCHEME_STORAGE_KEY) { + syncColorSchemeFromStorage(); + } + }; + + const systemThemeQuery = window.matchMedia?.( + "(prefers-color-scheme: dark)", + ); + const handleSystemThemeChange = () => { + setThemeState((current) => { + if (current.themeMode !== "system") { + return current; + } + + const effectiveThemeMode = applyLimeThemeMode("system"); + return { + themeMode: "system", + effectiveThemeMode, + }; + }); + }; + + syncThemeFromStorage(); + syncColorSchemeFromStorage(); + + window.addEventListener(LIME_THEME_CHANGED_EVENT, handleThemeChanged); + window.addEventListener( + LIME_COLOR_SCHEME_CHANGED_EVENT, + handleColorSchemeChanged, + ); + window.addEventListener("storage", handleStorageChange); + systemThemeQuery?.addEventListener("change", handleSystemThemeChange); + + return () => { + window.removeEventListener(LIME_THEME_CHANGED_EVENT, handleThemeChanged); + window.removeEventListener( + LIME_COLOR_SCHEME_CHANGED_EVENT, + handleColorSchemeChanged, + ); + window.removeEventListener("storage", handleStorageChange); + systemThemeQuery?.removeEventListener("change", handleSystemThemeChange); + }; + }, []); + + useEffect(() => { + if (!appearancePopoverOpen || typeof window === "undefined") { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if ( + target instanceof Node && + appearanceControlRef.current?.contains(target) + ) { + return; + } + + setAppearancePopoverOpen(false); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setAppearancePopoverOpen(false); + } + }; + + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [appearancePopoverOpen]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const refreshRememberedProjectId = () => { + setRememberedProjectId(loadPersistedProjectId(LAST_PROJECT_ID_KEY)); + }; + + const handlePersistedProjectChanged = (event: Event) => { + const detail = (event as CustomEvent<{ key?: string }>).detail; + if (detail?.key && detail.key !== LAST_PROJECT_ID_KEY) { + return; + } + refreshRememberedProjectId(); + }; + + const handleStorageChange = (event: StorageEvent) => { + if (event.key !== LAST_PROJECT_ID_KEY) { + return; + } + refreshRememberedProjectId(); + }; + + window.addEventListener( + PERSISTED_PROJECT_ID_CHANGED_EVENT, + handlePersistedProjectChanged, + ); + window.addEventListener("storage", handleStorageChange); + + return () => { + window.removeEventListener( + PERSISTED_PROJECT_ID_CHANGED_EVENT, + handlePersistedProjectChanged, + ); + window.removeEventListener("storage", handleStorageChange); + }; + }, []); useEffect(() => { requestedNavigationTargetRef.current = activeNavigationTarget; @@ -1066,6 +2283,8 @@ export function AppSidebar({ const shouldShowConversationList = !collapsed && !(activePage === "agent" && activeAgentPageParams?.immersiveHome); + const shouldLoadWorkspaceScopedConversations = + shouldShowConversationList && Boolean(currentProjectId); const shouldShowSessionLoadingState = sidebarSessionsLoading && sidebarSessions.length === 0; const shouldShowArchivedSessionLoadingState = @@ -1084,16 +2303,17 @@ export function AppSidebar({ useEffect(() => { setArchivedSessionsVisibleCount(SIDEBAR_ARCHIVED_SESSION_PAGE_SIZE); + setArchivedSessionsCollapsed(true); }, [currentProjectId]); - const recentSessionRequestLimit = useMemo( - () => - buildSidebarSessionRequestLimit( - recentSessionsVisibleCount, - SIDEBAR_RECENT_SESSION_PAGE_SIZE, - ), - [recentSessionsVisibleCount], - ); + const recentSessionRequestLimit = useMemo(() => { + const requestLimit = buildSidebarSessionRequestLimit( + recentSessionsVisibleCount, + SIDEBAR_RECENT_SESSION_PAGE_SIZE, + ); + + return Math.max(requestLimit, SIDEBAR_RECENT_SESSION_PREFETCH_LIMIT); + }, [recentSessionsVisibleCount]); const archivedSessionRequestLimit = useMemo( () => buildSidebarSessionRequestLimit( @@ -1104,7 +2324,10 @@ export function AppSidebar({ ); const loadRecentSidebarSessions = useCallback(async () => { - if (!shouldShowConversationList) { + if (!shouldLoadWorkspaceScopedConversations) { + setSidebarSessions([]); + setSidebarSessionsHasMore(false); + setSidebarSessionsLoading(false); return; } @@ -1114,15 +2337,15 @@ export function AppSidebar({ try { const sessions = await listAgentRuntimeSessions({ limit: recentSessionRequestLimit, - workspaceId: currentProjectId ?? undefined, + workspaceId: currentProjectId ?? "", }); const sortedSessions = sortSidebarSessions(sessions); - const { sessions: nextSessions, hasMore } = splitSidebarSessionResult({ + const { hasMore } = splitSidebarSessionResult({ sessions: sortedSessions, visibleCount: recentSessionsVisibleCount, pageSize: SIDEBAR_RECENT_SESSION_PAGE_SIZE, }); - setSidebarSessions(nextSessions); + setSidebarSessions(sortedSessions); setSidebarSessionsHasMore(hasMore); } catch (error) { console.error("加载导航任务列表失败:", error); @@ -1135,7 +2358,7 @@ export function AppSidebar({ currentProjectId, recentSessionRequestLimit, recentSessionsVisibleCount, - shouldShowConversationList, + shouldLoadWorkspaceScopedConversations, ]); const loadRecentSidebarSessionsRef = useRef(loadRecentSidebarSessions); useEffect(() => { @@ -1143,7 +2366,10 @@ export function AppSidebar({ }, [loadRecentSidebarSessions]); const loadArchivedSidebarSessions = useCallback(async () => { - if (!shouldShowConversationList || archivedSessionsCollapsed) { + if (!shouldLoadWorkspaceScopedConversations || archivedSessionsCollapsed) { + setArchivedSessionEntries([]); + setArchivedSessionEntriesHasMore(false); + setArchivedSidebarSessionsLoading(false); return; } @@ -1154,17 +2380,17 @@ export function AppSidebar({ const sessions = await listAgentRuntimeSessions({ archivedOnly: true, limit: archivedSessionRequestLimit, - workspaceId: currentProjectId ?? undefined, + workspaceId: currentProjectId ?? "", }); const sortedSessions = sortSidebarSessions( sessions.filter((session) => Boolean(session.archived_at)), ); - const { sessions: nextSessions, hasMore } = splitSidebarSessionResult({ + const { hasMore } = splitSidebarSessionResult({ sessions: sortedSessions, visibleCount: archivedSessionsVisibleCount, pageSize: SIDEBAR_ARCHIVED_SESSION_PAGE_SIZE, }); - setArchivedSessionEntries(nextSessions); + setArchivedSessionEntries(sortedSessions); setArchivedSessionEntriesHasMore(hasMore); } catch (error) { console.error("加载归档任务列表失败:", error); @@ -1178,7 +2404,7 @@ export function AppSidebar({ archivedSessionsCollapsed, archivedSessionsVisibleCount, currentProjectId, - shouldShowConversationList, + shouldLoadWorkspaceScopedConversations, ]); const loadArchivedSidebarSessionsRef = useRef(loadArchivedSidebarSessions); useEffect(() => { @@ -1197,7 +2423,7 @@ export function AppSidebar({ ]); useEffect(() => { - if (!shouldShowConversationList) { + if (!shouldLoadWorkspaceScopedConversations) { return; } @@ -1218,11 +2444,11 @@ export function AppSidebar({ currentProjectId, hasCachedCurrentSessionSidebarEntry, isClawTaskCenter, - shouldShowConversationList, + shouldLoadWorkspaceScopedConversations, ]); useEffect(() => { - if (!shouldShowConversationList) { + if (!shouldLoadWorkspaceScopedConversations) { return; } @@ -1235,18 +2461,22 @@ export function AppSidebar({ return () => { window.removeEventListener("focus", handleFocus); }; - }, [refreshSidebarSessions, shouldShowConversationList]); + }, [refreshSidebarSessions, shouldLoadWorkspaceScopedConversations]); useEffect(() => { - if (!shouldShowConversationList || archivedSessionsCollapsed) { + if (!shouldLoadWorkspaceScopedConversations || archivedSessionsCollapsed) { return; } void loadArchivedSidebarSessionsRef.current(); - }, [archivedSessionsCollapsed, currentProjectId, shouldShowConversationList]); + }, [ + archivedSessionsCollapsed, + currentProjectId, + shouldLoadWorkspaceScopedConversations, + ]); useEffect(() => { - if (!shouldShowConversationList || !sidebarSessionsHasMore) { + if (!shouldLoadWorkspaceScopedConversations || !sidebarSessionsHasMore) { return; } @@ -1258,14 +2488,14 @@ export function AppSidebar({ }, [ loadRecentSidebarSessions, recentSessionsVisibleCount, - shouldShowConversationList, + shouldLoadWorkspaceScopedConversations, sidebarSessions.length, sidebarSessionsHasMore, ]); useEffect(() => { if ( - !shouldShowConversationList || + !shouldLoadWorkspaceScopedConversations || archivedSessionsCollapsed || !archivedSessionEntriesHasMore ) { @@ -1283,7 +2513,7 @@ export function AppSidebar({ archivedSessionsCollapsed, archivedSessionsVisibleCount, loadArchivedSidebarSessions, - shouldShowConversationList, + shouldLoadWorkspaceScopedConversations, ]); const assistantItems = useMemo(() => { @@ -1301,22 +2531,30 @@ export function AppSidebar({ enabledNavItems.includes(SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID) && assistantItems.length > 0; const recentSidebarSessions = useMemo(() => { + if (!currentProjectId) { + return []; + } + const filteredSessions = currentProjectId ? sidebarSessions.filter( (session) => !session.workspace_id || session.workspace_id === currentProjectId, ) - : sidebarSessions; + : []; return filteredSessions.filter((session) => !session.archived_at); }, [currentProjectId, sidebarSessions]); const archivedSidebarSessions = useMemo(() => { + if (!currentProjectId) { + return []; + } + const filteredSessions = currentProjectId ? archivedSessionEntries.filter( (session) => !session.workspace_id || session.workspace_id === currentProjectId, ) - : archivedSessionEntries; + : []; return filteredSessions.filter((session) => Boolean(session.archived_at)); }, [archivedSessionEntries, currentProjectId]); @@ -1358,6 +2596,31 @@ export function AppSidebar({ }; const handleNavigate = (item: SidebarNavItem) => { + if (item.id === "home-general") { + const targetParams = buildHomeAgentParams({ + projectId: currentProjectId ?? undefined, + }); + const target = { + page: "agent" as Page, + rawParams: targetParams, + paramsKey: serializeNavigationParams(targetParams), + } satisfies SidebarNavigationTarget; + + if ( + isSameSidebarNavigationTarget( + target, + requestedNavigationTargetRef.current.page, + requestedNavigationTargetRef.current.rawParams, + ) + ) { + return; + } + + requestedNavigationTargetRef.current = target; + onNavigate(target.page, target.rawParams); + return; + } + if (item.id === "workbench") { const fallbackSessionId = currentSessionId ?? @@ -1535,15 +2798,161 @@ export function AppSidebar({ [refreshSidebarSessions], ); + const currentColorScheme = getLimeColorScheme(colorSchemeId); + const currentThemeLabel = + LIME_THEME_MODE_OPTIONS.find((option) => option.id === themeState.themeMode) + ?.label ?? "跟随系统"; + const currentLanguageLabel = resolveSidebarLanguageLabel(language); + const accountDisplayName = resolveAccountDisplayName(cloudSessionState); + const accountEmail = resolveAccountEmail(cloudSessionState); + const accountTenantLabel = resolveAccountTenantLabel(cloudSessionState); + const accountProviderLabel = resolveAccountProviderLabel(cloudSessionState); + const accountDefaultProviderSummary = + resolveBootstrapDefaultProviderSummary(cloudBootstrapState); + const accountAvatarUrl = cloudSessionState?.session.user.avatarUrl?.trim(); + const accountInitial = resolveAccountInitial(accountDisplayName); + const hasCloudAccount = Boolean(cloudSessionState); + const inviteEntryVisible = + !hasCloudAccount || cloudBootstrapState?.features.referralEnabled !== false; + const accountButtonTooltip = hasCloudAccount + ? `${accountDisplayName}${accountEmail ? ` · ${accountEmail}` : ""}` + : "开源使用 · 本地可用"; + const inviteShare = inviteDashboard?.share; + const invitePolicy = inviteDashboard?.policy; + const inviteHeadline = inviteShare?.headline?.trim() || "邀请好友加入内测"; + const inviteRules = + inviteShare?.rules?.trim() || + "通过云端邀请策略自动发放奖励,具体到账以当前 OEM 租户配置为准。"; + + const handleThemeModeChange = useCallback((nextThemeMode: LimeThemeMode) => { + const themeMode = persistLimeThemeMode(nextThemeMode); + setThemeState({ + themeMode, + effectiveThemeMode: getEffectiveLimeThemeMode(themeMode), + }); + }, []); + + const handleColorSchemeChange = useCallback( + (nextColorSchemeId: LimeColorSchemeId) => { + const resolvedColorSchemeId = persistLimeColorScheme(nextColorSchemeId); + setColorSchemeId(resolvedColorSchemeId); + }, + [], + ); + + const handleAccountMenuNavigate = useCallback( + (params: PageParams) => { + setAccountMenuOpen(false); + setLanguageMenuOpen(false); + onNavigate("settings", params); + }, + [onNavigate], + ); + + const handleAccountLogout = useCallback(async () => { + const tenantId = cloudSessionState?.session.tenant.id; + setAccountLogoutPending(true); + try { + if (tenantId) { + await logoutClient(tenantId); + } + } catch (error) { + console.error("OEM 云端退出登录失败,已清理本地会话:", error); + } finally { + clearStoredOemCloudSessionState(); + clearOemCloudBootstrapSnapshot(); + clearSkillCatalogCache(); + clearServiceSkillCatalogCache(); + void clearSiteAdapterCatalogCache(); + setAccountMenuOpen(false); + setLanguageMenuOpen(false); + setAccountLogoutPending(false); + } + }, [cloudSessionState?.session.tenant.id]); + + const handleCopyInviteText = useCallback( + async (value: string | undefined, successMessage: string) => { + const text = value?.trim(); + if (!text) { + toast.info("暂无可复制内容"); + return; + } + + try { + if (!navigator.clipboard?.writeText) { + throw new Error("clipboard unavailable"); + } + await navigator.clipboard.writeText(text); + toast.success(successMessage); + } catch { + toast.error("复制失败,请检查剪贴板权限"); + } + }, + [], + ); + + const handleLanguageChange = useCallback( + async (nextLanguage: Language) => { + const previousLanguage = language; + if (nextLanguage === previousLanguage) { + setLanguageMenuOpen(false); + return; + } + + setLanguageState(nextLanguage); + setI18nLanguage(nextLanguage); + setLanguageMenuOpen(false); + + try { + const config = await getConfig(); + await saveConfig({ + ...config, + language: nextLanguage, + }); + } catch (error) { + console.error("保存语言设置失败:", error); + setLanguageState(previousLanguage); + setI18nLanguage(previousLanguage); + } + }, + [language, setI18nLanguage], + ); + + const renderThemeModeIcon = (themeMode: LimeThemeMode) => { + if (themeMode === "dark") { + return ; + } + + if (themeMode === "system") { + return ; + } + + return ; + }; + return ( - - + + {maybeWrapWithTooltip( onNavigate("agent", buildHomeAgentParams())} + onClick={() => + onNavigate( + "agent", + buildHomeAgentParams({ + projectId: currentProjectId ?? undefined, + }), + ) + } + aria-label="返回 Lime 首页" title="返回 Lime 首页" > @@ -1554,6 +2963,26 @@ export function AppSidebar({ "Lime 首页", )} + {inviteEntryVisible + ? maybeWrapWithTooltip( + { + setInviteDashboard(null); + setInviteDialogOpen(true); + }} + title="邀请好友" + aria-label="邀请好友" + data-testid="app-sidebar-invite-button" + > + + 邀请好友 + , + "邀请好友", + ) + : null} + {maybeWrapWithTooltip( setCollapsed((value) => !value)} @@ -1569,7 +2998,14 @@ export function AppSidebar({ {maybeWrapWithTooltip( onNavigate("agent", buildHomeAgentParams())} + onClick={() => + onNavigate( + "agent", + buildHomeAgentParams({ + projectId: currentProjectId ?? undefined, + }), + ) + } title="搜索任务" aria-label="搜索任务" > @@ -1586,199 +3022,35 @@ export function AppSidebar({ {shouldShowConversationList ? ( - - - - - setRecentSessionsCollapsed((value) => !value) - } - aria-expanded={!recentSessionsCollapsed} - > - - 最近对话 - - - - - - {!recentSessionsCollapsed ? ( - - {shouldShowSessionLoadingState ? ( - - - 正在加载对话 - - ) : recentSidebarSessions.length > 0 ? ( - visibleRecentSidebarSessions.map((session) => { - const isCurrentConversation = - currentSessionId === session.id; - return ( - - - handleNavigateToConversation(session) - } - title={resolveSidebarSessionTitle(session)} - > - - - {resolveSidebarSessionTitle(session)} - - - {formatSidebarSessionMeta(session)} - - - { - event.stopPropagation(); - void handleToggleSessionArchive(session, true); - }} - > - - - - ); - }) - ) : ( - - - 还没有开始对话 - - )} - {hasMoreRecentSidebarSessions ? ( - - setRecentSessionsVisibleCount( - (current) => - current + SIDEBAR_RECENT_SESSION_PAGE_SIZE, - ) - } - > - 查看更多对话 - - ) : null} - - ) : null} - - - - - - setArchivedSessionsCollapsed((value) => !value) - } - aria-expanded={!archivedSessionsCollapsed} - > - - 归档 - - - {!archivedSessionsCollapsed ? ( - - {shouldShowArchivedSessionLoadingState ? ( - - - 正在加载归档 - - ) : archivedSidebarSessions.length > 0 ? ( - visibleArchivedSidebarSessions.map((session) => ( - - - handleNavigateToConversation(session) - } - title={resolveSidebarSessionTitle(session)} - > - - - {resolveSidebarSessionTitle(session)} - - - {formatSidebarSessionMeta(session)} - - - { - event.stopPropagation(); - void handleToggleSessionArchive(session, false); - }} - > - - - - )) - ) : ( - - - 暂无归档内容 - - )} - {hasMoreArchivedSidebarSessions ? ( - - setArchivedSessionsVisibleCount( - (current) => - current + SIDEBAR_ARCHIVED_SESSION_PAGE_SIZE, - ) - } - > - 查看更多归档 - - ) : null} - - ) : null} - - + { + void handleToggleSessionArchive(session, archived); + }} + onShowMoreRecent={() => + setRecentSessionsVisibleCount( + (current) => current + SIDEBAR_RECENT_SESSION_PAGE_SIZE, + ) + } + onShowMoreArchived={() => + setArchivedSessionsVisibleCount( + (current) => current + SIDEBAR_ARCHIVED_SESSION_PAGE_SIZE, + ) + } + onToggleArchivedCollapsed={() => + setArchivedSessionsCollapsed((current) => !current) + } + /> ) : null} {shouldShowPluginExtensionsSection && ( @@ -1787,36 +3059,591 @@ export function AppSidebar({ {assistantItems.map((item) => renderNavItem(item))} )} - - -
- {filteredFooterNavItems.map((item) => renderNavItem(item))} -
- - - {!collapsed ?
: null} - {maybeWrapWithTooltip( - setTheme(theme === "dark" ? "light" : "dark")} - title={theme === "dark" ? "深色模式" : "浅色模式"} - aria-label={ - theme === "dark" ? "切换到浅色模式" : "切换到深色模式" - } - > - {theme === "dark" ? : } - , - theme === "dark" ? "切换到浅色模式" : "切换到深色模式", - )} - - + + + + {!collapsed ?
: null} + + {collapsed ? ( + + + { + setAccountMenuOpen(false); + setLanguageMenuOpen(false); + setAppearancePopoverOpen((current) => !current); + }} + title="快速切换外观" + aria-label="快速切换外观" + aria-expanded={appearancePopoverOpen} + aria-haspopup="dialog" + > + {themeState.effectiveThemeMode === "dark" ? ( + + ) : ( + + )} + + + 快速切换外观 + + ) : ( + { + setAccountMenuOpen(false); + setLanguageMenuOpen(false); + setAppearancePopoverOpen((current) => !current); + }} + title="快速切换外观" + aria-label="快速切换外观" + aria-expanded={appearancePopoverOpen} + aria-haspopup="dialog" + > + {themeState.effectiveThemeMode === "dark" ? ( + + ) : ( + + )} + + )} + + {appearancePopoverOpen ? ( + + + + + 外观 + + + {currentThemeLabel} · {currentColorScheme.label} + + + + + 主题 + + {LIME_THEME_MODE_OPTIONS.map((option) => { + const active = option.id === themeState.themeMode; + return ( + handleThemeModeChange(option.id)} + > + {renderThemeModeIcon(option.id)} + {option.label} + + ); + })} + + + + + 配色 + + {LIME_COLOR_SCHEMES.map((scheme) => { + const active = scheme.id === colorSchemeId; + return ( + handleColorSchemeChange(scheme.id)} + > + + + + {scheme.label} + + + + + + + ); + })} + + + + ) : null} + + + + + {maybeWrapWithTooltip( + { + setAppearancePopoverOpen(false); + setLanguageMenuOpen(false); + setAccountMenuOpen((current) => !current); + }} + aria-label="打开用户菜单" + aria-expanded={accountMenuOpen} + aria-haspopup="dialog" + data-testid="app-sidebar-account-button" + > + + + {accountAvatarUrl ? ( + {accountDisplayName} + ) : ( + accountInitial + )} + + {accountDisplayName} + + {collapsed ? ( + + {accountAvatarUrl ? ( + {accountDisplayName} + ) : ( + accountInitial + )} + + ) : null} + + + {hasCloudAccount ? "OEM" : "本地可用"} + + + + , + accountButtonTooltip, + )} + + {accountMenuOpen ? ( + + {hasCloudAccount ? ( + + + + + {accountAvatarUrl ? ( + {accountDisplayName} + ) : ( + accountInitial + )} + + + + {accountDisplayName} + + + {accountEmail ?? "OEM 云端账号"} + + + + OEM 已连接 + + + 已连接 + {accountTenantLabel ? ` ${accountTenantLabel} ` : " OEM "} + 云端;套餐、积分和模型目录以云端实时状态为准。 + + + 登录方式:{accountProviderLabel} + + 默认服务:{accountDefaultProviderSummary ?? "云端同步"} + + + + ) : ( + + + 开源版 + 本地可用 + + + 本地开源功能可直接使用;你可以先进入模型设置配置本地渠道,也可以按需连接 + OEM 云端同步账号、积分、套餐和商业化能力。 + + + 不登录也可用 + OEM 云端可选 + + + + handleAccountMenuNavigate({ + tab: SettingsTabs.Providers, + providerView: "cloud", + }) + } + > + + 连接 OEM 云端 + + + handleAccountMenuNavigate({ + tab: SettingsTabs.Providers, + providerView: "settings", + }) + } + > + + 模型设置 + + + + )} + + + {filteredFooterNavItems.map((item) => { + const AccountNavIcon = item.icon; + const active = isActive(item); + + return ( + { + setAccountMenuOpen(false); + handleNavigate(item); + }} + > + + + {item.label} + + + + ); + })} + + setLanguageMenuOpen((current) => !current)} + > + + + 语言 + + + {currentLanguageLabel} + + + + {languageMenuOpen ? ( + + 界面语言 + {APP_SIDEBAR_LANGUAGE_OPTIONS.map((option) => { + const active = option.id === language; + + return ( + + void handleLanguageChange(option.id) + } + > + + + {option.label} + + + {option.hint} + + + {active ? : null} + + ); + })} + + ) : null} + + {hasCloudAccount ? ( + + handleAccountMenuNavigate({ + tab: SettingsTabs.Profile, + }) + } + > + + + 用户中心 + + + + ) : null} + + handleAccountMenuNavigate({ + tab: SettingsTabs.Providers, + providerView: "settings", + }) + } + > + + + 模型设置 + + + + + handleAccountMenuNavigate({ + tab: SettingsTabs.Providers, + providerView: "cloud", + }) + } + > + + + OEM 云端 + + + + + handleAccountMenuNavigate({ tab: SettingsTabs.About }) + } + > + + + 关于 + + + + {hasCloudAccount ? ( + <> + + void handleAccountLogout()} + > + + + {accountLogoutPending ? "退出中..." : "退出登录"} + + + + ) : null} + + + ) : null} + + + setInviteDialogOpen(false)} + className="p-0" + maxWidth="max-w-xl" + > + + + + {inviteShare?.brandName ?? accountTenantLabel ?? "Lime"} 邀请 + + 邀请好友 + + {inviteHeadline} + {!inviteLoading && !inviteError ? `。${inviteRules}` : ""} + + + + + {!hasCloudAccount ? ( + + 登录 Lime 云端后会生成专属邀请码,并自动读取当前 OEM + 租户的品牌、域名和奖励策略。 + + { + setInviteDialogOpen(false); + onNavigate("settings", { + tab: SettingsTabs.Providers, + providerView: "cloud", + }); + }} + > + + 连接云端账号 + + + + ) : null} + + {hasCloudAccount && inviteLoading ? ( + 正在从云端同步邀请信息... + ) : null} + + {hasCloudAccount && inviteError ? ( + + {inviteError} + + setInviteReloadKey((value) => value + 1)} + > + + 重试 + + + + ) : null} + + {hasCloudAccount && + !inviteLoading && + !inviteError && + inviteDashboard ? ( + + + + 邀请码 + {inviteShare?.code} + + + void handleCopyInviteText( + inviteShare?.code, + "已复制邀请码", + ) + } + > + + 复制 + + + + + + 下载地址 + {inviteShare?.downloadUrl} + + + 邀请链接 + {inviteShare?.landingUrl} + + + 邀请人奖励 + + {formatReferralCredits( + invitePolicy?.referrerRewardCredits, + )} + + + + 被邀请人奖励 + + {formatReferralCredits( + invitePolicy?.inviteeRewardCredits, + )} + + + + + + + void handleCopyInviteText( + inviteShare?.shareText, + "已复制邀请文案", + ) + } + > + + 复制邀请文案 + + + void handleCopyInviteText( + inviteShare?.landingUrl, + "已复制邀请链接", + ) + } + > + + 复制邀请链接 + + + + ) : null} + + + ); } diff --git a/src/components/Modal.test.tsx b/src/components/Modal.test.tsx index a9c47169a..3e2df7014 100644 --- a/src/components/Modal.test.tsx +++ b/src/components/Modal.test.tsx @@ -114,6 +114,9 @@ describe("Modal", () => { ) as HTMLDivElement | null; expect(overlay?.className).toContain("overflow-y-auto"); + expect(overlay?.className).toContain( + "bg-[image:var(--lime-stage-surface-soft)]", + ); expect(overlay?.className).toContain("backdrop-blur-[2px]"); expect(overlay?.className).not.toContain("bg-black/50"); expect(dialog?.className).toContain("max-h-[calc(100vh-2rem)]"); diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index fe2837a81..f81098872 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -146,7 +146,7 @@ export function Modal({ return createPortal(
diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index 1ae205e95..1e8bf43d1 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -274,8 +274,8 @@ interface SplashScreenProps { export function SplashScreen({ onComplete, - duration = 1200, - exitDuration = 320, + duration = 420, + exitDuration = 160, }: SplashScreenProps) { const [isExiting, setIsExiting] = useState(false); diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index ca3173f0e..4cf74464e 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -329,8 +329,12 @@ import { } from "@/lib/sceneapp"; const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; -const BLANK_HOME_DEFERRED_LOAD_MS = 6_000; +const BLANK_HOME_DEFERRED_LOAD_MS = 18_000; const SESSION_ENTRY_DEFERRED_LOAD_MS = 12_000; +const BROWSER_WORKSPACE_HOME_HINT_STORAGE_KEY = + "lime.agent.browser-workspace-home-hint-shown"; +const BROWSER_WORKSPACE_HOME_HINT_MESSAGE = "在这里切换或新建工作区"; +const BROWSER_WORKSPACE_HOME_HINT_AUTO_HIDE_MS = 5_500; const NOOP_SET_CHAT_MESSAGES: Dispatch> = () => undefined; @@ -468,7 +472,7 @@ export function AgentChatWorkspace({ onWorkflowProgressChange, initialUserPrompt, initialUserImages, - initialSessionName, + initialSessionName: _initialSessionName, entryBannerMessage, initialPendingServiceSkillLaunch, initialInputCapability, @@ -501,6 +505,8 @@ export function AgentChatWorkspace({ const [entryBannerVisible, setEntryBannerVisible] = useState( Boolean(effectiveEntryBannerMessage), ); + const [browserWorkspaceHintVisible, setBrowserWorkspaceHintVisible] = + useState(false); const shouldBootstrapCanvasOnEntry = Boolean(contentId) && isSpecializedWorkbenchTheme(normalizedEntryTheme); @@ -565,7 +571,6 @@ export function AgentChatWorkspace({ rememberProjectId, getRememberedProjectId, applyProjectSelection, - resetProjectSelection, clearProjectSelectionRuntime, startTopicProjectResolution, finishTopicProjectResolution, @@ -588,15 +593,31 @@ export function AgentChatWorkspace({ agentEntry === "new-task" && !contentId; const shouldPreserveBlankHomeSurface = shouldPreserveEntryThemeOnHome && normalizedEntryTheme === "general"; + const shouldUseBrowserWorkspaceHomeChrome = shouldPreserveBlankHomeSurface; const shouldPrioritizeInitialSessionEntry = normalizedInitialSessionId !== null && !contentId; + const shouldPrioritizeInitialPromptEntry = + agentEntry === "claw" && + !contentId && + normalizedEntryTheme === "general" && + Boolean(initialUserPrompt?.trim()) && + !initialUserImages?.length && + !initialSiteSkillLaunch && + !initialPendingServiceSkillLaunch?.skillId?.trim() && + !initialPendingServiceSkillLaunch?.skillKey?.trim() && + !initialInputCapability?.capabilityRoute && + !initialProjectFileOpenTarget?.relativePath?.trim(); const shouldDeferWorkspaceAuxiliaryLoads = - shouldPreserveBlankHomeSurface || shouldPrioritizeInitialSessionEntry; + shouldPreserveBlankHomeSurface || + shouldPrioritizeInitialSessionEntry || + shouldPrioritizeInitialPromptEntry; const shouldDeferInitialTopicsLoad = - shouldPreserveBlankHomeSurface || shouldPrioritizeInitialSessionEntry; + shouldPreserveBlankHomeSurface || + shouldPrioritizeInitialSessionEntry || + shouldPrioritizeInitialPromptEntry; const deferredWorkspaceAuxiliaryLoadMs = shouldPreserveBlankHomeSurface ? BLANK_HOME_DEFERRED_LOAD_MS - : shouldPrioritizeInitialSessionEntry + : shouldPrioritizeInitialSessionEntry || shouldPrioritizeInitialPromptEntry ? SESSION_ENTRY_DEFERRED_LOAD_MS : undefined; const [isInitialContentLoading, setIsInitialContentLoading] = useState( @@ -626,6 +647,48 @@ export function AgentChatWorkspace({ setEntryBannerVisible(Boolean(effectiveEntryBannerMessage)); }, [effectiveEntryBannerMessage]); + useEffect(() => { + if ( + !shouldUseBrowserWorkspaceHomeChrome || + !projectId || + entryBannerMessage + ) { + return; + } + + try { + if ( + window.localStorage.getItem(BROWSER_WORKSPACE_HOME_HINT_STORAGE_KEY) === + "true" + ) { + return; + } + + window.localStorage.setItem( + BROWSER_WORKSPACE_HOME_HINT_STORAGE_KEY, + "true", + ); + } catch { + // 本地存储不可用时仍展示一次提示,避免首开闭环静默失败。 + } + + setBrowserWorkspaceHintVisible(true); + }, [entryBannerMessage, projectId, shouldUseBrowserWorkspaceHomeChrome]); + + useEffect(() => { + if (!browserWorkspaceHintVisible) { + return; + } + + const timer = window.setTimeout(() => { + setBrowserWorkspaceHintVisible(false); + }, BROWSER_WORKSPACE_HOME_HINT_AUTO_HIDE_MS); + + return () => { + window.clearTimeout(timer); + }; + }, [browserWorkspaceHintVisible]); + const pageMountedAtRef = useRef(Date.now()); useEffect(() => { @@ -794,6 +857,76 @@ export function AgentChatWorkspace({ projectId, ]); + useEffect(() => { + if ( + !shouldUseBrowserWorkspaceHomeChrome || + projectId || + externalProjectId + ) { + return; + } + + let cancelled = false; + const startedAt = Date.now(); + + logAgentDebug("AgentChatPage", "homeDefaultWorkspace.resolve.start", { + agentEntry, + }); + + void (async () => { + try { + const defaultProject = await getOrCreateDefaultProject(); + if (cancelled) { + return; + } + + if (!defaultProject?.id) { + logAgentDebug( + "AgentChatPage", + "homeDefaultWorkspace.resolve.empty", + { + durationMs: Date.now() - startedAt, + }, + { level: "warn" }, + ); + return; + } + + applyProjectSelection(defaultProject.id); + setProject(defaultProject); + logAgentDebug("AgentChatPage", "homeDefaultWorkspace.resolve.success", { + durationMs: Date.now() - startedAt, + projectId: defaultProject.id, + }); + } catch (error) { + if (cancelled) { + return; + } + + console.warn("[AgentChatPage] 准备默认工作区失败:", error); + logAgentDebug( + "AgentChatPage", + "homeDefaultWorkspace.resolve.error", + { + durationMs: Date.now() - startedAt, + error, + }, + { level: "warn" }, + ); + } + })(); + + return () => { + cancelled = true; + }; + }, [ + agentEntry, + applyProjectSelection, + externalProjectId, + projectId, + shouldUseBrowserWorkspaceHomeChrome, + ]); + // 画布状态(支持多种画布类型) const [canvasState, setCanvasState] = useState( () => { @@ -2010,7 +2143,9 @@ export function AgentChatWorkspace({ } setImageWorkbenchBySessionId((previous) => { - if (isSessionImageWorkbenchStateMeaningful(previous[normalizedSessionId])) { + if ( + isSessionImageWorkbenchStateMeaningful(previous[normalizedSessionId]) + ) { return previous; } @@ -2447,6 +2582,7 @@ export function AgentChatWorkspace({ contextWorkspace, isThemeWorkbench, harnessPanelVisible, + setHarnessPanelVisible, harnessPendingCount, showHarnessToggle, harnessAttentionLevel, @@ -2505,6 +2641,10 @@ export function AgentChatWorkspace({ selectedText, defaultToolPreferences: effectiveChatToolPreferences, onNavigate: _onNavigate, + catalogLoadMode: shouldDeferWorkspaceAuxiliaryLoads + ? "deferred" + : "immediate", + catalogDeferredDelayMs: deferredWorkspaceAuxiliaryLoadMs, }); const handlePendingServiceSkillLaunchSubmit = workspaceServiceSkillEntryActions.handlePendingServiceSkillLaunchSubmit; @@ -3142,17 +3282,13 @@ export function AgentChatWorkspace({ clearRuntimeTeamState, clearProjectSelectionRuntime, resetRestoredSessionState, - resetProjectSelection, resetGuideState, hasHandledNewChatRequest, markNewChatRequestHandled, - createFreshSession, defaultTopicSidebarVisible, normalizedInitialTheme: normalizedEntryTheme, initialCreationMode, newChatAt, - initialSessionName, - projectId, externalProjectId, onNavigate: _onNavigate, autoCollapsedTopicSidebarRef, @@ -3166,8 +3302,6 @@ export function AgentChatWorkspace({ setTaskFiles, setSelectedFileId, setMentionedCharacters, - setProject, - setProjectMemory, setActiveTheme, setCreationMode, }); @@ -4156,6 +4290,23 @@ export function AgentChatWorkspace({ sessionId, taskCenterEmbeddedHomeSessionIds, ]); + const suppressHomeNavbarUtilityActions = + shouldUseBrowserWorkspaceHomeChrome || shouldRenderTaskCenterEmbeddedHome; + + useEffect(() => { + if ( + !suppressHomeNavbarUtilityActions || + !harnessPanelVisible + ) { + return; + } + + setHarnessPanelVisible(false); + }, [ + harnessPanelVisible, + setHarnessPanelVisible, + suppressHomeNavbarUtilityActions, + ]); const shouldHideDetachedTaskCenterTabs = useMemo( () => shouldHideTaskCenterTabsForDetachedSession({ @@ -4327,6 +4478,33 @@ export function AgentChatWorkspace({ layoutMode, taskCenterTabItems, ]); + const browserWorkspaceHomeTabsNode = useMemo(() => { + if (!shouldUseBrowserWorkspaceHomeChrome) { + return null; + } + + const homeTab: TaskCenterTabItem = { + id: "new-task-home", + title: "新对话", + status: "draft", + updatedAt: new Date(newChatAt ?? pageMountedAtRef.current), + isActive: true, + hasUnread: false, + isPinned: false, + closable: false, + }; + + return ( + undefined} + onCloseTask={() => undefined} + onCreateTask={() => { + handleBackHome(); + }} + /> + ); + }, [handleBackHome, newChatAt, shouldUseBrowserWorkspaceHomeChrome]); const handleOpenTaskCenterSkillsPage = useCallback(() => { if (!_onNavigate) { return; @@ -5867,7 +6045,9 @@ export function AgentChatWorkspace({ const isWorkspaceCompactChrome = topBarChrome === "workspace-compact"; const shouldRenderBrandedEmptyState = !showChatLayout && !shouldRenderTaskCenterEmbeddedHome; - const shouldRenderTopBar = !hideTopBar && !shouldRenderBrandedEmptyState; + const shouldRenderTopBar = + !hideTopBar && + (!shouldRenderBrandedEmptyState || shouldUseBrowserWorkspaceHomeChrome); const shouldRenderInlineA2UI = isSpecializedThemeMode; const shouldUseTeamPrimaryChatPanelWidth = @@ -5913,6 +6093,7 @@ export function AgentChatWorkspace({ layoutMode, queuedTurns.length, shouldRenderTaskCenterEmbeddedHome, + shouldUseBrowserWorkspaceHomeChrome, shouldUseCompactGeneralWorkbench, teamDispatchPreviewState, teamSessionRuntime.hasRuntimeSessions, @@ -5958,10 +6139,14 @@ export function AgentChatWorkspace({ const generalWorkbenchHarnessDialog = ( { + setBrowserWorkspaceHintVisible(false); + }, projectRootPath: project?.rootPath || null, projectCharacters: projectMemory?.characters || [], generalCanvasContent: generalCanvasState.content, @@ -6461,7 +6662,9 @@ export function AgentChatWorkspace({ recentSessionActionLabel, handleResumeRecentSession, handleOpenSceneAppsDirectory, - taskCenterTabsNode, + taskCenterTabsNode: + agentEntry === "claw" ? taskCenterTabsNode : browserWorkspaceHomeTabsNode, + suppressNavbarUtilityActions: suppressHomeNavbarUtilityActions, hideHistoryToggle, showChatPanel: effectiveShowChatPanel, topBarChrome, @@ -6469,11 +6672,19 @@ export function AgentChatWorkspace({ fromResources, handleBackHome, handleToggleSidebar, - showHarnessToggle, - navbarHarnessPanelVisible, - harnessPendingCount, - harnessAttentionLevel, - harnessToggleLabel, + showHarnessToggle: + !suppressHomeNavbarUtilityActions && showHarnessToggle, + navbarHarnessPanelVisible: + !suppressHomeNavbarUtilityActions && navbarHarnessPanelVisible, + harnessPendingCount: suppressHomeNavbarUtilityActions + ? 0 + : harnessPendingCount, + harnessAttentionLevel: suppressHomeNavbarUtilityActions + ? "idle" + : harnessAttentionLevel, + harnessToggleLabel: suppressHomeNavbarUtilityActions + ? undefined + : harnessToggleLabel, isAutoRestoringSession: isAutoRestoringSession || taskCenterSessionSwitchPending, sessionId, diff --git a/src/components/agent/chat/agentChatWorkspaceLoader.ts b/src/components/agent/chat/agentChatWorkspaceLoader.ts index 9401010db..11f139c45 100644 --- a/src/components/agent/chat/agentChatWorkspaceLoader.ts +++ b/src/components/agent/chat/agentChatWorkspaceLoader.ts @@ -37,10 +37,7 @@ export async function loadModuleWithRetry( } catch (error) { lastError = error; const nextDelayMs = retryDelaysMs[attempt]; - if ( - nextDelayMs === undefined || - !isRetryableModuleImportFailure(error) - ) { + if (nextDelayMs === undefined || !isRetryableModuleImportFailure(error)) { throw error; } @@ -48,9 +45,7 @@ export async function loadModuleWithRetry( } } - throw lastError instanceof Error - ? lastError - : new Error("模块加载失败"); + throw lastError instanceof Error ? lastError : new Error("模块加载失败"); } export function loadAgentChatWorkspaceModule() { diff --git a/src/components/agent/chat/components/AgentThreadFileCheckpointDialog.tsx b/src/components/agent/chat/components/AgentThreadFileCheckpointDialog.tsx index 0efe35fb6..3671eba8d 100644 --- a/src/components/agent/chat/components/AgentThreadFileCheckpointDialog.tsx +++ b/src/components/agent/chat/components/AgentThreadFileCheckpointDialog.tsx @@ -148,9 +148,13 @@ export function AgentThreadFileCheckpointDialog({ } let cancelled = false; - setListState(createAsyncState("loading")); + setListState( + createAsyncState("loading"), + ); setDetailState(createAsyncState("idle")); - setDiffState(createAsyncState("idle")); + setDiffState( + createAsyncState("idle"), + ); setSelectedCheckpointId(""); void listAgentRuntimeFileCheckpoints({ @@ -165,7 +169,9 @@ export function AgentThreadFileCheckpointDialog({ const defaultCheckpoint = checkpoints.find( (checkpoint) => checkpoint.checkpoint_id === defaultCheckpointId, - ) || checkpoints[0] || null; + ) || + checkpoints[0] || + null; setListState( createAsyncState( @@ -202,8 +208,12 @@ export function AgentThreadFileCheckpointDialog({ } let cancelled = false; - setDetailState(createAsyncState("loading")); - setDiffState(createAsyncState("loading")); + setDetailState( + createAsyncState("loading"), + ); + setDiffState( + createAsyncState("loading"), + ); void Promise.allSettled([ getAgentRuntimeFileCheckpoint({ @@ -291,8 +301,8 @@ export function AgentThreadFileCheckpointDialog({ 文件快照详情 - 当前弹窗直接消费 runtime file checkpoint 的 current 主链: - `list / detail / diff`。 + 当前弹窗直接消费 runtime file checkpoint 的 current 主链: `list / + detail / diff`。 session={sessionId} @@ -617,7 +627,11 @@ export function AgentThreadFileCheckpointDialog({
- diff --git a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx index 21beefa0f..9966c278c 100644 --- a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx +++ b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx @@ -97,7 +97,9 @@ function DetailPanel(props: { children?: ReactNode; }) { return ( -
+
{props.title}
diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx index 04a5420b3..113b41d5a 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -567,9 +567,7 @@ describe("AgentThreadReliabilityPanel", () => { }); expect(document.body.textContent).toContain("文件快照详情"); expect(document.body.textContent).toContain("缺少 reviewer 字段"); - expect(document.body.textContent).toContain( - "新增可靠性面板快照详情入口", - ); + expect(document.body.textContent).toContain("新增可靠性面板快照详情入口"); expect(document.body.textContent).toContain("artifact-document:req-2:v7"); expect(document.body.textContent).toContain( ".lime/artifacts/thread-file/persistence-map.artifact.json", diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index e7fa28e12..0270fa333 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -999,8 +999,11 @@ export const AgentThreadReliabilityPanel: React.FC< const oemPolicy = threadRead?.oem_policy || null; const fallbackChain = Array.isArray(threadRead?.fallback_chain) ? threadRead?.fallback_chain || [] - : Array.isArray((runtimeSummary as { fallbackChain?: unknown } | null)?.fallbackChain) - ? (((runtimeSummary as { fallbackChain?: string[] | null }).fallbackChain || []) as string[]) + : Array.isArray( + (runtimeSummary as { fallbackChain?: unknown } | null)?.fallbackChain, + ) + ? (((runtimeSummary as { fallbackChain?: string[] | null }) + .fallbackChain || []) as string[]) : []; const runtimeDecisionReason = threadRead?.decision_reason || @@ -1405,7 +1408,9 @@ export const AgentThreadReliabilityPanel: React.FC< {runtimeDecisionReason || fallbackChain.length > 0 || oemPolicy ? (
-
当前路由事实
+
+ 当前路由事实 +
{runtimeDecisionReason ? (
@@ -1437,11 +1442,17 @@ export const AgentThreadReliabilityPanel: React.FC< OEM 额度偏低 ) : null} - {((oemPolicy as { defaultModel?: string | null }).defaultModel || - (oemPolicy as { selectedModel?: string | null }).selectedModel) ? ( + {(oemPolicy as { defaultModel?: string | null }).defaultModel || + (oemPolicy as { selectedModel?: string | null }) + .selectedModel ? ( - OEM 模型 {((oemPolicy as { defaultModel?: string | null }).defaultModel || - (oemPolicy as { selectedModel?: string | null }).selectedModel)!} + OEM 模型{" "} + { + ((oemPolicy as { defaultModel?: string | null }) + .defaultModel || + (oemPolicy as { selectedModel?: string | null }) + .selectedModel)! + } ) : null}
diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 7299e7f2a..0266a7f4a 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -905,7 +905,9 @@ describe("AgentThreadTimeline", () => { summary?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - const markdownBlocks = container.querySelectorAll('[data-testid="markdown-renderer"]'); + const markdownBlocks = container.querySelectorAll( + '[data-testid="markdown-renderer"]', + ); expect(markdownBlocks[0]?.textContent).toBe( "目录也不存在。可能整个 .lime 目录都不存在。", ); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index d632ca812..397fdc2fa 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -834,7 +834,8 @@ function renderGroupItemDetails( if (item.type === "subagent_activity") { const subagentSessionId = item.session_id?.trim(); - const displayTitle = resolveInternalImageTaskDisplayName(item.title) || "子任务"; + const displayTitle = + resolveInternalImageTaskDisplayName(item.title) || "子任务"; return ( { .querySelector('[data-testid="canvas-workbench-shell"]') ?.getAttribute("data-layout-mode"), ).toBe("split"); + expect( + container.querySelector('[data-testid="canvas-workbench-shell"]') + ?.className, + ).toContain("lime-workbench-theme-scope"); + expect( + container.querySelector('[data-testid="canvas-workbench-shell"]') + ?.className, + ).toContain("lime-workbench-surface-scope"); + expect( + container.querySelector('[data-testid="canvas-workbench-shell"]') + ?.className, + ).toContain("bg-[color:var(--lime-surface)]"); expect( container.querySelector( 'button[aria-label="切换画布标签-Session · Main"]', @@ -691,7 +703,8 @@ describe("CanvasWorkbenchLayout", () => { workspaceRoot: "/workspace", workspaceUnavailable: false, defaultPreview: { - selectionKey: "default-preview:exports/x-article-export/latest/index.md", + selectionKey: + "default-preview:exports/x-article-export/latest/index.md", title: "index.md", content: "# 导出结果\n\n这是正文。", filePath: "exports/x-article-export/latest/index.md", @@ -735,11 +748,13 @@ describe("CanvasWorkbenchLayout", () => { expect(workspaceButtons).toContain("展开目录-skills"); expect(workspaceButtons).toContain("展开目录-images"); - const indexButtonPosition = workspaceButtons.indexOf("选择工作区文件-index.md"); + const indexButtonPosition = + workspaceButtons.indexOf("选择工作区文件-index.md"); const agentsButtonPosition = workspaceButtons.indexOf("选择工作区文件-Agents.md"); - const manifestButtonPosition = - workspaceButtons.indexOf("选择工作区文件-manifest.json"); + const manifestButtonPosition = workspaceButtons.indexOf( + "选择工作区文件-manifest.json", + ); expect(indexButtonPosition).toBeGreaterThanOrEqual(0); expect(agentsButtonPosition).toBeGreaterThan(indexButtonPosition); @@ -1093,7 +1108,9 @@ describe("CanvasWorkbenchLayout", () => { await flushEffects(); expect( - container.querySelector('[data-testid="canvas-workbench-panel-document"]'), + container.querySelector( + '[data-testid="canvas-workbench-panel-document"]', + ), ).not.toBeNull(); expect( container.querySelector('[data-testid="canvas-workbench-panel-session"]'), @@ -1144,7 +1161,9 @@ describe("CanvasWorkbenchLayout", () => { await flushEffects(); expect( - harness.container.querySelector('[data-testid="canvas-workbench-panel-session"]'), + harness.container.querySelector( + '[data-testid="canvas-workbench-panel-session"]', + ), ).not.toBeNull(); expect( harness.container.querySelector('[data-testid="session-view-panel"]'), @@ -1289,7 +1308,8 @@ describe("CanvasWorkbenchLayout", () => { workspaceRoot: "/workspace", workspaceUnavailable: false, defaultPreview: { - selectionKey: "default-preview:exports/x-article-export/latest/index.md", + selectionKey: + "default-preview:exports/x-article-export/latest/index.md", title: "index.md", content: "# 导出结果\n\n这是正文。", filePath: "exports/x-article-export/latest/index.md", @@ -1400,9 +1420,7 @@ describe("CanvasWorkbenchLayout", () => { container.querySelector('[data-testid="team-panel"]'), ).not.toBeNull(); expect( - container.querySelector( - 'button[aria-label="切换画布标签-生成"]', - ), + container.querySelector('button[aria-label="切换画布标签-生成"]'), ).not.toBeNull(); expect(renderPreview).toHaveBeenCalled(); expect(renderTeamPanel).toHaveBeenCalled(); diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index 7eb50c3e1..65c739e94 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -258,22 +258,22 @@ export interface CanvasWorkbenchLayoutProps { } const WORKBENCH_SHELL_CLASSNAME = - "rounded-[28px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5"; + "rounded-[28px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] shadow-sm shadow-slate-950/5"; const WORKBENCH_PANEL_CLASSNAME = - "rounded-[24px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5"; + "rounded-[24px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] shadow-sm shadow-slate-950/5"; const WORKBENCH_MUTED_PANEL_CLASSNAME = - "rounded-[24px] border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-500"; + "rounded-[24px] border border-dashed border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] px-4 py-6 text-sm text-[color:var(--lime-text-muted)]"; const WORKBENCH_BUTTON_CLASSNAME = - "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900"; + "border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] text-[color:var(--lime-text)] hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-surface)] hover:text-[color:var(--lime-text-strong)]"; const WORKBENCH_ACTIVE_BUTTON_CLASSNAME = - "border-slate-300 bg-white text-slate-950 shadow-sm shadow-slate-950/5"; + "border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-surface)] text-[color:var(--lime-text-strong)] shadow-sm shadow-slate-950/5"; const WORKBENCH_GHOST_BUTTON_CLASSNAME = - "border-slate-200/80 text-slate-500 hover:bg-slate-50 hover:text-slate-900"; + "border-[color:var(--lime-surface-border)] text-[color:var(--lime-text-muted)] hover:bg-[color:var(--lime-surface-soft)] hover:text-[color:var(--lime-text-strong)]"; const STACKED_LAYOUT_BREAKPOINT = 1040; @@ -341,7 +341,10 @@ function resolveSavedContentBundleRoot( workspaceRoot: string | null | undefined, selectionPath: string | null | undefined, ): string | null { - const relativePath = resolveWorkspaceRelativePath(workspaceRoot, selectionPath); + const relativePath = resolveWorkspaceRelativePath( + workspaceRoot, + selectionPath, + ); if (!relativePath) { return null; } @@ -415,7 +418,10 @@ function sortWorkspaceListingEntries( if (entry.isDir && normalizedName === "skills") { return 2; } - if (entry.isDir && (normalizedName === "images" || normalizedName === "assets")) { + if ( + entry.isDir && + (normalizedName === "images" || normalizedName === "assets") + ) { return 3; } if (entry.isDir) { @@ -589,10 +595,7 @@ function buildEntries( }), subtitle: taskFile.name, filePath: taskFile.name, - absolutePath: resolveAbsoluteWorkspacePath( - workspaceRoot, - taskFile.name, - ), + absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, taskFile.name), previewText: taskFile.content?.trim().slice(0, 180), createdAt: taskFile.updatedAt, badgeLabel: taskFile.type === "document" ? "文档" : undefined, @@ -612,7 +615,10 @@ function buildEntries( .flatMap((artifact) => { const filePath = resolveArtifactProtocolFilePath(artifact); const writePhase = resolveArtifactWritePhase(artifact); - const absolutePath = resolveAbsoluteWorkspacePath(workspaceRoot, filePath); + const absolutePath = resolveAbsoluteWorkspacePath( + workspaceRoot, + filePath, + ); const pathKey = normalizePath(absolutePath || filePath || ""); if (pathKey) { if (taskFilePathSet.has(pathKey) || seenArtifactPaths.has(pathKey)) { @@ -621,25 +627,27 @@ function buildEntries( seenArtifactPaths.add(pathKey); } - return [{ - key: `artifact:${artifact.id}`, - source: "artifact", - artifact, - title: resolveContentPostArtifactDisplayTitle({ - title: artifact.title, + return [ + { + key: `artifact:${artifact.id}`, + source: "artifact", + artifact, + title: resolveContentPostArtifactDisplayTitle({ + title: artifact.title, + filePath, + metadata: artifact.meta, + }), + subtitle: filePath, filePath, - metadata: artifact.meta, - }), - subtitle: filePath, - filePath, - absolutePath, - previewText: resolveArtifactPreviewText(artifact), - createdAt: artifact.updatedAt || artifact.createdAt, - badgeLabel: writePhase - ? formatArtifactWritePhaseLabel(writePhase) - : undefined, - kindLabel: "产物", - }]; + absolutePath, + previewText: resolveArtifactPreviewText(artifact), + createdAt: artifact.updatedAt || artifact.createdAt, + badgeLabel: writePhase + ? formatArtifactWritePhaseLabel(writePhase) + : undefined, + kindLabel: "产物", + }, + ]; }); if (isDocumentCanvasState(canvasState)) { @@ -1403,7 +1411,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ resolveSavedContentBundleRoot( workspaceRoot, documentContext?.selectionPath || sessionContext?.selectionPath, - ) || workspaceRoot || null, + ) || + workspaceRoot || + null, [ documentContext?.selectionPath, sessionContext?.selectionPath, @@ -1580,9 +1590,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ { key: "team" as const, label: - teamView.tabLabel?.trim() || - teamView.title?.trim() || - "生成", + teamView.tabLabel?.trim() || teamView.title?.trim() || "生成", badge: teamView.tabBadge?.trim() || teamView.triggerState?.label?.trim() || @@ -1958,7 +1966,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
@@ -2028,8 +2039,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ return (
- {teamView?.panelCopy?.emptyText || - "当前没有可展示的生成结果。"} + {teamView?.panelCopy?.emptyText || "当前没有可展示的生成结果。"}
); @@ -2174,18 +2184,20 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ data-testid="canvas-workbench-shell" data-layout-mode={isStackedLayout ? "stacked" : "split"} className={cn( + "lime-workbench-theme-scope", + "lime-workbench-surface-scope", WORKBENCH_SHELL_CLASSNAME, "relative flex h-full min-h-0 flex-col overflow-hidden", )} > -
+
-
+
{primaryTabs.map((tab) => renderTopTab({ @@ -2197,7 +2209,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ )} {documentTabs.length > 0 ? ( -
+
) : null} {documentTabs.map((tab) => @@ -2255,15 +2267,15 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
{activeTab === "workspace" ? renderWorkspacePanel() : activeTab === "team" - ? renderTeamPanel() - : activeTab === "session" - ? renderSessionPanel() - : renderDocumentPanel()} + ? renderTeamPanel() + : activeTab === "session" + ? renderSessionPanel() + : renderDocumentPanel()}
); diff --git a/src/components/agent/chat/components/ChatNavbar.test.tsx b/src/components/agent/chat/components/ChatNavbar.test.tsx index 23698ce99..e966c0b72 100644 --- a/src/components/agent/chat/components/ChatNavbar.test.tsx +++ b/src/components/agent/chat/components/ChatNavbar.test.tsx @@ -236,10 +236,48 @@ describe("ChatNavbar", () => { ) as HTMLElement | null; expect(workspaceBar?.style.zIndex).toBe("8"); - expect(workspaceShell?.className).toContain("h-6"); - expect(workspaceShell?.className).toContain("min-w-[132px]"); - expect(workspaceShell?.className).toContain("max-w-[196px]"); - expect(workspaceShell?.style.backgroundColor).toBe("rgb(248, 252, 249)"); + expect(workspaceShell?.className).toContain("h-9"); + expect(workspaceShell?.className).toContain("min-w-[148px]"); + expect(workspaceShell?.className).toContain("max-w-[224px]"); + expect(workspaceShell?.className).toContain( + "bg-[color:var(--lime-chrome-tab-active-surface)]", + ); + expect(workspaceShell?.querySelectorAll("span[aria-hidden]")).toHaveLength( + 2, + ); + }); + + it("任务中心工作区提示应浮在加号上方,并在点击加号时关闭", () => { + const onDismissWorkspaceHint = vi.fn(); + const container = renderChatNavbar({ + contextVariant: "task-center", + projectId: "project-1", + workspaceType: "general", + workspaceHintVisible: true, + workspaceHintMessage: "在这里切换或新建工作区", + onDismissWorkspaceHint, + }); + + const hint = container.querySelector( + '[data-testid="task-center-workspace-hint"]', + ) as HTMLElement | null; + const menuTrigger = container.querySelector( + 'button[aria-label="展开工作区菜单"]', + ) as HTMLButtonElement | null; + + expect(hint).not.toBeNull(); + expect(hint?.textContent).toContain("在这里切换或新建工作区"); + expect(hint?.className).toContain("bottom-full"); + expect(hint?.className).toContain( + "border-[color:var(--lime-surface-border)]", + ); + expect(hint?.className).not.toContain("sky"); + + act(() => { + menuTrigger?.click(); + }); + + expect(onDismissWorkspaceHint).toHaveBeenCalledTimes(1); }); it("任务中心顶栏应保留 Harness 状态入口", () => { @@ -317,8 +355,11 @@ describe("ChatNavbar", () => { ) as HTMLButtonElement | null; expect(button).not.toBeNull(); - expect(button?.className).toContain("border-amber-300"); - expect(button?.className).toContain("text-amber-800"); + expect(button?.className).toContain( + "border-[color:var(--lime-warning-border)]", + ); + expect(button?.className).toContain("text-[color:var(--lime-warning)]"); + expect(button?.className).not.toContain("amber-300"); }); it("压缩上下文运行中时应禁用顶栏操作", () => { diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index 06fb9d0ff..515ba0c37 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -14,7 +14,10 @@ import { Button } from "@/components/ui/button"; import { ProjectSelector } from "@/components/projects/ProjectSelector"; import { cn } from "@/lib/utils"; import { Navbar } from "../styles"; -import { TASK_CENTER_CHROME_SURFACE } from "../workspace/taskCenterChromeTokens"; +import { + TASK_CENTER_CHROME_ACTIVE_TAB, + TASK_CENTER_CHROME_RAIL_SURFACE, +} from "../workspace/taskCenterChromeTokens"; interface ChatNavbarProps { isRunning: boolean; @@ -36,6 +39,10 @@ interface ChatNavbarProps { projectId?: string | null; onProjectChange?: (projectId: string) => void; workspaceType?: string; + deferWorkspaceListLoad?: boolean; + workspaceHintMessage?: string; + workspaceHintVisible?: boolean; + onDismissWorkspaceHint?: () => void; showHarnessToggle?: boolean; harnessPanelVisible?: boolean; onToggleHarnessPanel?: () => void; @@ -48,33 +55,34 @@ interface ChatNavbarProps { } const toolbarGroupClassName = - "flex items-center rounded-[20px] border border-slate-200/80 bg-white/90 p-1.5 shadow-sm shadow-slate-950/5 backdrop-blur-sm"; + "flex items-center rounded-[20px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-subtle)] p-1.5 shadow-sm shadow-slate-950/5 backdrop-blur-sm"; -const toolbarDividerClassName = "mx-1.5 h-6 w-px shrink-0 bg-slate-200/80"; +const toolbarDividerClassName = + "mx-1.5 h-6 w-px shrink-0 bg-[color:var(--lime-surface-border)]"; const toolbarEmbeddedButtonClassName = "h-9 rounded-2xl border border-transparent px-3.5 text-xs shadow-none"; const toolbarGhostIconButtonClassName = - "h-9 w-9 rounded-2xl text-slate-500 hover:bg-slate-100 hover:text-slate-900"; + "h-9 w-9 rounded-2xl text-[color:var(--lime-text-muted)] hover:bg-[color:var(--lime-surface-hover)] hover:text-[color:var(--lime-text)]"; const toolbarTextButtonClassName = - "gap-1.5 text-slate-700 hover:bg-white hover:text-slate-900"; + "gap-1.5 text-[color:var(--lime-text)] hover:bg-[color:var(--lime-surface)] hover:text-[color:var(--lime-text-strong)]"; const taskCenterTopRailClassName = - "relative flex h-7 w-full items-start overflow-visible bg-transparent pl-5 pr-3 pt-[3px]"; + "relative flex h-[42px] w-full items-end overflow-visible bg-[color:var(--lime-chrome-rail)] px-4 pt-1"; const taskCenterWorkspaceTabClassName = - "relative z-20 flex h-6 min-w-[132px] max-w-[196px] items-start rounded-t-[10px] border border-b-0 border-slate-200/70 px-2 pt-px text-sm font-medium text-slate-700 shadow-[0_-1px_0_rgba(255,255,255,0.86)] dark:border-white/10 dark:bg-slate-900 dark:text-slate-300"; + "relative z-20 flex h-9 min-w-[148px] max-w-[224px] items-center rounded-t-[18px] rounded-b-none border border-b-0 border-[color:var(--lime-chrome-border)] bg-[color:var(--lime-chrome-tab-active-surface)] px-2 text-sm font-medium text-[color:var(--lime-chrome-text)] shadow-[inset_0_1px_0_rgba(255,255,255,0.72)] dark:border-white/10 dark:bg-slate-900 dark:text-slate-300"; const taskCenterWorkspaceTabCurveClassName = - "pointer-events-none absolute bottom-0 h-3.5 w-3.5 bg-transparent"; + "pointer-events-none absolute bottom-0 h-[18px] w-[18px] bg-transparent"; const taskCenterIconButtonClassName = - "h-7 w-7 rounded-[10px] border border-transparent bg-transparent text-slate-500 shadow-none transition-[background-color,color] hover:bg-white/70 hover:text-slate-900"; + "h-7 w-7 rounded-[12px] border border-transparent bg-transparent text-[color:var(--lime-chrome-muted)] shadow-none transition-[background-color,color] hover:bg-[color:var(--lime-chrome-tab-hover)] hover:text-[color:var(--lime-chrome-text)]"; const taskCenterPillButtonClassName = - "h-7 rounded-[10px] border border-transparent bg-transparent px-2 text-[11px] font-medium text-slate-700 shadow-none transition-[background-color,color] hover:bg-white/70 hover:text-slate-900"; + "h-7 rounded-[12px] border border-transparent bg-transparent px-2 text-[11px] font-medium text-[color:var(--lime-chrome-text)] shadow-none transition-[background-color,color] hover:bg-[color:var(--lime-chrome-tab-hover)] hover:text-[color:var(--lime-text-strong)]"; export const ChatNavbar: React.FC = ({ isRunning: _isRunning, @@ -96,6 +104,10 @@ export const ChatNavbar: React.FC = ({ projectId = null, onProjectChange, workspaceType, + deferWorkspaceListLoad, + workspaceHintMessage, + workspaceHintVisible = false, + onDismissWorkspaceHint, showHarnessToggle = false, harnessPanelVisible = false, onToggleHarnessPanel, @@ -106,7 +118,8 @@ export const ChatNavbar: React.FC = ({ contextCompactionRunning = false, onCompactContext, }) => { - const [workspaceSelectorOpen, setWorkspaceSelectorOpen] = React.useState(false); + const [workspaceSelectorOpen, setWorkspaceSelectorOpen] = + React.useState(false); const isTaskCenterChrome = contextVariant === "task-center"; const isWorkspaceCompact = chrome === "workspace-compact"; const effectiveCollapseChrome = collapseChrome && !isTaskCenterChrome; @@ -114,7 +127,7 @@ export const ChatNavbar: React.FC = ({ toolbarGroupClassName, (isWorkspaceCompact || effectiveCollapseChrome) && "rounded-[18px] p-1", effectiveCollapseChrome && - "border-slate-200/70 bg-white shadow-sm shadow-slate-950/4 backdrop-blur-0", + "border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] shadow-sm shadow-slate-950/5 backdrop-blur-0", ); const dividerClassName = cn( toolbarDividerClassName, @@ -122,7 +135,8 @@ export const ChatNavbar: React.FC = ({ ); const embeddedButtonClassName = cn( toolbarEmbeddedButtonClassName, - (isWorkspaceCompact || effectiveCollapseChrome) && "h-8 rounded-[18px] px-3", + (isWorkspaceCompact || effectiveCollapseChrome) && + "h-8 rounded-[18px] px-3", ); const ghostIconButtonClassName = cn( toolbarGhostIconButtonClassName, @@ -145,6 +159,8 @@ export const ChatNavbar: React.FC = ({ ? "min-w-[184px] max-w-[248px]" : "min-w-[196px] max-w-[280px]"; const showEntryContext = Boolean(entryContextLabel); + const shouldDeferWorkspaceListLoad = + deferWorkspaceListLoad ?? isTaskCenterChrome; if (isTaskCenterChrome) { return ( @@ -161,33 +177,29 @@ export const ChatNavbar: React.FC = ({ zIndex: 8, }} > -
+
-
+
+ {workspaceHintVisible && workspaceHintMessage ? ( +
+ {workspaceHintMessage} + +
+ ) : null}
-
+
{showContextCompactionAction ? ( @@ -251,10 +293,11 @@ export const ChatNavbar: React.FC = ({ className={cn( taskCenterPillButtonClassName, "gap-1 px-2.5", - harnessPanelVisible && "bg-white text-slate-900", + harnessPanelVisible && + "bg-[color:var(--lime-chrome-tab-active-surface)] text-[color:var(--lime-text)]", harnessAttentionLevel === "warning" && !harnessPanelVisible && - "bg-amber-50/90 text-amber-800 hover:bg-amber-100 hover:text-amber-900", + "bg-[color:var(--lime-warning-soft)] text-[color:var(--lime-warning)] hover:bg-[color:var(--lime-warning-soft)] hover:text-[color:var(--lime-warning)]", )} onClick={onToggleHarnessPanel} aria-label={ @@ -268,7 +311,7 @@ export const ChatNavbar: React.FC = ({ {harnessToggleLabel} {harnessPendingCount > 0 ? ( - + {harnessPendingCount > 99 ? "99+" : harnessPendingCount} ) : null} @@ -396,7 +439,7 @@ export const ChatNavbar: React.FC = ({ )} >
- + {entryContextLabel} {!isWorkspaceCompact && entryContextHint ? ( @@ -424,6 +467,8 @@ export const ChatNavbar: React.FC = ({ enableManagement={workspaceType === "general"} density="compact" chrome="embedded" + deferProjectListLoad={shouldDeferWorkspaceListLoad} + skipDefaultWorkspaceReadyCheck={shouldDeferWorkspaceListLoad} className={compactProjectSelectorClassName} /> {onToggleSettings ? ( @@ -494,10 +539,11 @@ export const ChatNavbar: React.FC = ({ className={cn( embeddedButtonClassName, toolbarTextButtonClassName, - harnessPanelVisible && "bg-slate-100 text-slate-900", + harnessPanelVisible && + "bg-[color:var(--lime-surface-hover)] text-[color:var(--lime-text)]", harnessAttentionLevel === "warning" && !harnessPanelVisible && - "border-amber-300 bg-amber-50/75 text-amber-800 hover:bg-amber-100 hover:text-amber-900", + "border-[color:var(--lime-warning-border)] bg-[color:var(--lime-warning-soft)] text-[color:var(--lime-warning)] hover:bg-[color:var(--lime-warning-soft)] hover:text-[color:var(--lime-warning)]", )} onClick={onToggleHarnessPanel} aria-label={ @@ -511,7 +557,7 @@ export const ChatNavbar: React.FC = ({ {harnessToggleLabel} {harnessPendingCount > 0 ? ( - + {harnessPendingCount > 99 ? "99+" : harnessPendingCount} ) : null} diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index 6416ab93a..57f6dfe83 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { ArrowUpLeft, BrainCircuit, @@ -720,25 +726,26 @@ export const ChatSidebar: React.FC = ({ const hasAnyTasks = topics.length > 0; const hasFilteredResults = filteredTaskItems.length > 0; const taskHeadingLabel = isTaskCenter ? "最近对话" : "任务"; - const taskHeadingHint = - isTaskCenter ? "继续最近对话,待处理会话会优先显示在前面。" : null; - const searchPlaceholder = - isTaskCenter ? "搜索对话标题或摘要" : "搜索任务标题或摘要"; + const taskHeadingHint = isTaskCenter + ? "继续最近对话,待处理会话会优先显示在前面。" + : null; + const searchPlaceholder = isTaskCenter + ? "搜索对话标题或摘要" + : "搜索任务标题或摘要"; const newChatLabel = isTaskCenter ? "新建对话" : "新建任务"; const allTasksLabel = isTaskCenter ? "全部对话" : "全部任务"; const activeTasksLabel = isTaskCenter ? "待继续" : "仅看进行中"; - const emptyStateTitle = - isTaskCenter ? "还没有最近对话" : "还没有任务"; - const emptyStateDescription = - isTaskCenter - ? "从“新建对话”开始后,最近对话会自动出现在这里。" - : "从“新建任务”开始输入需求,创建后会出现在这里。"; + const emptyStateTitle = isTaskCenter ? "还没有最近对话" : "还没有任务"; + const emptyStateDescription = isTaskCenter + ? "从“新建对话”开始后,最近对话会自动出现在这里。" + : "从“新建任务”开始输入需求,创建后会出现在这里。"; const noResultsTitle = isTaskCenter ? "没有匹配的对话" : "没有匹配的任务"; const noResultsDescription = isTaskCenter ? "试试搜索对话标题、摘要或状态关键词。" : "试试搜索标题、执行摘要或状态关键词。"; - const olderSectionMoreLabel = - isTaskCenter ? "查看更多归档对话" : "查看更多历史任务"; + const olderSectionMoreLabel = isTaskCenter + ? "查看更多归档对话" + : "查看更多历史任务"; const taskCountLabel = searchKeyword.trim() ? `${filteredTaskItems.length} 个结果` : `${topics.length} 个${isTaskCenter ? "对话" : "任务"}`; @@ -987,10 +994,7 @@ export const ChatSidebar: React.FC = ({ }; return ( -
+ +
+
+
+ + {hasApiKey ? ( + 已配置 + ) : null} +
+
+ { + if (!apiKeyDirty && apiKeyMask) { + setApiKeyDraft(""); + setApiKeyDirty(true); + } + }} + onChange={(event) => { + setApiKeyDirty(true); + setApiKeyDraft(event.target.value); + }} + placeholder={ + apiKeyRequired ? "输入 API 密钥" : "本地服务可留空" + } + className="h-12 rounded-[18px] border-slate-200 bg-white px-4 pr-11" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + disabled={loading || testingConnection || fetchingModels} + data-testid="provider-api-key-input" + /> + +
+ {accessHelp.keylessHint ? ( +

+ {accessHelp.keylessHint} +

+ ) : null} +
+ +
+
+
+ +

+ 只使用接口返回或你手动添加的模型,不再显示本地兜底模型。 +

+
+ +
+ + {!modelAutoFetchCapability.supported && + modelAutoFetchCapability.unsupportedReason ? ( +

+ {modelAutoFetchCapability.unsupportedReason} +

+ ) : null} + + {modelFetchStatus ? ( +
+ {getStatusIcon(modelFetchStatus.tone)} + {modelFetchStatus.message} +
+ ) : null} + + {suggestedApiModels.length > 0 ? ( +
+
+ 接口模型(点击添加) +
+
+ {suggestedApiModels.map((modelId) => ( + + ))}
- - {connectionReady ? "可测试" : "待就绪"} -
+ ) : null} -
- 默认模型: - - {defaultModel ?? - (requiresLiveModelTruth ? "待读取真实目录" : "未设置")} - -
+
+ -
- 0 ? ( +
+ {modelList.map((modelId, index) => ( +
+ :: + {index === 0 ? ( + + 主模型 + + ) : null} + + {modelId} + + {index > 0 ? ( + + ) : null} + +
+ ))} +
+ ) : ( +
+ 暂无模型。请从接口获取后选择,或手动添加模型 ID。 +
+ )} + +
+ setModelDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + void handleAddModelDraft(); + } + }} + placeholder="输入模型 ID,按 Enter 添加" + className="h-11 rounded-[16px] border-slate-200 bg-white px-4 normal-case" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + disabled={loading} + data-testid="model-draft-input" />
+
+
- {connectionBlockHint ? ( -

- {connectionBlockHint} -

- ) : null} - +
+ + + {!canTestConnection && !testingConnection ? ( +

+ {modelList.length === 0 + ? "先添加一个模型,再测试连接。" + : apiKeyRequired && !hasApiKey && !canUseDraftApiKey + ? "先填写 API 密钥,再测试连接。" + : "当前暂不可测试。"} +

+ ) : null} + + {connectionStatus ? ( +
+ {getStatusIcon(connectionStatus.tone)} + {connectionStatus.message} +
+ ) : null}
-
+
- - - - - 对话测试 - - 发送一条最小对话请求,直接查看返回内容或原始错误,便于排查模型、权限或路由问题。 - - - -
-