diff --git a/.monkeycode/MEMORY.md b/.monkeycode/MEMORY.md new file mode 100644 index 00000000..524807bf --- /dev/null +++ b/.monkeycode/MEMORY.md @@ -0,0 +1,28 @@ +# User Instruction Memory + +This file records user instructions, preferences, and teachings for reference in future interactions. + +## Format + +[User Instruction Summary] +- Date: [YYYY-MM-DD] +- Context: [Mentioned scenario or time] +- Instructions: + - [Content] + +## Deduplication Strategy +- Before adding a new entry, check for similar or identical instructions. +- If a duplicate is found, skip the new entry or merge it with the existing one. + +## Entries + +[仓库同步关系] +- Date: 2026-09-12 +- Context: 用户要求把上游 jieapi/AiCode v1.10.1 的最新源码同步进本仓库 +- Category: Operations & Deployment +- Instructions: + - 上游主仓库(用户自己的,带正式 v* 发版 tag):https://github.com/jieapi/AiCode,remote 名 `upstream` + - 本工作 fork(推送目标):https://github.com/jiangyuyutan-oss/AiCode,remote 名 `origin` + - 同步上游更新的流程:`git fetch upstream main && git merge upstream/main`(历史已于 16d3c9a 嫁接,此后是常规合并) + - 上游 v* tag 已冻结其迁移文件(如 v1.10.1 冻结 8..42);本地这些文件必须与上游逐字节一致,新增迁移只能用更大编号 + - `gh` CLI 可用 git credential helper 的 token 认证:`git credential fill` 取 password 后 `gh auth login --with-token`(token 不得出现在任何输出中) diff --git a/.monkeycode/docs/ARCHITECTURE.md b/.monkeycode/docs/ARCHITECTURE.md new file mode 100644 index 00000000..15a0f24f --- /dev/null +++ b/.monkeycode/docs/ARCHITECTURE.md @@ -0,0 +1,336 @@ +# 系统架构 + +## 概述 + +AiCode 是一款运行在 Android 手机上的 AI 编程工具,将大语言模型与本地 Linux 开发环境深度集成。它面向需要在移动设备上完成编码、调试与项目管理的开发者,使 AI 能直接读写文件、执行 Shell 命令、运行构建工具,并支持以远程 SSH 服务器作为执行后端,把手机变成远程项目的移动工作站。 + +系统的核心架构特征: + +- **单 Activity + Compose 的 Kotlin 应用**:功能按 feature 分层(`agent` / `terminal` / `workspace` / `editor` / `git` / `settings` / `backup` / `credentials` / `onboarding`),每个 feature 内部再分 `data` / `domain` / `presentation` 三层,依赖注入统一由 Hilt 管理。 +- **本地与远程双执行后端**:本地模式基于 Termux 组件与 PRoot 运行 Alpine Linux 容器;远程模式通过 sshj 以 SSH exec channel 执行命令、shell channel 驱动终端。两者共享同一套抽象接口(`CommandEngine` / `FileAccessProvider` / `TerminalSessionProvider`),由委托层按执行模式运行时分发。 +- **AI Agent 引擎**:兼容 OpenAI / Anthropic / Gemini 三类协议,内置 19 个工具(文件读写、Shell 执行、搜索、待办、子代理派生等),支持 MCP 协议动态扩展工具、三种权限模式(BUILD / PLAN / AUTO)、检查点回滚与子代理并行。 +- **数据库与资产随代码演进**:Room 数据库(版本 52)采用「文件式 SQL 迁移 + AutoMigration」双轨机制,45+ 个迁移脚本由 `MigrationLoader` 在启动时自动执行并对账。 + +## 技术栈 + +**语言与运行时** +- Kotlin(JVM target 17,KSP 编译期代码生成) +- Java(Termux 派生模块 `terminal-emulator` / `terminal-view`) +- Android:minSdk 26,targetSdk 28(锁定,见下文「关键架构决策」),compileSdk 36 + +**框架** +- UI:Jetpack Compose(Material 3,BOM 2026.01.00)、Navigation Compose、Kyant0/backdrop 1.0.6(液体玻璃材质:blur/lens/AGSL 水波折射,三档磨砂/水玻璃/液体玻璃) +- 依赖注入:Hilt(Dagger 2.56.1) +- 持久化:Room 2.7.1(主数据库 `aicode_agent_db`)+ DataStore Preferences(20+ 个分域设置仓库) +- 网络:Retrofit 2.11 + OkHttp 4.12(LLM API)、sshj 0.38(SSH/SFTP)、commons-net + ftpserver-core(FTP 同步与内置 FTP 服务端) +- 编辑器:sora-editor 0.24.6(TextMate 语法高亮) +- 终端:Termux `terminal-emulator` / `terminal-view`(本地模块,经 JitPack 坐标对齐) +- 容器:PRoot(预编译 .so 走 jniLibs)+ Alpine rootfs(assets 内置 tar.gz)+ commons-compress(解压) + +**数据存储** +- Room 数据库 `aicode_agent_db`:会话、消息、待办、检查点、LLM 调用记录、AI Provider、远程连接等 +- DataStore:执行模式、主题、语言、代理、保活、编辑器设置等 20+ 分域偏好 +- 文件系统:`filesDir/projects/`(工作区项目)、`filesDir/checkpoints/`(文件快照)、`filesDir/aicode/`(容器初始化资源与凭据文件) + +**基础设施** +- CI/CD:GitHub Actions —— `ci.yml`(push/PR 冒烟构建 + 单测 + 迁移对账)、`android-release.yml`(v* Tag 触发三 flavor 签名 APK 发版)、`sync-gitcode.yml`(GitCode 镜像同步) +- 分发:GitHub Release(APK 直装,不上 Google Play);F-Droid 可复现构建兼容 + +**外部服务** +- OpenAI / Anthropic / Gemini 三类 LLM API(含兼容网关,可自定义 baseUrl) +- MCP 服务器(本地 stdio 进程 / 远程 Streamable HTTP + SSE) +- models.dev(内置模型快照 `api.official.json` 的数据源,仅打 Tag 前手动刷新) + +## 项目结构 + +``` +project-root/ +├── app/ # 主应用模块(全部业务代码) +│ ├── src/main/java/com/aicode/ +│ │ ├── AIEditorApp.kt # @HiltAndroidApp Application,全局初始化 +│ │ ├── MainActivity.kt # 唯一 Activity,NavHost + 侧边栏 + 双栏布局 +│ │ ├── WorkbenchPane.kt # 大屏右栏容器(编辑器/终端/Git 三态切换) +│ │ ├── CrashActivity.kt # 崩溃详情全屏页 +│ │ ├── core/ # 跨 feature 基础设施 +│ │ │ ├── db/ # MigrationLoader + SqlScriptSplitter(文件式迁移) +│ │ │ ├── net/ # AppProxy(全局代理 + provider 级代理分派) +│ │ │ ├── theme/ # Compose 主题与配色预设 +│ │ │ ├── ui/ # 通用 Compose 组件库 +│ │ │ └── util/ # FileLogger、AILogger、LineDiff、GitIgnoreMatcher 等 +│ │ ├── di/ # Hilt 装配:AgentModule / RepositoryModule / BackupModule +│ │ └── feature/ +│ │ ├── agent/ # AI Agent 核心(最大模块) +│ │ │ ├── data/ # Room 实体/DAO + 三协议 Retrofit API +│ │ │ ├── domain/ # 工具系统、provider 适配、MCP、workflow、checkpoint、子代理 +│ │ │ └── presentation/ # AIAgentViewModel + AIChatPanel 及全部聊天组件 +│ │ ├── terminal/ # 终端会话(本地 PRoot / 远程 SSH)+ 保活服务 +│ │ ├── workspace/ # 工作区抽象、文件访问、SAF Provider、同步引擎 +│ │ ├── editor/ # sora-editor 封装、TextMate、编码检测 +│ │ ├── git/ # 容器内命令行 git 的可视化封装 +│ │ ├── settings/ # Provider 管理、20+ DataStore 仓库、执行模式 +│ │ ├── backup/ # tar.gz + AES-GCM 加密备份恢复 +│ │ ├── credentials/ # Git 凭据文件仓库与 helper IPC 桥 +│ │ └── onboarding/ # 首启 spotlight 引导 +│ ├── src/main/assets/ +│ │ ├── prompts/ # AI 系统提示词分片(12 个编号 .md + agent/) +│ │ ├── migrations/ # 45+ 个 {VERSION}_{description}.sql 迁移脚本 +│ │ ├── aicode/ # 容器初始化:provision.sh、git-credential-aicode +│ │ ├── container/ # Alpine rootfs(按 flavor 提供 arm/x86) +│ │ └── api.official.json # 内置 provider/模型快照 +│ └── schemas/ # Room 导出的 schema JSON(编译期校验) +├── terminal-emulator/ # Termux 终端仿真核心(Java,Apache 2.0) +├── terminal-view/ # Termux 终端渲染 View(Java) +├── docs-site/ # VitePress 用户文档(构建期经 syncAiDocs 打进 APK) +├── scripts/ # check_migrations.py 等维护脚本 +└── .github/workflows/ # CI 与发版流水线 +``` + +**入口点** +- `AIEditorApp.kt` — Application:attachBaseContext 装 FileLogger/崩溃处理器/全局代理;onCreate 初始化 MCP、凭据桥、保活、WorkManager +- `MainActivity.kt` — 唯一 Activity:Compose 导航、抽屉侧边栏、宽屏双栏工作台 +- `WorkbenchPane.kt` — 大屏右栏:编辑器 / 终端 / Git 面板容器 + +## 子系统 + +### 1. AI Agent 引擎(feature/agent) + +**目的**:驱动 LLM 对话循环——组装提示词 → 调用 provider → 解析工具调用 → 权限判定 → 执行工具 → 回填结果,直至产出最终回复。 +**位置**:`app/src/main/java/com/aicode/feature/agent/` +**关键文件**:`domain/workflow/StatefulAgentWorkflow.kt`(状态机主循环)、`domain/tool/AgentTool.kt` + `ToolRegistry.kt`(工具系统)、`domain/provider/AnthropicAdapter.kt` 等(协议适配)、`domain/permission/ToolPermissionPolicyEngine.kt`(授权策略)、`domain/mcp/McpManager.kt`(MCP 扩展)、`domain/checkpoint/CheckpointManager.kt`(检查点)、`domain/tool/subagent/TaskTool.kt`(子代理派生)、`presentation/AIAgentViewModel.kt`(UI 状态中枢,约 1900 行) +**依赖**:terminal(CommandEngine 执行命令)、workspace(FileAccessProvider 读写文件)、settings(AIProviderRepository 拿模型配置)、credentials +**被依赖**:MainActivity 的聊天面板、terminal 的 AI 终端工具 + +### 2. 终端与执行引擎(feature/terminal) + +**目的**:提供多标签终端会话与后台命令执行;统一「本地 PRoot 容器」与「远程 SSH」两种后端。 +**位置**:`app/src/main/java/com/aicode/feature/terminal/` +**关键文件**:`domain/TerminalSessionProvider.kt`(后端接口)、`domain/TerminalSessionManager.kt`(本地会话池)、`domain/RemoteTerminalSessionManager.kt` + `domain/SshShellBackend.kt`(远程 shell channel)、`domain/DelegatingTerminalSessionProvider.kt`(模式分发)、`domain/TerminalKeepaliveService.kt`(前台服务保活)+ `domain/KeepaliveWorker.kt`(WorkManager 兜底) +**依赖**:`terminal-emulator` / `terminal-view` 模块(Termux 派生)、agent 的 `RemoteSshConnection` +**被依赖**:AI 工具(`TerminalSessionTool`)、终端 UI(TerminalScreen) + +### 3. 工作区与文件访问(feature/workspace) + +**目的**:管理项目工作区(本地私有目录 / 远程 SSH 目录),提供统一的文件读写后端,并承担 SAF 导出、SFTP/FTP 同步。 +**位置**:`app/src/main/java/com/aicode/feature/workspace/` +**关键文件**:`domain/FileAccessProvider.kt`(接口)、`domain/LocalFileAccess.kt` / `domain/RemoteSftpFileAccess.kt`(实现;远程走 SSH exec channel 读写,规避 sshj SFTP 缓冲区溢出 bug)、`domain/DelegatingFileAccess.kt`(分发)、`domain/WorkspacePathMapper.kt`(容器路径 ⇄ 宿主路径映射)、`data/repository/WorkspaceRepository.kt`(工作区管理)、`data/provider/WorkspaceDocumentsProvider.kt`(SAF)、`domain/remote/SyncEngine.kt`(文件同步) +**依赖**:agent 的 `RemoteSshConnection`、settings 的 `ExecutionModeHolder` +**被依赖**:AI 文件工具、编辑器、Git 面板 + +### 4. 设置与 Provider 管理(feature/settings) + +**目的**:管理 AI Provider(多 Key 轮换、每提供商代理)、执行模式切换、主题语言等 20+ 分域设置。 +**位置**:`app/src/main/java/com/aicode/feature/settings/` +**关键文件**:`data/local/entity/AIProviderEntity.kt`(Room)、`data/repository/ExecutionModeRepository.kt`(本地/远程模式)、`ExecutionModeHolder.kt`(模式内存缓存,三个委托层的分发依据)、`ProviderKeyRotator.kt`(Key 轮换)、`data/remote/ModelApiService.kt`(模型连通性测试,含限并发 4 的批量测试)、`data/repository/BackgroundSettingsRepository.kt`(背景图/磨砂/玻璃材质配置 6 键聚合)、`data/remote/UpdateCheckService.kt`(更新检查) +**依赖**:Room(AgentDatabase 挂载其实体) +**被依赖**:几乎所有 feature(拿配置与模式) + +### 5. 备份与凭据(feature/backup + feature/credentials) + +**目的**:加密备份恢复全量数据;Git 凭据的文件化存储与容器内注入。 +**位置**:`app/src/main/java/com/aicode/feature/backup/`、`app/src/main/java/com/aicode/feature/credentials/` +**关键文件**:`backup/domain/BackupManager.kt` + `backup/data/BackupManagerImpl.kt`(流式 tar.gz + PBKDF2/AES-GCM)、`credentials/data/repository/FileCredentialRepository.kt`(git-credential-store 格式文件)、`credentials/data/CredentialRequestBridge.kt`(credential helper 文件 IPC 桥) +**依赖**:AgentDatabase 各 DAO、commons-compress +**被依赖**:设置页、Git 操作 + +### 6. 跨模块基础设施(core/ + di/) + +**目的**:数据库迁移加载、全局代理、主题、通用 UI 组件(含玻璃材质系统)、日志;以及 app 层 Hilt 总装配。 +**位置**:`app/src/main/java/com/aicode/core/`、`app/src/main/java/com/aicode/di/` +**关键文件**:`core/db/MigrationLoader.kt`、`core/db/SqlScriptSplitter.kt`、`core/net/AppProxy.kt`、`core/ui/glass/GlassPanel.kt`(玻璃面板 Modifier:三档效果链+主题色染色+API33 门槛)、`core/util/FileLogger.kt`、`di/AgentModule.kt`(核心装配:数据库、Retrofit、ToolRegistry、委托绑定) +**依赖**:无业务依赖(底层) +**被依赖**:全部 feature + +## 关键架构决策 + +### 本地/远程委托模式 + +本地(PRoot 容器)与远程(SSH)是两条完全不同的执行链路,但通过三个接口统一: + +| 抽象接口 | 本地实现 | 远程实现 | +|---------|---------|---------| +| `CommandEngine`(命令执行) | `LinuxContainerEngine`(PRoot 拉起容器内命令) | `RemoteSshEngine`(sshj exec channel) | +| `FileAccessProvider`(文件读写) | `LocalFileAccess`(java.io.File) | `RemoteSftpFileAccess`(SSH exec + 重定向/base64) | +| `TerminalSessionProvider`(终端会话) | `TerminalSessionManager`(本地 PTY) | `RemoteTerminalSessionManager`(sshj shell channel) | + +每个接口对应一个 `Delegating*` 委托类,方法调用时读 `ExecutionModeHolder.currentMode()` 决定转发目标。切换执行模式即时生效,上层(AI 工具、编辑器、Git)无感知。 + +### targetSdk 锁定 28 + +PRoot 需要在 App 可写目录执行二进制,Android 10+ 的 W^X / SELinux 禁止该行为。解法:proot 全套以 `lib*.so` 命名走 jniLibs,安装后解压到 `nativeLibraryDir`(App 目录中唯一允许 execve 的位置);客户机二进制由 proot loader 用 `mmap(PROT_EXEC)` 映射进内存,绕开内核 execve。代价是无法上架 Google Play。 + +### 数据库迁移双轨制 + +- **文件式(默认)**:`core/db/MigrationLoader.kt` 扫描 `assets/migrations/{VERSION}_{description}.sql`,由 `SqlScriptSplitter` 按语句切分(识别注释与字符串字面量),整段包事务、失败整体回滚,成功记入 `migration_history` 表。适用于含数据清理/重命名的复杂变更。 +- **AutoMigration**:纯 schema 变更(加列/建表/索引)用 `@AutoMigration` 编译期生成。 +- 两者编号共用一个连续序列(当前 SCHEMA_VERSION = 52),由 `scripts/check_migrations.py` 对账(编号连续、已发布 Tag 的迁移冻结不可篡改)。 + +### flavor 按容器镜像拆包 + +`universal`(arm64 + x86 两套 rootfs)/ `armsolo`(仅 arm)/ `x86solo`(仅 x86)三个 flavor 共享 sourceSet:`_armAssets`/`_x86Assets` 与 `_armJniLibs`/`_x86JniLibs` 各只放一份二进制,由 AGP 资源并集合并,单架构包体积约为通用包的一半。 + +### 玻璃材质系统(磨砂 / 水玻璃 / 液体玻璃) + +基于 `io.github.kyant0:backdrop:1.0.6` 建立三档玻璃材质,全局作用于侧边栏、输入框、内容面板三区域: + +- **库版本锁定 1.0.6**:2.0+ 编译于 compileSdk 37,与项目 AGP 8.9.3(最高支持 SDK 36)冲突;1.0.6 编译于 SDK 36 兼容,API 表面(drawBackdrop/effects DSL/blur/lens/vibrancy)与 2.0 一致。1.0.6 无 `runtimeShaderEffect`,水玻璃档改用平台 `RenderEffect.createRuntimeShaderEffect` + 库 `effect()` 链入,与 2.0 语义等价。 +- **平台门槛 API 33+**:AGSL / RuntimeShader 需 Android 13,`glassPanel` Modifier 在 API < 33 直接透传、设置项禁用提示,不做降级。 +- **仅壁纸层采样**:壁纸 `Image` 标记为 backdrop 源(`rememberLayerBackdrop` 垫窗口底色 + `layerBackdrop`),静态录制一次零逐帧开销,不采样应用内容层(规避自反馈伪影)。 +- **配置零迁移**:6 个玻璃键(总开关/档位/三区域开关/水波动画)存入既有 `background_prefs`,复用 `frost_intensity`(0..1 × 32 = dp 半径)三档共用,无 Room 迁移。 +- **染色可读性**:`onDrawSurface` 画 `colorScheme.surface` 半透明层,alpha 下限 0.3;液体玻璃档额外 1dp 边缘高光描边。 + +## 图表 + +### 系统架构总览 + +```mermaid +flowchart LR + subgraph UI["presentation 层"] + MainActivity["MainActivity 双栏工作台"] + AIAgentVM["AIAgentViewModel"] + TerminalScreen["TerminalScreen"] + GitScreen["GitScreen"] + CodeEditor["CodeEditorScreen"] + end + + subgraph AgentCore["feature/agent"] + Workflow["StatefulAgentWorkflow"] + ToolRegistry["ToolRegistry 19 个工具"] + PolicyEngine["ToolPermissionPolicyEngine"] + Adapters["AnthropicAdapter / OpenAIAdapter / GeminiAdapter"] + McpManager["McpManager"] + CheckpointMgr["CheckpointManager"] + end + + subgraph Delegation["委托层(按执行模式分发)"] + DelegatingCmd["DelegatingCommandEngine"] + DelegatingFile["DelegatingFileAccess"] + DelegatingTerm["DelegatingTerminalSessionProvider"] + end + + subgraph Backends["执行后端"] + ContainerEngine["LinuxContainerEngine PRoot 容器"] + LocalFile["LocalFileAccess"] + TermSessionMgr["TerminalSessionManager"] + RemoteSshEngine["RemoteSshEngine"] + RemoteSftp["RemoteSftpFileAccess"] + RemoteTerm["RemoteTerminalSessionManager"] + end + + subgraph Data["数据层"] + RoomDB[("AgentDatabase v52")] + DataStore[("DataStore 20+ 分域")] + Files[("filesDir 工作区/快照/凭据")] + end + + LLM["LLM API OpenAI / Anthropic / Gemini"] + McpServer["MCP Server stdio / HTTP"] + + MainActivity --> AIAgentVM + MainActivity --> TerminalScreen + MainActivity --> GitScreen + MainActivity --> CodeEditor + AIAgentVM --> Workflow + Workflow --> ToolRegistry + ToolRegistry --> PolicyEngine + PolicyEngine --> DelegatingCmd + PolicyEngine --> DelegatingFile + ToolRegistry --> DelegatingFile + Workflow --> Adapters + Workflow --> CheckpointMgr + McpManager --> ToolRegistry + Workflow --> RoomDB + AIAgentVM --> DataStore + + DelegatingCmd --> ContainerEngine + DelegatingCmd --> RemoteSshEngine + DelegatingFile --> LocalFile + DelegatingFile --> RemoteSftp + DelegatingTerm --> TermSessionMgr + DelegatingTerm --> RemoteTerm + + ContainerEngine --> Files + LocalFile --> Files + Adapters --> LLM + McpManager --> McpServer +``` + +### AI 请求-工具循环时序 + +```mermaid +sequenceDiagram + participant VM as AIAgentViewModel + participant WF as StatefulAgentWorkflow + participant Adapter as AIProvider Adapter + participant Engine as ToolPermissionPolicyEngine + participant Tool as AgentTool + participant CE as CommandEngine/FileAccess + + VM->>WF: run(session, userMessage) + WF->>WF: 组装提示词 + 历史 + checkpoint + WF->>Adapter: completeStream(request) + Adapter-->>WF: AIStreamChunk 文本/工具调用 + WF->>Engine: resolve(tool, args, mode) + alt 需要授权 + Engine-->>VM: 弹窗请求(awaitApproval 挂起) + VM-->>Engine: 用户决定 ALLOW / DENY + end + Engine-->>WF: ALLOW + WF->>Tool: executeWithContext(args) + Tool->>CE: runCommand / read / write + CE-->>Tool: 执行结果 + Tool-->>WF: ToolResult + WF->>Adapter: 携带工具结果继续请求 + Adapter-->>WF: 最终回复(流式) + WF-->>VM: AgentEvent 流 → UI 渲染 +``` + +### 模块依赖关系 + +```mermaid +flowchart TD + subgraph Gradle["Gradle 模块"] + App[":app"] + TermEmu[":terminal-emulator"] + TermView[":terminal-view"] + end + + subgraph Features["feature/"] + Agent["agent"] + Terminal["terminal"] + Workspace["workspace"] + Editor["editor"] + Git["git"] + Settings["settings"] + Backup["backup"] + Credentials["credentials"] + Onboarding["onboarding"] + end + + subgraph Core["core/ + di/"] + CoreMod["db / net / theme / ui / util"] + DIMod["AgentModule 等装配"] + end + + TermView --> TermEmu + App --> TermView + App --> TermEmu + DIMod --> CoreMod + Agent --> CoreMod + Agent --> Terminal + Agent --> Workspace + Agent --> Settings + Agent --> Credentials + Terminal --> CoreMod + Terminal --> Workspace + Terminal --> Settings + Workspace --> CoreMod + Workspace --> Settings + Editor --> CoreMod + Editor --> Agent + Git --> CoreMod + Git --> Settings + Settings --> CoreMod + Backup --> CoreMod + Credentials --> CoreMod + Onboarding --> CoreMod +``` diff --git a/.monkeycode/docs/DEVELOPER_GUIDE.md b/.monkeycode/docs/DEVELOPER_GUIDE.md new file mode 100644 index 00000000..b56f98c7 --- /dev/null +++ b/.monkeycode/docs/DEVELOPER_GUIDE.md @@ -0,0 +1,213 @@ +# 开发者指南 + +## 项目目的 + +AiCode 是 Android 端 AI 编程工具:内置 Alpine Linux 容器(PRoot)与终端模拟器,让 AI 直接读写文件、执行 Shell 命令、运行构建工具;同时支持远程 SSH 服务器作为执行后端。App 内含文件树与代码编辑器、可视化 Git 面板、可在后台并行干活的子代理。 + +**核心职责**: +- AI Agent 对话循环:提示词组装、多协议 LLM 调用、工具调用与权限管控、上下文压缩、检查点回滚 +- 开发环境:本地 PRoot 容器 / 远程 SSH 双后端、多标签终端、代码编辑器、Git 可视化 +- 数据管理:Room 持久化、20+ 分域 DataStore 设置、加密备份恢复 + +**相关系统**: +- Termux(`terminal-emulator` / `terminal-view` 模块)— 终端仿真核心,Apache 2.0 +- OpenCode — 终端 AI 编码工具,核心灵感来源 +- models.dev — 内置模型快照数据源 + +## 环境搭建 + +### 前置条件 + +- JDK 17(Temurin) +- Android SDK(compileSdk 36,buildTools 35.0.0) +- Python 3(运行 `scripts/check_migrations.py` 迁移对账) +- Git 完整历史(versionCode 由 `git rev-list --count HEAD` 生成,浅克隆会得到 1) + +### 克隆与构建 + +```bash +git clone +cd aicode + +# 冒烟编译(日常默认,单 flavor 最快) +./gradlew :app:assembleUniversalDebug + +# 完整单测 +./gradlew :app:testUniversalDebugUnitTest + +# 迁移对账(编号连续、已发布迁移未被篡改) +python3 scripts/check_migrations.py +# 或 +./gradlew checkMigrations +``` + +### 签名配置(release 构建) + +release 签名凭据读 `app/keystore.properties`(已 gitignore): + +```properties +storeFile=aicode.jks +storePassword=<密码> +keyAlias=<别名> +keyPassword=<密码> +``` + +本地通常不存放签名文件;CI 从 GitHub secret 还原 `app/aicode.jks`。无此文件时 release 产出 unsigned 包。 + +### 构建变体 + +| Flavor | ABI | 容器镜像 | 用途 | +|--------|-----|---------|------| +| `universal` | arm64-v8a + x86_64 | arm + x86 两套 | 通用包,体积最大 | +| `armsolo` | arm64-v8a | 仅 arm | 真机首选,体积约为一半 | +| `x86solo` | x86_64 | 仅 x86 | 模拟器/Chromebook | + +debug 构建带包名后缀 `.debug`(`com.aicode.debug`),可与 release 同机共存;两者的私有目录互相隔离,已解压的容器 rootfs 与工作区项目在 debug 下不可见(需重新初始化),属预期行为。 + +### 日常验证命令 + +**别用聚合任务做日常验证**:`assembleDebug` / `assembleRelease` / `test` / `build` 都会跨三个 flavor 全跑,耗时极长。 + +| 用途 | 命令 | +|------|------| +| 冒烟编译 | `./gradlew :app:assembleUniversalDebug` | +| 推送前单测 | `./gradlew :app:testUniversalDebugUnitTest` | +| 推送前迁移对账 | `python3 scripts/check_migrations.py` | +| 发版构建 | `./gradlew assembleRelease` / `./gradlew bundleRelease` | + +产物路径:`app/build/outputs/apk//release/app--release.apk`。 + +## 开发工作流 + +### 分支策略 + +- **`main`** — 日常 bug 修复、补单测、CI/构建配置、纯文档直接提交 +- **`feat/*` / `refactor/*`** — 新功能、复杂多文件改动、架构重构;验证通过后合回 `main` +- **`hotfix/*`** — 从已发布的 RC Tag 拉出(勿从最新 main 拉),修复合回 `main` 后删除 +- **发版** — Tag 驱动:在 `main` 上打 `v*` Tag 触发 `android-release.yml` 自动构建签名 APK 并发 Release;严禁在 feature 分支上打 Tag + +### 提交规范 + +Conventional Commits(`.githooks/commit-msg` 本地校验),格式 `(): `: + +- **type**:`feat | fix | refactor | docs | style | chore | ci | build | perf | test` +- **scope**:`agent | settings | terminal | workspace | git | ui | mcp | db | core | docs | build | deps` +- 示例:`fix(settings): 修复 provider 保存时校验失败` + +### 提交前检查 + +改完编译型代码(`.kt` / `.gradle.kts` / `AndroidManifest.xml`)→ 必跑冒烟编译;`git push` 前必跑单元测试 + 迁移对账。CI(`ci.yml`)会在 push/PR 时自动构建 + 测试 + 对账兜底。 + +### 资产同步硬规则 + +改代码时以下内容必须同步,否则功能/文档/文案会出现缺口: + +| 改动 | 同步目标 | +|------|---------| +| AI 工具增删改名、参数签名变化、agent 行为调整 | `app/src/main/assets/prompts/` 下对应提示词分片 | +| UI 变化(新页面、交互、布局、文案) | `docs-site/docs/` 用户文档;新文档页同步加入 `docs-site/.vitepress/config.ts` 侧栏 | +| 新增用户可见中文文案 | 双语 strings.xml:`values/strings.xml`(中文)+ `values-en/strings.xml`(英文);代码用 `stringResource(R.string.xxx)`,禁止在 `.kt` 硬编码中文 UI 文案 | + +`docs-site/docs/` 是文档唯一事实源,构建时由 `syncAiDocs` task 复制到 `assets/docs/` 打进 APK——AI 在容器内看到的是 `~/.aicode/docs/{guide,advanced}/*.md`,与在线文档站永远一致。 + +### 版本号规则 + +版本号唯一事实源是 Git Tag / Commit,无需手写: + +- `versionName` = `gitVersionName()`:Tag `v1.7.0` → `1.7.0`;Tag 后第 N 个提交 → `1.7.0-dev.N+` +- `versionCode` = `gitCommitCount()`:提交数单调递增 + +## 常见任务 + +### 添加数据库迁移 + +Room 数据库当前 `SCHEMA_VERSION = 52`(见 `app/src/main/java/com/aicode/feature/agent/data/local/database/AgentDatabase.kt`)。一个版本号二选一: + +**文件式(默认,含数据清理/重命名/改约束的版本必须走这条)**: + +1. 递增 `AgentDatabase.kt` 的 `SCHEMA_VERSION` +2. 在 `app/src/main/assets/migrations/` 新建 `{VERSION}_{description}.sql`(如 `53_add_xxx.sql`),**编号必须连续** +3. 写 DDL/SQL(字符串里可放心写 `;`,`SqlScriptSplitter` 会识别字符串字面量) + +**AutoMigration(纯 schema 变更:加列/建表/索引)**: + +1. 改 entity,加 `@AutoMigration(from = N-1, to = N)` 注解(忘写会直接编译失败) +2. 保证 `to == SCHEMA_VERSION` 且 `from` 衔接文件式最大版本 + +验证:`python3 scripts/check_migrations.py`(校验编号连续、与已发布 Tag 无冲突)。 + +**跨分支硬规则**:已打 `v*` Tag 的迁移文件内容与编号冻结不可改;hotfix/RC 的迁移号合回 `main` 时,`main` 上编号更小的未发布迁移必须整体重编号到该上限之后。 + +### 添加新 AI 工具 + +**需修改的文件**: +1. `app/src/main/java/com/aicode/feature/agent/domain/tool/<分类>/.kt` — 继承 `AgentTool`(需要流式过程输出则实现 `StreamingAgentTool`,强制走上下文则继承 `AbstractContextualTool`) +2. `app/src/main/java/com/aicode/di/AgentModule.kt` — 注入并注册进 `ToolRegistry` +3. `app/src/main/assets/prompts/` — 在 `60-tools-and-paths.md` 等分片中同步工具说明 + +**步骤**: +1. 实现 `name` / `description` / `parameters` / `permissionPolicy` / `capabilities` 属性与 `execute(args)` 方法 +2. 在 `AgentModule` 的 `ToolRegistry` 装配处注册 +3. 如需授权弹窗/白名单,确认 `ToolPermissionPolicyEngine` 的内置安全清单是否需要更新 +4. 更新提示词分片 + +### 添加设置项 + +各设置域独立一个 Preferences DataStore(`feature/settings/data/repository/` 下已有 20+ 个,如 `ThemeSettingsRepository`、`KeepaliveSettingsRepository`)。新设置项: + +1. 新建 `XxxRepository`(`@Singleton`,注入 `@ApplicationContext`,定义私有 `preferencesDataStore`) +2. UI 放到 `feature/settings/presentation/component/SettingsScreen.kt` 对应分组 +3. 用户可见文案进双语 strings.xml + +### 添加新页面/交互 + +1. Compose Screen + ViewModel 放对应 feature 的 `presentation/` +2. 路由加进 `MainActivity.kt` 的 NavHost;大屏右栏容器能力在 `WorkbenchPane.kt` +3. 更新 `docs-site/docs/`(新文档页同步加侧栏与 `guide/overview.md` 索引) +4. 所有中文文案进双语 strings.xml + +### 修复 Bug + +1. 定位模块(对照 [架构文档](./ARCHITECTURE.md) 的子系统地图) +2. 最小改动修复;涉及共享组件签名变化时 `rg` 找出全部调用点确认都改到 +3. 冒烟编译 + 相关单测 +4. 提交信息用 `fix(): <描述>` + +### 发版流程概要 + +1. (打 Tag 前)`python3 scripts/update-models-dev-assets.py` 刷新内置模型快照;失败则跳过此步直接发版,不要重试或手改文件 +2. 在 `main` 最新提交打 Tag 并推送:`git tag v1.7.0 && git push origin v1.7.0` +3. CI 自动推导版本、构建三个签名 APK、发布 GitHub Release +4. 真机装 RC 包验证 AI 对话 + 终端 + 容器启动三条主线,有问题从该 RC Tag 拉 `hotfix/` 修复 + +## 编码规范 + +### 文件组织 + +- feature-based 分层 + DDD:每个 feature 内 `data/`(实现与持久化)/ `domain/`(业务逻辑与接口)/ `presentation/`(ViewModel + Compose) +- 依赖注入:可构造注入的类用 `@Inject constructor`;跨实现的接口绑定集中在 `di/` 下的 Module 或 feature 内自有 Module +- 协程用 Kotlin Coroutines / Flow,UI 层用 `StateFlow` 暴露状态 + +### 命名 + +| 类型 | 约定 | 示例 | +|------|------|------| +| 类 | PascalCase | `ToolPermissionPolicyEngine` | +| 函数 | camelCase | `resolveApproval()` | +| 常量 | SCREAMING_SNAKE | `SCHEMA_VERSION` | +| 数据库迁移文件 | `{版本}_{下划线描述}.sql` | `52_add_provider_custom_headers.sql` | +| strings.xml 资源 | 语义化英文小写下划线,复用加 `common_` 前缀 | `chat_send_message` | + +### 关键约定 + +- **targetSdk 锁定 28**(PRoot 依赖可写目录 execve),勿升级 +- **迁移编号连续**且已发布 Tag 的迁移冻结 +- **`SqlScriptSplitter` 已支持字符串内分号**,SQL 字符串字面量无需再用 `char(59)` 绕行 +- **单测环境不 mock android.jar**(`unitTests.isReturnDefaultValues = true`);迁移测试用 Robolectric + `MigrationTestHelper`,只跑 universalDebug 变体 +- 测试栈:JUnit 4 + MockK(`coEvery` 协程打桩)+ kotlinx-coroutines-test(`runTest` 虚拟时间) + +### 日志 + +- `core/util/FileLogger.kt` — 落盘日志:按天分文件、等级阈值、7 天/5MB 自动清理 +- `core/util/AILogger.kt` — AI 请求/响应逐会话完整日志 +- 用户反馈崩溃由 `AIEditorApp` 全局 handler 捕获并拉起 `CrashActivity`(展示详情 + 复制 + 重启) diff --git a/.monkeycode/docs/INDEX.md b/.monkeycode/docs/INDEX.md new file mode 100644 index 00000000..e7a9c531 --- /dev/null +++ b/.monkeycode/docs/INDEX.md @@ -0,0 +1,98 @@ +# AiCode 项目文档 + +本文档基于对代码仓库的递归分析生成,覆盖系统架构、模块职责、扩展点接口、领域概念与开发工作流,面向参与本项目的开发者与 AI 协作代理。 + +**快速链接**: [架构](./ARCHITECTURE.md) | [接口](./INTERFACES.md) | [开发者指南](./DEVELOPER_GUIDE.md) + +--- + +## 核心文档 + +### [架构](./ARCHITECTURE.md) +系统设计、技术栈、组件结构、关键架构决策(本地/远程委托模式、targetSdk 28 锁定、数据库迁移双轨制、flavor 拆包)与 Mermaid 架构图。从这里开始了解系统如何运作。 + +### [接口](./INTERFACES.md) +AI 工具系统(19 个内置工具 + 扩展点)、LLM Provider 适配(OpenAI / Anthropic / Gemini)、MCP 协议、执行后端抽象接口、Room 数据契约与内部脚本。集成或扩展系统的参考。 + +### [开发者指南](./DEVELOPER_GUIDE.md) +环境搭建、构建变体、分支与提交规范、资产同步硬规则(prompts / docs-site / 双语 strings)、常见任务(加迁移、加工具、加设置、发版)。贡献者必读。 + +--- + +## 模块 + +| 模块 | 描述 | 文档 | +|------|------|------| +| `feature/agent/` | AI Agent 核心:workflow 状态机、工具系统、权限引擎、MCP、检查点、子代理 | [agent](./模块/agent.md) | +| `feature/terminal/` | 终端会话(本地 PRoot / 远程 SSH 统一抽象)+ 进程保活 | [terminal](./模块/terminal.md) | +| `feature/workspace/` | 工作区管理、文件访问后端、SAF Provider、SFTP/FTP 同步 | [workspace](./模块/workspace.md) | +| `feature/settings/` | Provider 管理、执行模式、模型连通性批量测试、玻璃材质配置、20+ 分域 DataStore 设置 | [settings](./模块/settings.md) | +| `feature/editor/` | sora-editor 封装、TextMate 语法高亮、编码检测 | [editor](./模块/editor.md) | +| `feature/git/` | 容器内命令行 git 的可视化封装(状态/分支/历史/diff) | [git](./模块/git.md) | +| `feature/backup/` | tar.gz + AES-GCM 加密备份恢复(流式) | [backup](./模块/backup.md) | +| `feature/credentials/` | Git 凭据文件仓库与 helper 文件 IPC 桥 | [credentials](./模块/credentials.md) | +| `core/` | 数据库迁移加载、全局代理、主题、通用组件、玻璃材质系统、日志 | [core](./模块/core.md) | +| `:terminal-emulator` / `:terminal-view` | Termux 派生终端模块(Java,Apache 2.0) | 见 [架构-项目结构](./ARCHITECTURE.md#项目结构) | + +`feature/onboarding/`(首启 spotlight 引导)体量小、自包含:`domain/OnboardingStep.kt` 定义 10 步流程,`OnboardingCoordinator` 桥接状态机与 DataStore 持久化,UI 侧 `OnboardingOverlay` + `SpotlightOverlay` 实现挖孔高亮。 + +--- + +## 核心概念 + +理解这些领域概念有助于导航代码库: + +| 概念 | 描述 | +|------|------| +| [执行模式](./专有概念/执行模式.md) | 本地 PRoot 容器 vs 远程 SSH,三个委托层的分发依据 | +| [Agent 权限模式](./专有概念/Agent权限模式.md) | BUILD / PLAN / AUTO 三档授权范围与判定顺序 | +| [PRoot 容器](./专有概念/PRoot容器.md) | 免 root 的 Alpine 用户态容器机制与构建打包约束 | +| [检查点](./专有概念/检查点.md) | AI 改码前的自动快照与三维回滚 | +| [子代理](./专有概念/子代理.md) | 独立上下文的后台并行代理,定义可全局/项目级定制 | + +--- + +## 入门指南 + +### 项目新人? + +按此路径学习: +1. **[架构](./ARCHITECTURE.md)** — 了解全局与关键架构决策 +2. **[核心概念](#核心概念)** — 学习领域术语 +3. **[开发者指南](./DEVELOPER_GUIDE.md)** — 搭建环境、跑通构建 +4. **[模块文档](#模块)** — 深入负责的模块 + +### 需要扩展功能? + +1. **[接口](./INTERFACES.md)** — 工具系统、provider 适配、MCP 等扩展点契约 +2. **[开发者指南 - 常见任务](./DEVELOPER_GUIDE.md#常见任务)** — 加工具 / 加迁移 / 加设置的分步指南 + +### 定位问题? + +1. **[架构 - 子系统](./ARCHITECTURE.md#子系统)** — 按功能定位模块 +2. **[模块文档](#模块)** — 各模块的错误处理与代码模式约定 + +--- + +## 快速参考 + +### 命令 + +```bash +./gradlew :app:assembleUniversalDebug # 冒烟编译(日常默认) +./gradlew :app:testUniversalDebugUnitTest # 单元测试 +python3 scripts/check_migrations.py # 迁移对账(推送前必跑) +./gradlew checkMigrations # 同上(Gradle 包装) +./gradlew assembleRelease # 发版构建三 flavor APK +``` + +### 重要文件 + +| 文件 | 目的 | +|------|------| +| `app/build.gradle.kts` | 构建配置(版本号规则、flavor、proot 打包约束,注释信息量大) | +| `app/src/main/java/com/aicode/di/AgentModule.kt` | Hilt 核心装配(数据库、Retrofit、ToolRegistry、委托绑定) | +| `app/src/main/java/com/aicode/feature/agent/data/local/database/AgentDatabase.kt` | Room 主库,SCHEMA_VERSION 当前 52 | +| `app/src/main/assets/migrations/` | 文件式 SQL 迁移脚本(编号必须连续) | +| `app/src/main/assets/prompts/` | AI 系统提示词分片(改动 agent 行为须同步) | +| `CLAUDE.md` | AI 协作规则(构建验证、资产同步、发版流程) | diff --git a/.monkeycode/docs/INTERFACES.md b/.monkeycode/docs/INTERFACES.md new file mode 100644 index 00000000..20d398b7 --- /dev/null +++ b/.monkeycode/docs/INTERFACES.md @@ -0,0 +1,150 @@ +# 接口文档 + +AiCode 是 Android 应用,没有对外 HTTP API。本文档覆盖四类「接口」:核心扩展点接口(工具系统、执行后端抽象)、LLM/MCP 协议适配、数据契约(Room 实体)与内部脚本/Gradle task。 + +## AI 工具系统(核心扩展点) + +所有 AI 可调用的工具实现 `AgentTool` 接口(`app/src/main/java/com/aicode/feature/agent/domain/tool/AgentTool.kt`): + +```kotlin +interface AgentTool { + val name: String // 注册名,function-calling 用 + val description: String // 给 LLM 看的工具说明 + val parameters: Map // 参数定义 → toJsonSchema() 生成 schema + val permissionPolicy: ToolPermission // 授权策略 + val capabilities: Set + suspend fun execute(args: JSONObject): ToolResult + suspend fun executeWithContext(args: JSONObject, context: AgentContext): ToolResult +} +``` + +- `ToolResult` 为 sealed:`Success` / `Error` / `Partial` +- 需要流式过程输出的工具实现 `StreamingAgentTool`(返回 `Flow`) +- 强制依赖会话上下文的工具继承 `AbstractContextualTool` +- 工具经 `ToolRegistry`(`domain/tool/ToolRegistry.kt`)注册,LinkedHashMap 保序;MCP 工具运行时由 `McpManager` 动态追加/反注册 + +### 内置工具清单 + +注册于 `app/src/main/java/com/aicode/di/AgentModule.kt`: + +| 注册名 | 类 | 位置 `feature/agent/domain/tool/` | 职责 | +|--------|-----|------|------| +| `readFile` | ReadFileTool | file/FileTools.kt | 读文件 | +| `writeFile` | WriteFileTool | file/FileTools.kt | 写文件(触发检查点快照) | +| `editFile` | EditFileTool | editor/EditFileTool.kt | 精确编辑文件片段 | +| `sendFile` | SendFileTool | file/SendFileTool.kt | 发送文件给用户 | +| `viewImage` | ViewImageTool | file/ImageTools.kt | 读取图片进上下文 | +| `generateImage` | GenerateImageTool | file/GenerateImageTool.kt | 文生图 | +| `Bash` | ExecuteCommandTool | container/ExecuteCommandTool.kt | 容器/远程执行 Shell 命令 | +| `terminal` | TerminalSessionTool | container/BackgroundTerminalTools.kt | 后台终端会话(创建/输入/读输出) | +| `list` | ListFilesTool | explorer/ | 列目录 | +| `search` | SearchCodeTool | explorer/ | 代码搜索 | +| `websearch` | WebSearchTool | search/ | 网页搜索 | +| `webfetch` | WebFetchTool | search/ | 抓取网页(jsoup 清洗) | +| `loadSkill` | LoadSkillTool | skill/LoadSkillTool.kt | 加载技能(Skills) | +| `memory` | MemoryTool | memory/MemoryTool.kt | 读写长期记忆 | +| `todo` | TodoTool | todo/TodoTool.kt | 任务清单管理 | +| `switchMode` | SwitchModeTool | mode/SwitchModeTool.kt | BUILD/PLAN/AUTO 模式切换 | +| `askUserQuestion` | AskUserQuestionTool | question/AskUserQuestionTool.kt | 向用户提问(挂起等待) | +| `manageMcp` | ManageMcpTool | mcp/ManageMcpTool.kt | 管理 MCP 服务器连接 | +| `task` | TaskTool | subagent/TaskTool.kt | 派生子代理并行工作 | + +## LLM Provider 适配 + +统一抽象 `AIProvider`(`feature/agent/domain/provider/AIProvider.kt`): + +```kotlin +interface AIProvider { + suspend fun complete(request: AIRequest): AIResponse + fun completeStream(request: AIRequest): Flow +} +``` + +- `AIStreamChunk` 为 sealed:`TextDelta` / `ReasoningDelta` / `Final` / `Retrying` +- 三个适配器位于 `feature/agent/domain/provider/`: + +| 适配器 | 协议 | 特性 | +|--------|------|------| +| `AnthropicAdapter` | Messages API(`AnthropicApi`,Retrofit + 手动 SSE 解析) | thinking/signature 块回传 | +| `OpenAIAdapter` | Chat Completions;`useResponseApi=true` 时走 Responses API | 图片生成、Responses 事件聚合器 `ResponsesStreamAccumulator` | +| `GeminiAdapter` | generateContent + Interactions API 双协议 | Interactions 事件聚合器 `GeminiInteractionsStreamAccumulator` | + +- 重试与容错:`RetryPolicy`(阶梯重试)、`KeyFailureClassifier`(API Key 失效分类 → 配合 `ProviderKeyRotator` 多 Key 轮换)、`HttpErrorEnricher` +- Retrofit 接口层位于 `feature/agent/data/remote/{anthropic,openai,gemini}/`,由 `di/AgentModule.kt` 提供 `@Named` 区分的三个实例 + +## MCP 协议(动态工具扩展) + +`McpManager`(`feature/agent/domain/mcp/McpManager.kt`)连接 MCP 服务器并把远端工具桥接为 `AgentTool`: + +| 传输方式 | 实现类 | 说明 | +|---------|--------|------| +| 本地 stdio | `StdioTransport` | 在 PRoot 容器内以子进程拉起 server(`command`/`args`/`env`) | +| 远程 HTTP | `StreamableHttpTransport` | OkHttp Streamable HTTP + SSE(手动解析 `data:` 行) | + +- 协议实现:`McpClient`(JSON-RPC 握手 + `tools/list` + `tools/call`)、`McpJsonRpc`(消息模型)、`McpTransport`(可插拔传输接口) +- 工具桥接:远端工具注册名命名空间化为 `mcp____`,断开时反注册 +- 配置:`McpConfigRepository` + `.mcp.json` 风格配置文件;订阅 4 个信号源(工作区切换、配置文件外部编辑、容器 profile 切换、远程默认容器变化)自动 reload + +## 执行后端抽象接口 + +三个委托接口实现本地/远程双后端(详见 [架构文档 - 本地/远程委托模式](./ARCHITECTURE.md#本地远程委托模式)): + +| 接口 | 位置 | 方法概要 | 本地实现 | 远程实现 | +|------|------|---------|---------|---------| +| `CommandEngine` | `feature/agent/domain/container/` | `runCommand` / `runCommandSyncUnbounded` | `LinuxContainerEngine`(PRoot 拉起容器内进程) | `RemoteSshEngine`(sshj exec channel) | +| `FileAccessProvider` | `feature/workspace/domain/` | `read` / `write` / `list` / `delete` / `copy` / `rename` / `mkdirs` | `LocalFileAccess` | `RemoteSftpFileAccess`(SSH exec channel,规避 sshj SFTP 缓冲区溢出 bug) | +| `TerminalSessionProvider` | `feature/terminal/domain/` | `startBackgroundCommand` / `sendInput` / `getTabOutput` / `listTabs` / `closeTab` / `tabFinishedEvents` | `TerminalSessionManager`(本地 PTY) | `RemoteTerminalSessionManager`(sshj shell channel → `SshShellBackend`) | + +分发机制:`DelegatingCommandEngine` / `DelegatingFileAccess` / `DelegatingTerminalSessionProvider` 每次调用读 `ExecutionModeHolder.currentMode()` 转发目标实现。 + +## 数据契约(Room 实体) + +主数据库 `aicode_agent_db`,`AgentDatabase`(`feature/agent/data/local/database/AgentDatabase.kt`),`SCHEMA_VERSION = 52`: + +| 实体(表名) | 路径 `feature/agent/data/local/entity/` | 关键字段 | +|-------------|------|--------| +| `ChatSessionEntity`(chat_sessions) | ChatSessionEntity.kt | 标题、模式(BUILD/PLAN/AUTO)、provider/model、token 统计、parentId(子代理会话) | +| `AgentMessageEntity`(agent_messages) | AgentMessageEntity.kt | 角色、内容、工具调用 JSON、附件、reasoning 回传字段 | +| `TodoItemEntity`(todo_items) | TodoItemEntity.kt | AI 任务清单项 | +| `CheckpointEntity`(session_checkpoints) | CheckpointEntity.kt | 检查点节点(归属会话与用户消息) | +| `CheckpointFileSnapshotEntity`(checkpoint_file_snapshots) | CheckpointFileSnapshotEntity.kt | 单文件快照记录(内容副本存 `filesDir/checkpoints/`) | +| `LlmCallRecordEntity`(llm_call_records) | LlmCallRecordEntity.kt | 每次 LLM 调用的 token/耗时审计 | +| `AIProviderEntity`(ai_providers) | `feature/settings/data/local/entity/AIProviderEntity.kt` | 多 Key 轮换、每提供商代理、自定义请求头 | +| `RemoteConnectionEntity` / `RemoteMountEntity` | `feature/workspace/data/local/` | 远程 SSH 连接与挂载配置 | + +对应 DAO:`AgentMessageDao`、`ChatSessionDao`、`TodoItemDao`、`CheckpointDao`、`LlmCallRecordDao`、`AIProviderDao`、`RemoteConnectionDao`。 + +迁移脚本:`app/src/main/assets/migrations/{VERSION}_{description}.sql`,由 `core/db/MigrationLoader.kt` 加载执行并记入 `migration_history` 表;schema 导出在 `app/schemas/`。 + +## 内部脚本与 Gradle Task + +### scripts/(仓库根) + +| 脚本 | 用途 | 调用时机 | +|------|------|---------| +| `check_migrations.py` | 迁移对账:编号连续、`SCHEMA_VERSION` 一致、已发布 Tag 迁移未篡改/复用 | push 前 + CI 构建 | +| `update-models-dev-assets.py` | 从 models.dev 刷新 `app/src/main/assets/api.official.json`(仅内置 provider) | 仅打 Tag 发版前手动;失败非零退出且不改文件 | +| `build-vscode-themes.py` | 合并 VSCode Dark+/Light+ 主题为 tm4e 单文件 JSON → `assets/textmate/` | 编辑器主题变更时 | +| `sync-gitcode-releases.py` | 同步 GitHub Release APK 到 GitCode 镜像 | 发布后 | + +### assets/aicode/(容器内脚本) + +| 脚本 | 用途 | +|------|------| +| `provision.sh` | 首次进终端的依赖安装菜单(基础工具 + Node/Python/Java/Go 运行时,多镜像换源;`PROVISION_VERSION` 变更触发存量设备重跑) | +| `git-credential-aicode` | 自定义 git credential helper(busybox ash 兼容,仅 HTTPS),经文件 IPC 与 App 凭据弹窗联动 | + +### Gradle Task + +| Task | 用途 | +|------|------| +| `:app:assemble` | 构建 APK(`universal` / `armsolo` / `x86solo` × `debug` / `release`) | +| `:app:testUniversalDebugUnitTest` | 单元测试(迁移测试只跑 universalDebug 变体) | +| `:app:checkMigrations` | 调 `scripts/check_migrations.py` 迁移对账 | +| `:app:syncAiDocs` | 把 `docs-site/docs/*.md` 复制进 `assets/docs/`(`preBuild` 自动依赖) | + +## 权限与授权接口 + +- `ToolPermissionPolicyEngine`(`feature/agent/domain/permission/`):弹窗前判定 ALLOW / DENY / ASK。判定顺序:DENY 规则 → 不可静态判定则 ASK → 内置安全白名单(`BuiltInSafeCommands`)→ 已记忆 ALLOW 规则 → ASK +- `ToolPermissionManager`(`feature/agent/domain/tool/`):授权中台,`awaitApproval()` 挂起(CompletableDeferred)等用户在弹窗选择,多会话并行互不阻塞 +- 三种运行模式:BUILD(正常授权)、PLAN(工具层拦截全部写操作)、AUTO(全部放行,保留灾难性 `rm` 防护) diff --git "a/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/Agent\346\235\203\351\231\220\346\250\241\345\274\217.md" "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/Agent\346\235\203\351\231\220\346\250\241\345\274\217.md" new file mode 100644 index 00000000..18c8fc81 --- /dev/null +++ "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/Agent\346\235\203\351\231\220\346\250\241\345\274\217.md" @@ -0,0 +1,69 @@ +# Agent 权限模式(BUILD / PLAN / AUTO) + +权限模式控制 AI 工具的授权范围,按信任程度在「逐步授权」与「完全放行」之间切换。它与[执行模式](./执行模式.md)正交:权限模式决定 AI 能做什么,执行模式决定在哪做。 + +## 什么是权限模式? + +每个会话有独立的 `AgentMode`(领域模型 `feature/agent/domain/model/`),UI 上随时可切。AI 也可通过 `switchMode` 工具自行切换(受授权策略约束)。 + +**三种模式**: + +| 模式 | 行为 | +|------|------| +| `BUILD` | 正常开发模式:写操作按授权规则弹窗确认 | +| `PLAN` | 只读规划:`ToolPermissionPolicyEngine` 在工具层物理拦截全部写操作,AI 只能调研并输出计划;计划经 `PlanApprovalManager` 批准后切回执行 | +| `AUTO` | 全部放行免授权,保留灾难性命令防护(如递归删除根目录类 `rm`) | + +## 代码位置 + +| 方面 | 位置 | +|------|------| +| 模式定义 | `feature/agent/domain/model/`(`ChatSession` 携带 `AgentMode`) | +| 策略引擎 | `feature/agent/domain/permission/ToolPermissionPolicyEngine.kt` | +| 授权中台 | `feature/agent/domain/tool/ToolPermissionManager.kt` | +| 模式切换工具 | `feature/agent/domain/tool/mode/SwitchModeTool.kt` | +| 计划批准流 | `PlanApprovalManager` | +| 提示词 | `app/src/main/assets/prompts/80-plan-mode.md`、`81-auto-mode.md` | + +## 授权判定顺序 + +`ToolPermissionPolicyEngine.resolve()` 对每次工具调用(弹窗前)按序判定: + +```mermaid +flowchart TD + Call["工具调用请求"] --> Deny{"命中 DENY 规则?"} + Deny -->|是| DenyIt["拒绝"] + Deny -->|否| Static{"可静态判定?"} + Static -->|否| Ask1["弹窗询问"] + Static -->|是| Safe{"内置安全白名单?"} + Safe -->|是| Allow["放行"] + Safe -->|否| Remembered{"已记忆 ALLOW 规则?"} + Remembered -->|是| Allow + Remembered -->|否| Ask2["弹窗询问"] +``` + +**关键细节**: +- 「始终允许」按命令前缀记忆:`git` / `npm` 记到子命令级,避免过宽放行 +- PLAN 模式的拦截发生在策略引擎层(物理拦截),`ToolRegistry` 仍返回全部工具定义 +- `ToolPermissionManager.awaitApproval()` 用 CompletableDeferred 挂起等待用户弹窗选择,多会话并行互不阻塞 +- Shell 命令解析由 `ShellCommandParser` 支持,识别管道与组合命令的各段 + +## 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> BUILD: 新建会话 + BUILD --> PLAN: 切换 / switchMode + PLAN --> BUILD: 计划获批准 + BUILD --> AUTO: 切换 / switchMode + AUTO --> BUILD: 切换 / switchMode + PLAN --> AUTO: 切换 / switchMode + AUTO --> PLAN: 切换 / switchMode +``` + +## 关系 + +| 关联概念 | 关系 | 描述 | +|---------|------|------| +| [执行模式](./执行模式.md) | 正交 | 权限模式约束「做什么」,执行模式决定「在哪做」 | +| 检查点 | 兜底 | AUTO 模式下文件改动仍有检查点快照,可回滚 | diff --git "a/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/PRoot\345\256\271\345\231\250.md" "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/PRoot\345\256\271\345\231\250.md" new file mode 100644 index 00000000..d12c24a0 --- /dev/null +++ "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/PRoot\345\256\271\345\231\250.md" @@ -0,0 +1,44 @@ +# PRoot 容器 + +AiCode 本地执行后端的底层机制:不需要 root 权限,在 App 私有目录里运行一个 Alpine Linux 用户态容器,AI 的 Shell 命令、MCP stdio 服务器、Git 操作都跑在其中。 + +## 什么是 PRoot 容器? + +PRoot 是用户态的 chroot/挂载模拟器:它以 ptrace 跟踪客户进程的系统调用,把容器内路径(如 `/root/workspace`)透明重定向到宿主真实路径(App 私有目录),并模拟 root 权限。AiCode 用它加载内置的 Alpine rootfs(`assets/container/` 下的 tar.gz,按 flavor 提供 arm/x86 两套)。 + +**关键特征**: +- **免 root**:客户机二进制由 proot loader 用 `mmap(PROT_EXEC)` 映射进内存执行,绕开内核 execve 的 W^X / SELinux 限制 +- **proot 本体走 jniLibs**:以 `lib*.so` 命名打进 APK,安装后解压到 `nativeLibraryDir`——App 目录中唯一允许 execve 的位置 +- **rootfs 在 assets**:`ContainerInstaller` 首次启动解压到 `filesDir`(容器 profile 目录) +- **宿主目录 bind mount**:工作区项目经 proot `-b` 参数挂进容器内 `~/workspace` + +## 代码位置 + +| 方面 | 位置 | +|------|------| +| 命令执行入口 | `feature/agent/domain/container/LinuxContainerEngine.kt`(`buildProotInvocation` 组装 proot 命令行) | +| 命令引擎抽象 | `feature/agent/domain/container/CommandEngine.kt` | +| 容器安装与 profile | `ContainerInstaller` / `ContainerProfile`(`feature/settings/data/` 或容器相关仓库) | +| 镜像清单 | `app/src/main/assets/container-images.json` | +| rootfs 二进制 | `app/src/_armAssets/container/arm/`、`app/src/_x86Assets/container/x86/` | +| proot 二进制 | `app/src/_armJniLibs/`、`app/src/_x86JniLibs/`(`libproot.so` + loader + `libtalloc` + `libandroid-shmem`) | +| 容器内初始化脚本 | `app/src/main/assets/aicode/provision.sh`(提取到 `filesDir/aicode`,容器内为 `/root/.aicode`) | +| 路径映射 | `feature/workspace/domain/WorkspacePathMapper.kt`(容器路径 ⇄ 宿主路径) | + +## 构建打包约束 + +容器二进制的打包方式由 `app/build.gradle.kts` 精细控制,改动前先读注释: + +- `packaging.jniLibs.useLegacyPackaging = true` — 必须:否则 `.so` 不解压、直接从 APK 映射,`execve libproot.so` 会得到 ENOENT +- `keepDebugSymbols` 显式保留 proot 全套 — 外部预编译产物不得被构建流程后处理 +- `_armJniLibs` / `_x86JniLibs` 按 flavor 共享 sourceSet — 单架构包只含一套二进制 + +## 不变量 + +1. **proot 文件名必须是 `lib*.so`**:改名会失去 jniLibs 解压到 `nativeLibraryDir` 的待遇,SELinux 直接禁止 execve +2. **targetSdk 锁定 28**:Android 10+ 收紧 W^X 后,App 可写目录执行二进制的合法路径只有 nativeLibraryDir;升 targetSdk 需重新论证整条容器链路 +3. **容器内二进制由 loader 映射执行**:rootfs 内的 bash/python 等不经过内核 execve,故不受 W^X 约束——这套机制成立的前提是 proot loader 本身能被 execve + +## 与远程模式的关系 + +远程 SSH 模式完全绕开 PRoot:命令走 sshj exec channel(`RemoteSshEngine`),终端走 shell channel(`SshShellBackend`)。MCP stdio 服务器在远程模式下跑在远程服务器的默认容器/环境中。分发逻辑见[执行模式](./执行模式.md)。 diff --git "a/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/\345\255\220\344\273\243\347\220\206.md" "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/\345\255\220\344\273\243\347\220\206.md" new file mode 100644 index 00000000..dec36ee1 --- /dev/null +++ "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/\345\255\220\344\273\243\347\220\206.md" @@ -0,0 +1,65 @@ +# 子代理(Subagent) + +主会话派生出的拥有独立上下文的后台代理:并行调研、审查或对比方案,全程不阻塞当前对话。 + +## 什么是子代理? + +子代理是一个独立跑完整 Agent 工作流的子会话——有自己的消息历史、上下文窗口与工具集,由主会话通过 `task` 工具派生。主对话收到的是子代理的最终结论,中间过程在侧边栏按父子关系展开查看。 + +**关键特征**: +- **独立上下文**:`ChatSession.parentId` 建立父子关系,子代理消息与主会话隔离,避免污染主上下文 +- **定义可定制**:`AgentDefinition` 描述名称、提示词注入片段(`InjectPart`)、允许的工具集;支持全局与项目两级(项目级放 `.agents/` 目录) +- **内置 Explore**:只读调研型子代理(对应 assets/agents/explore.md) +- **事件总线**:`SubAgentEventBus` 广播 SPAWNED / COMPLETED / FAILED / STOPPED,UI 据此展示进度 + +## 代码位置 + +| 方面 | 位置 | +|------|------| +| 派生入口 | `feature/agent/domain/tool/subagent/TaskTool.kt`(`task` 工具) | +| 定义模型 | `feature/agent/domain/subagent/AgentDefinition.kt` | +| 定义解析 | `AgentDefinitionParser` / `LocalDirectoryAgentSource` / `ProjectDirectoryAgentSource` | +| 定义仓库 | `AgentDefinitionRepository` / `AgentDefinitionConfigRepository` | +| 事件总线 | `SubAgentEventBus` | +| 会话模型 | `ChatSession.parentId` / `subagentType`(`feature/agent/domain/model/`) | +| 内置定义 | `app/src/main/assets/agents/explore.md` | +| UI | `feature/settings/presentation/component/SubAgentsSection.kt`(管理)、聊天侧边栏(进度展开) | + +## 结构 + +``` +AgentDefinition +├── name // 子代理名(task 工具的 subagentType 参数) +├── prompt // InjectPart 列表:注入到子代理系统提示词的片段 +├── tools // 允许的工具集(缺省继承全部) +└── scope // global / project(.agents/ 目录,随工作区走) +``` + +## 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> Spawned: 主会话调用 task 工具 + Spawned --> Running: 建子会话并独立跑 workflow + Running --> Completed: 产出最终结论 + Running --> Failed: 执行出错 + Running --> Stopped: 用户手动停止 + Completed --> [*]: 结论回传主会话 + Failed --> [*] + Stopped --> [*] +``` + +事件经 `SubAgentEventBus` 广播,主 UI 侧边栏实时展示各子代理状态。 + +## 不变量 + +1. **父子关系单向**:子代理可再派生子代理,`parentId` 链只在创建时确定 +2. **工具集受权限体系约束**:子代理的授权仍走 [Agent 权限模式](./Agent权限模式.md) 的策略引擎,定义中的 `tools` 只是可见范围收窄 +3. **结论才回传**:子代理的全部中间消息留在子会话,主会话只收最终回复 + +## 关系 + +| 关联概念 | 关系 | 描述 | +|---------|------|------| +| 检查点 | 独立快照链 | 每个子会话有自己的检查点 | +| [Agent 权限模式](./Agent权限模式.md) | 共享授权体系 | 子代理工具调用同样过策略引擎 | diff --git "a/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/\346\211\247\350\241\214\346\250\241\345\274\217.md" "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/\346\211\247\350\241\214\346\250\241\345\274\217.md" new file mode 100644 index 00000000..a7348330 --- /dev/null +++ "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/\346\211\247\350\241\214\346\250\241\345\274\217.md" @@ -0,0 +1,48 @@ +# 执行模式(Execution Mode) + +执行模式决定「命令、文件、终端在哪跑」——是本机 PRoot Linux 容器,还是远程 SSH 服务器。它是 AiCode 双后端架构的中枢概念,三个委托层(命令/文件/终端)每次调用都依据它分发。 + +## 什么是执行模式? + +执行模式是全局唯一的运行时状态,持久化在 DataStore(`execution_mode_prefs`),并在内存中由 `ExecutionModeHolder`(`feature/settings/`)以 StateFlow 缓存——委托层同步读取,切换即时生效。 + +**关键特征**: +- 两种取值:本地 PRoot 容器 / 远程 SSH +- 对 AI 工具与 UI 完全透明:`Bash` 工具、编辑器、Git 面板只依赖抽象接口 +- 远程模式下 MCP stdio 服务器跑在远程默认容器上,文件同步与凭据注入随之切换 + +## 代码位置 + +| 方面 | 位置 | +|------|------| +| 模式缓存 | `feature/settings/ExecutionModeHolder.kt` | +| 持久化 | `feature/settings/data/repository/ExecutionModeRepository.kt` | +| 命令分发 | `feature/agent/domain/container/DelegatingCommandEngine.kt` | +| 文件分发 | `feature/workspace/domain/DelegatingFileAccess.kt` | +| 终端分发 | `feature/terminal/domain/DelegatingTerminalSessionProvider.kt` | +| 远程连接 | `feature/agent/data/remote/RemoteSshConnection.kt`(共享 sshj client) | + +## 委托分发机制 + +```mermaid +flowchart LR + Caller["AI 工具 / UI"] + Holder["ExecutionModeHolder currentMode"] + Delegating["Delegating 委托层"] + Local["本地实现 LinuxContainerEngine / LocalFileAccess / TerminalSessionManager"] + Remote["远程实现 RemoteSshEngine / RemoteSftpFileAccess / RemoteTerminalSessionManager"] + + Caller --> Delegating + Holder --> Delegating + Delegating -->|本地| Local + Delegating -->|远程 SSH| Remote +``` + +委托类是纯分发器:每个方法先读 `currentMode()`,再转发对应实现。新增执行后端时只需新增实现类并扩展委托层分支。 + +## 关系 + +| 关联概念 | 关系 | 描述 | +|---------|------|------| +| [PRoot 容器](./PRoot容器.md) | 本地模式的底层 | 本地模式实际在 PRoot 容器内执行 | +| [Agent 权限模式](./Agent权限模式.md) | 正交概念 | 执行模式决定「在哪跑」,权限模式决定「AI 能做什么」 | diff --git "a/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/\346\243\200\346\237\245\347\202\271.md" "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/\346\243\200\346\237\245\347\202\271.md" new file mode 100644 index 00000000..d8cc27e5 --- /dev/null +++ "b/.monkeycode/docs/\344\270\223\346\234\211\346\246\202\345\277\265/\346\243\200\346\237\245\347\202\271.md" @@ -0,0 +1,62 @@ +# 检查点(Checkpoint) + +Agent 修改代码前的自动快照机制:每条用户消息建一个检查点节点,每次文件被改前抓单文件快照,对话中可一键回滚代码、对话或两者。 + +## 什么是检查点? + +检查点是「可回滚的最小安全网」:它解决 AI 改错代码后无法恢复的问题。实现分两层——Room 记录元数据(哪些文件、何时、归属哪个会话),文件内容副本存私有目录(`filesDir/checkpoints///`)。 + +**关键特征**: +- **自动创建**:用户每发一条消息,`createCheckpoint()` 建节点;无需用户操作 +- **改前快照**:`beforeFileModified()` 在每次文件修改前执行,只备份将被覆盖的文件(增量,省空间) +- **三维回滚**:仅恢复代码 / 仅恢复对话 / 两者同时恢复(`RewindOptionsBottomSheet` 让用户选) +- **按会话隔离**:活动 checkpoint 按会话管理,多会话并行互不干扰 + +## 代码位置 + +| 方面 | 位置 | +|------|------| +| 管理器 | `feature/agent/domain/checkpoint/CheckpointManager.kt` | +| 元数据实体 | `feature/agent/data/local/entity/CheckpointEntity.kt`(session_checkpoints 表) | +| 快照实体 | `feature/agent/data/local/entity/CheckpointFileSnapshotEntity.kt`(checkpoint_file_snapshots 表) | +| DAO | `feature/agent/data/local/dao/CheckpointDao.kt` | +| 回滚 UI | `feature/agent/presentation/component/RewindOptionsBottomSheet.kt` | +| 文件存储 | `filesDir/checkpoints///` | + +## 结构 + +``` +CheckpointEntity(节点) +├── sessionId // 归属会话 +├── userMessageId // 关联的用户消息 +└── createdAt +CheckpointFileSnapshotEntity(快照,多条/节点) +├── checkpointId // 归属检查点 +├── filePath // 被改文件的容器路径 +└── backupPath // filesDir 下的副本位置 +``` + +## 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> Created: 用户发消息 createCheckpoint + Created --> Snapshotting: AI 改文件前 beforeFileModified + Snapshotting --> Snapshotting: 每个文件改前抓快照 + Snapshotting --> Restored: 用户回滚 restoreCodeToCheckpoint + Created --> [*]: 会话结束 clearSessionCheckpoints + Restored --> [*] +``` + +## 不变量 + +1. **先快照后修改**:所有走 `writeFile` / `editFile` 工具的写入必须先过 `beforeFileModified()`,直接写盘的代码路径会绕过保护 +2. **快照与元数据一致**:`CheckpointFileSnapshotEntity` 记录的 backupPath 必须真实存在,回滚依赖它 +3. **清理不可逆**:`clearSessionCheckpoints()` 会删文件副本,回滚窗口仅限会话存活期 + +## 关系 + +| 关联概念 | 关系 | 描述 | +|---------|------|------| +| [Agent 权限模式](./Agent权限模式.md) | 兜底安全网 | AUTO 模式免授权,检查点是误操作的最后防线 | +| 子代理 | 共享 | 子代理会话同样有独立的检查点链 | diff --git "a/.monkeycode/docs/\346\250\241\345\235\227/agent.md" "b/.monkeycode/docs/\346\250\241\345\235\227/agent.md" new file mode 100644 index 00000000..e9a434d2 --- /dev/null +++ "b/.monkeycode/docs/\346\250\241\345\235\227/agent.md" @@ -0,0 +1,74 @@ +# agent — AI Agent 核心模块 + +驱动 LLM 对话循环的最大业务模块:提示词组装 → 多协议 provider 调用 → 工具调用与权限管控 → 回填结果,直至产出最终回复。同时拥有 MCP 扩展、检查点回滚、子代理、上下文压缩等全部 Agent 能力。 + +## 结构 + +``` +feature/agent/ +├── data/ +│ ├── local/ +│ │ ├── database/AgentDatabase.kt # Room 主库,SCHEMA_VERSION = 52 +│ │ ├── entity/ # 6 个实体:会话/消息/待办/检查点×2/调用记录 +│ │ └── dao/ # 5 个 DAO +│ └── remote/ +│ ├── anthropic/ # AnthropicApi + DTO(Messages API) +│ ├── openai/ # OpenAIApi(Chat Completions + Responses + Images) +│ ├── gemini/ # GeminiApi(generateContent + Interactions) +│ └── RemoteSshConnection.kt # 共享 sshj client(远程后端的总连接) +├── domain/ +│ ├── workflow/StatefulAgentWorkflow.kt # 状态机主循环(AgentEvent 事件流) +│ ├── provider/ # AIProvider 接口 + 三适配器 + RetryPolicy/Key 轮换 +│ ├── tool/ # AgentTool 基类 + 19 个内置工具 + ToolRegistry +│ ├── permission/ToolPermissionPolicyEngine.kt # 授权策略引擎 +│ ├── mcp/McpManager.kt # MCP stdio/HTTP 客户端与动态工具注册 +│ ├── checkpoint/CheckpointManager.kt # 检查点创建/快照/回滚 +│ ├── subagent/ # AgentDefinition + 事件总线 + 定义源 +│ ├── session/ # SessionUseCase / MessagePersistenceUseCase +│ ├── model/ # ChatSession / AgentMessage / AgentContext +│ ├── command/ # 斜杠命令(multibinding 注册) +│ └── ...(memory / skill / todo / container / prompt) +└── presentation/ + ├── AIAgentViewModel.kt # 约 1900 行,agent 功能唯一 ViewModel + └── component/ # AIChatPanel、消息气泡、Markdown 渲染链、回滚面板等 +``` + +## 关键文件 + +| 文件 | 目的 | +|------|------| +| `domain/workflow/StatefulAgentWorkflow.kt` | 核心状态机:LLM 请求 → 工具循环 → 权限弹窗 → checkpoint,20+ 依赖注入 | +| `domain/tool/AgentTool.kt` | 工具公共接口(见 [接口文档](../INTERFACES.md)) | +| `domain/tool/ToolRegistry.kt` | @Singleton 保序注册表;PLAN 模式拦截交给策略引擎 | +| `domain/permission/ToolPermissionPolicyEngine.kt` | 弹窗前 ALLOW/DENY/ASK 判定 + PLAN 物理拦截 + AUTO 灾难命令防护 | +| `domain/mcp/McpManager.kt` | 订阅 4 信号源自动 reload;stdio 跑在 PRoot 容器内,HTTP 走 Streamable + SSE | +| `domain/checkpoint/CheckpointManager.kt` | 每条消息建节点、改前抓快照、三维回滚 | +| `domain/provider/AIProvider.kt` | 统一 provider 抽象(complete / completeStream) | +| `presentation/AIAgentViewModel.kt` | 多会话并行 UI 状态、权限弹窗回传、子代理事件、唤醒锁与保活 | + +## 依赖 + +**本模块依赖**: +- `feature/terminal` — `CommandEngine` 执行 Shell +- `feature/workspace` — `FileAccessProvider` 读写文件 +- `feature/settings` — `AIProviderRepository`(模型配置)、`ExecutionModeHolder`(模式) +- `feature/credentials` — 远程 SSH 凭据 +- `terminal-emulator` — 后台命令的输出解析 + +**依赖本模块的**: +- `MainActivity` / `WorkbenchPane` — 聊天面板挂载 +- `feature/editor` — 复用 `MarkdownContent` 渲染链 + +## 规范 + +### 代码模式 + +**新工具**:继承 `AgentTool`(流式输出实现 `StreamingAgentTool`),在 `di/AgentModule.kt` 注册,同步更新 `assets/prompts/`。详见[开发者指南](../DEVELOPER_GUIDE.md)。 + +**授权**:工具调用统一走 `ToolPermissionPolicyEngine`,判定顺序 DENY → 静态不可判定 ASK → 内置白名单 → 已记忆规则 → ASK。 + +**错误处理**:工具失败返回 `ToolResult.Error` 回传给 LLM(AI 自行重试/改道),进程级异常经 workflow 事件流上报 UI;LLM 网络错误由 `RetryPolicy` 阶梯重试,Key 失效由 `KeyFailureClassifier` 触发轮换。 + +### 测试 + +纯 JVM 单测 + MockK 打桩 + `kotlinx-coroutines-test`(`runTest` 虚拟时间);数据库迁移测试用 Robolectric + `MigrationTestHelper`(仅 universalDebug 变体)。 diff --git "a/.monkeycode/docs/\346\250\241\345\235\227/backup.md" "b/.monkeycode/docs/\346\250\241\345\235\227/backup.md" new file mode 100644 index 00000000..793c1cf6 --- /dev/null +++ "b/.monkeycode/docs/\346\250\241\345\235\227/backup.md" @@ -0,0 +1,49 @@ +# backup — 备份恢复模块 + +加密备份与恢复全量数据:provider 配置、凭据、聊天历史、待办、检查点元数据、工作区文件与各域设置。流式实现,内存占用与数据量解耦。 + +## 结构 + +``` +feature/backup/ +├── domain/ +│ ├── BackupManager.kt # 接口:export / import / previewImport / exportSession +│ ├── BackupCrypto.kt # PBKDF2 + AES-GCM +│ └── BackupSnapshot.kt # 快照 DTO(schemaVersion 兼容性校验) +├── data/ +│ └── BackupManagerImpl.kt # 实现:跨 Room + DataStore 采集/还原 +└── presentation/ + ├── BackupSection.kt # 设置页备份 UI + └── BackupViewModel.kt +``` + +## 关键文件 + +| 文件 | 目的 | +|------|------| +| `domain/BackupManager.kt` | 导出 = 按 `BackupOptions` 采集快照 → 流式序列化(metadata.json + 各 `*.jsonl`)→ tar.gz → 口令非空则 AES-GCM 加密;导入反向,支持 `previewImport` 先校验并列出可勾选的工作区 | +| `domain/BackupCrypto.kt` | PBKDF2WithHmacSHA256(21 万迭代)派生密钥 + AES/GCM;salt+IV 写文件头;手动 64KB 分块 `Cipher.update/doFinal`(刻意避开 `CipherInputStream` 吞 GCM 校验异常的坑);口令错误抛 `BackupDecryptionException` | +| `data/BackupManagerImpl.kt` | Apache commons-compress 流式 tar.gz;跨 Room 各 DAO + 各 DataStore 设置仓库采集/还原 | +| `domain/BackupSnapshot.kt` | 与 Room Entity 同构但解耦的 `@Serializable` DTO,`schemaVersion` 做兼容性校验 | + +## 依赖 + +**本模块依赖**: +- `AgentDatabase` 全部 DAO +- 各 feature 的 DataStore 设置仓库 +- commons-compress + +**依赖本模块的**: +- `feature/settings` 的备份设置页 + +## 规范 + +### 代码模式 + +**DTO 解耦**:备份 DTO 与 Room Entity 分离(同构映射),Entity 字段演进不直接破坏旧备份兼容性;不兼容变更递增 `schemaVersion` 并在导入侧分支处理。 + +**流式**:所有序列化/压缩/加密均为流式管道,新增备份内容时保持 chunked 写入,避免整包载入内存。 + +### 加密 + +改动加密实现前先读 `BackupCrypto.kt` 注释(分块 doFinal 的原因);保持文件头格式(salt+IV)向后兼容,否则旧备份无法解密。 diff --git "a/.monkeycode/docs/\346\250\241\345\235\227/core.md" "b/.monkeycode/docs/\346\250\241\345\235\227/core.md" new file mode 100644 index 00000000..3c606495 --- /dev/null +++ "b/.monkeycode/docs/\346\250\241\345\235\227/core.md" @@ -0,0 +1,62 @@ +# core — 跨模块基础设施 + +无业务依赖的底层能力:数据库迁移加载、全局代理、主题、通用 Compose 组件、日志与工具类。全部 feature 与入口共同依赖。 + +## 结构 + +``` +core/ +├── db/ +│ ├── MigrationLoader.kt # 扫 assets/migrations 生成 Room 文件式迁移 +│ └── SqlScriptSplitter.kt # 状态机按语句切分 SQL +├── net/ +│ └── AppProxy.kt # 全局 HTTP 代理接入点 +├── theme/ +│ ├── AIEditorTheme.kt # Compose 主题入口 +│ └── AppThemePreset.kt # 多套主题预设配色 +├── ui/ # 通用 Compose 组件库 +│ ├── FloatingTabBar.kt / SegmentedTabs.kt / SplitHandle.kt +│ ├── SwipeToDeleteRow.kt / AppSwitch.kt / AppTextField.kt +│ ├── ImageViewer.kt / ImageDecode.kt +│ ├── ImeInset.kt / PageMotion.kt / WindowSize.kt +│ └── glass/ # 玻璃材质系统(三档磨砂/水玻璃/液体玻璃) +│ ├── GlassMode.kt # 枚举 + GlassSettings + LocalGlassSettings/LocalBackdrop +│ ├── GlassPanel.kt # glassPanel Modifier(三档效果链 + 染色 + API33 门槛) +│ └── WaterWaveShader.kt # 水波 AGSL(runtimeShaderEffect 经平台 RenderEffect 链入) +└── util/ + ├── FileLogger.kt # 落盘日志(按天分文件、等级阈值、7 天/5MB 清理) + ├── AILogger.kt # AI 请求/响应逐会话完整日志 + ├── GitIgnoreMatcher.kt # gitignore 风格路径段匹配 + ├── LineDiff.kt / LanguageRegistry.kt / ErrorUtils.kt / MediaRedactor.kt +``` + +## 关键文件 + +| 文件 | 目的 | +|------|------| +| `db/MigrationLoader.kt` | 扫描 `assets/migrations/{version}_{desc}.sql` 生成 Room `FileMigration`(`version-1 → version`);整段包事务、失败整体回滚,成功记入 `migration_history` 表 | +| `db/SqlScriptSplitter.kt` | 状态机切分 SQL:识别 `--` / `/* */` 注释、单双引号/反引号字面量(含 `''` 转义)、`[...]` 标识符——字符串里的 `;` 不误切 | +| `net/AppProxy.kt` | `applyGlobal` 在 `attachBaseContext` 设 JVM 全局 `ProxySelector` 与 `Authenticator`(OkHttp 407 显式认证);容器内命令经 `proxyEnv` 注入 HTTP(S)_PROXY;支持 provider 级代理按 host 分派(配合 settings 的 `ProviderProxyRegistry`) | +| `util/FileLogger.kt` | 外部私有目录按天分文件、单线程串行、等级阈值、自动清理 | +| `ui/glass/GlassPanel.kt` | 玻璃面板 `Modifier.glassPanel(shape, area)`:按 `LocalGlassSettings` 档位组装效果链(磨砂 blur / 水玻璃 blur+AGSL 水波 / 液体玻璃 vibrancy+blur+lens 色散),`onDrawSurface` 主题色染色 alpha≥0.3,API<33 透传;backdrop 源从 `LocalBackdrop` 读(MainActivity 标记壁纸层) | + +## 依赖 + +**本模块依赖**: +- Room(MigrationLoader 实现 `RoomDatabase.Callback` 风格迁移钩子) +- 无任何 feature 依赖(分层底线) + +**依赖本模块的**: +- 全部 feature 与入口 + +## 规范 + +### 代码模式 + +**迁移加载**:文件式迁移的切分正确性由 `SqlScriptSplitter` 的状态机保证;新增语 法特性(如存储过程)前先评估切分器兼容性。迁移整体流程见[开发者指南](../DEVELOPER_GUIDE.md)。 + +**组件库**:`core/ui` 只放跨 feature 复用的无业务组件;带业务状态的组件放各自 feature 的 `presentation/component/`。 + +### 分层约束 + +`core/` 不得 import 任何 `feature/` 包——出现即分层破坏,应把代码下沉为更底层能力或移入对应 feature。 diff --git "a/.monkeycode/docs/\346\250\241\345\235\227/credentials.md" "b/.monkeycode/docs/\346\250\241\345\235\227/credentials.md" new file mode 100644 index 00000000..2122f9ce --- /dev/null +++ "b/.monkeycode/docs/\346\250\241\345\235\227/credentials.md" @@ -0,0 +1,52 @@ +# credentials — Git 凭据模块 + +Git 凭据的文件化存储与容器内注入:App、容器内 git、远程服务器 git 三方共用同一份 git-credential-store 格式文件;git 缺凭据时经文件 IPC 弹窗向用户索要。 + +## 结构 + +``` +feature/credentials/ +├── domain/ +│ ├── repository/CredentialRepository.kt # 接口 +│ └── model/GitCredential.kt # host/username/token,id 即 host +├── data/ +│ ├── repository/FileCredentialRepository.kt # 文件仓库实现 +│ ├── CredentialRequestBridge.kt # helper ↔ App 文件 IPC 桥 +│ ├── CredentialInjectSettingsRepository.kt # 远程注入开关(按 host 隔离) +│ └── LegacyCredentialMigrator.kt # 旧 Room 凭据表一次性迁移 +└── presentation/component/ + ├── CredentialScreen.kt / CredentialEditorScreen.kt + ├── CredentialPromptDialog.kt + └── GlobalCredentialDialogHost.kt # 全局弹窗宿主 +``` + +## 关键文件 + +| 文件 | 目的 | +|------|------| +| `data/repository/FileCredentialRepository.kt` | 真源是文件 `filesDir/aicode/git-credentials`(容器内 `/root/.aicode/git-credentials`),git-credential-store 标准格式(每行 `https://user:token@host`);内存 StateFlow 缓存 + Mutex 原子落盘 | +| `data/CredentialRequestBridge.kt` | git 缺凭据时容器内 helper(`assets/aicode/git-credential-aicode`)写 `cred-req-` 请求文件 → `FileObserver` 捕获 → StateFlow 供全局 Compose 弹窗 → 用户回填写 `cred-resp-` → helper 轮询取走喂回 git;含低频兜底轮询、与 `LinuxContainerEngine` 在途计数联动(暂停 120s 超时杀进程 watchdog) | +| `data/CredentialInjectSettingsRepository.kt` | 「自动注入凭据到远程服务器」开关,SharedPreferences 按 `MD5(host:port)` 前 8 位每服务器隔离 | + +## 依赖 + +**本模块依赖**: +- `feature/agent` — `LinuxContainerEngine`(在途命令计数联动) +- `core/util` — `FileLogger` + +**依赖本模块的**: +- `feature/git` — Git 面板凭据配置 +- `feature/settings` — 凭据相关设置 UI +- `di/AgentModule` — SSH host/login key store + +## 规范 + +### 代码模式 + +**文件即契约**:凭据文件格式是 git-credential-store 标准,App 侧与容器内 helper 双方读写同一份;任何一方改格式都会断开另一方。 + +**IPC 生命周期**:`CredentialRequestBridge.start()` 在 `AIEditorApp.onCreate` 启动;请求/响应文件用唯一 id 配对,弹窗取消则留空让 helper 超时。 + +### 安全 + +凭据仅存 App 私有目录,备份导出走 [backup 模块](./backup.md) 加密;日志中不得打印 token。 diff --git "a/.monkeycode/docs/\346\250\241\345\235\227/editor.md" "b/.monkeycode/docs/\346\250\241\345\235\227/editor.md" new file mode 100644 index 00000000..faa74f59 --- /dev/null +++ "b/.monkeycode/docs/\346\250\241\345\235\227/editor.md" @@ -0,0 +1,46 @@ +# editor — 代码编辑器模块 + +sora-editor 的 Compose 封装:全屏编辑器页与 WorkbenchPane 嵌入两用,提供 TextMate 语法高亮、VS Code 配色、编码检测与编辑器设置。 + +## 结构 + +``` +feature/editor/ +├── domain/ +│ ├── TextMateSetup.kt # TextMate 语法/主题一次性初始化 +│ └── FileEncoding.kt # 编码检测与回写 +├── data/ +│ └── EditorSettingsRepository.kt # DataStore(editor_prefs) +└── presentation/ + ├── CodeEditorScreen.kt # AndroidView 封装 sora-editor + ├── CodeEditorViewModel.kt + └── EditorSettingsScreen.kt / EditorSettingsViewModel.kt +``` + +## 关键文件 + +| 文件 | 目的 | +|------|------| +| `domain/TextMateSetup.kt` | IO 线程加载 assets 语法包(GrammarRegistry)+ `dark_plus`/`light_plus` 主题;开启逐 1000 行推送着色;`scopeNameFor(path)` 按扩展名映射 scopeName,未打包语法走纯文本 | +| `domain/FileEncoding.kt` | BOM → 严格 UTF-8 → GBK 兜底检测;保存时用同一编码回写,防转码破坏 | +| `presentation/CodeEditorScreen.kt` | Compose `AndroidView` 封装;全屏路由与 `WorkbenchPane` 嵌入共用 | +| `data/EditorSettingsRepository.kt` | 字号/换行/缩进参考线/空白显示持久化 | + +## 依赖 + +**本模块依赖**: +- sora-editor(`io.github.rosemoe:editor` + `language-textmate`,editor-bom 0.24.6)——只取纯 JVM 模块,避开 treesitter/oniguruma 的 .so(与 ABI flavor 拆分和 F-Droid 可复现构建冲突) +- `feature/agent` — Markdown 预览复用其 `MarkdownContent` 渲染链 + +**依赖本模块的**: +- `MainActivity` / `WorkbenchPane` — 编辑器面板挂载 + +## 规范 + +### 代码模式 + +**语法高亮**:新增语言支持时在 TextMateSetup 的 assets 语法包与 `scopeNameFor` 扩展名映射处扩展;编辑器主题 JSON 由 `scripts/build-vscode-themes.py` 离线生成(`assets/textmate/`),手改会被覆盖。 + +### 依赖约束 + +引入含 native 库的编辑器增强(treesitter 等)前先确认与 ABI flavor 拆分、可复现构建的冲突——这是当初只选纯 JVM 模块的原因。 diff --git "a/.monkeycode/docs/\346\250\241\345\235\227/git.md" "b/.monkeycode/docs/\346\250\241\345\235\227/git.md" new file mode 100644 index 00000000..e0ff0efc --- /dev/null +++ "b/.monkeycode/docs/\346\250\241\345\235\227/git.md" @@ -0,0 +1,50 @@ +# git — Git 可视化模块 + +基于容器内命令行 `git`(非 JGit)的可视化 Git 面板:状态、分支、提交历史(含泳道拓扑图)、差异、标签、暂存/回退与凭据配置。 + +## 结构 + +``` +feature/git/ +├── domain/ +│ ├── GitRepository.kt # 全部 git 子命令聚合(status/branches/log/commit/push/pull...) +│ ├── GitGraphBuilder.kt # 纯 Kotlin 泳道拓扑布局(供 Canvas 绘制) +│ ├── GitErrorMessage.kt # git 英文 stderr → 中文友好提示 +│ ├── GitCommandFailureException.kt +│ └── model/ # GitModels.kt / GitGraphModels.kt 领域模型 +└── presentation/ + ├── GitViewModel.kt + └── component/ + ├── GitScreen.kt / GitStatusTab.kt / GitBranchesTab.kt / GitLogTab.kt + └── DiffViewer.kt # 语法高亮 diff 视图 +``` + +## 关键文件 + +| 文件 | 目的 | +|------|------| +| `domain/GitRepository.kt` | 经 `CommandEngine.runCommandSyncUnbounded` 执行 git(cwd=当前工作区),逐参数 shell 转义拼 `/bin/sh -c`;统一带 `-c core.quotepath=false` 防中文路径乱码。读命令 `git()`,写命令 `gitChecked()`(非零退出抛 `GitCommandFailureException` 携带 git 输出) | +| `domain/GitGraphBuilder.kt` | 解析 `git log`(0x1f 分隔字段)做泳道布局,输出 `GitGraph` 领域模型 | +| `domain/GitErrorMessage.kt` | stderr 模式匹配转译(冲突/无凭据/网络错误等) | + +## 依赖 + +**本模块依赖**: +- `feature/agent` — `CommandEngine`(容器内执行) +- `feature/settings` — 执行模式 +- `feature/credentials` — 凭据(`credential.helper=store` + 自定义 helper 注入,不经 agent 工具链/权限引擎) + +**依赖本模块的**: +- `MainActivity` / `WorkbenchPane` — Git 面板挂载 + +## 规范 + +### 代码模式 + +**命令执行**:一律走 `CommandEngine`,保持本地容器/远程 SSH 一致;拼命令必须逐参数转义,禁止拼接用户输入。 + +**错误处理**:写命令失败抛 `GitCommandFailureException`,UI 层经 `GitErrorMessage` 转中文提示展示。 + +### 与 AI 的边界 + +Git 面板的凭据配置直接读写凭据仓库,不经 AI 工具链与授权弹窗;AI 侧的 git 操作则走 `Bash` 工具受权限体系管控。 diff --git "a/.monkeycode/docs/\346\250\241\345\235\227/settings.md" "b/.monkeycode/docs/\346\250\241\345\235\227/settings.md" new file mode 100644 index 00000000..7e177cf7 --- /dev/null +++ "b/.monkeycode/docs/\346\250\241\345\235\227/settings.md" @@ -0,0 +1,64 @@ +# settings — 设置与 Provider 管理模块 + +管理 AI Provider(多 Key 轮换、每提供商代理)、执行模式切换、主题语言等全部设置。存储双轨:provider 等结构化数据进 Room,其余各设置域独立 DataStore。 + +## 结构 + +``` +feature/settings/ +├── data/ +│ ├── local/ +│ │ ├── entity/AIProviderEntity.kt # ai_providers 表 +│ │ └── dao/AIProviderDao.kt +│ │ ├── repository/ # 20+ 个 DataStore 仓库 +│ │ │ ├── ExecutionModeRepository.kt # 本地/远程模式 + 远程连接配置 +│ │ │ ├── KeepaliveSettingsRepository.kt +│ │ │ ├── ThemeSettingsRepository.kt / LanguageSettingsRepository.kt +│ │ │ ├── ProxySettingsRepository.kt / LogSettingsRepository.kt +│ │ │ ├── BackgroundSettingsRepository.kt # 背景图/磨砂 + 玻璃材质 6 键聚合 +│ │ │ └── ContainerSettingsRepository.kt / SyncSettingsRepository.kt / ... +│ ├── ExecutionModeHolder.kt # 模式内存 StateFlow 缓存(委托层分发依据) +│ ├── ProviderKeyRotator.kt # 多 Key 轮换/冷却 +│ ├── ProviderProxyRegistry.kt # provider 级代理分派 +│ └── remote/ # ModelApiService(单测+批量)/ UpdateCheckService / ContainerImageDownloader +└── presentation/component/ + ├── SettingsScreen.kt # 设置入口页 + ├── ProvidersAndLogSection.kt # Provider 管理(拖拽排序) + └── McpSettingsSection.kt / ProxySection.kt / SkillsSection.kt / SubAgentsSection.kt +``` + +## 关键文件 + +| 文件 | 目的 | +|------|------| +| `data/local/entity/AIProviderEntity.kt` | provider 配置:明文 apiKey + 多 Key 轮换字段 + 每提供商代理字段 + 自定义请求头/脚本参数 | +| `ExecutionModeHolder.kt` | 执行模式的内存 StateFlow,启动时从 repository 读首帧值;`DelegatingCommandEngine` / `DelegatingFileAccess` / `DelegatingTerminalSessionProvider` 同步读取 | +| `ProviderKeyRotator.kt` | 同一 provider 多 Key 自动轮换,失效 Key 冷却 | +| `data/remote/ModelApiService.kt` | 模型连通性测试:`testModel` 发极短 "hi" 请求验证 Key+模型+端点(OpenAI/Anthropic/Gemini 三协议);`SettingsViewModel.testAllModels` 限并发 4(`Semaphore`)批量测当前 provider 全部模型,`BatchTestState` 聚合 total/done/success,`stopAllModels` 取消 | +| `data/repository/BackgroundSettingsRepository.kt` | 背景图路径/透明度/磨砂强度 + 玻璃材质 6 键(总开关/档位/三区域开关/水波动画),`glassStateFlow` 聚合(半径 = `frost_intensity` × 32,零 Room 迁移) | +| `data/remote/UpdateCheckService.kt` | GitHub Release 更新检查 | + +## 依赖 + +**本模块依赖**: +- Room(`AIProviderEntity` 挂在 `AgentDatabase` 上) +- `core/net/AppProxy` — 全局代理应用 + +**依赖本模块的**: +- 几乎所有 feature(拿配置与执行模式)——settings 是依赖汇聚点 + +## 规范 + +### 代码模式 + +**新设置域**:每域一个独立 `preferencesDataStore` + `@Singleton` Repository(如 `EditorSettingsRepository`)。避免把所有设置塞进同一个 preferences 文件(写放大与迁移困难)。 + +**执行模式切换**:改模式必须经 `ExecutionModeRepository` → `ExecutionModeHolder`,保证三个委托层即时感知;不要在各处缓存模式快照。 + +**模型连通性测试**:单测 `ModelApiService.testModel` 发极短 "hi" 请求验证 Key+模型+端点;批量 `testAllModels(provider)` 限并发 4(`Semaphore.withPermit`)测当前 provider 模型列表全部模型,逐条复用 `_testing`/`_testResults`(每行实时反映),`BatchTestState` 聚合 total/done/success,`stopAllModels` 取消(OkHttp 阻塞调用不响应 cancel,`finally` 清残留 testing 标记兜底)。 + +**玻璃材质配置**:`BackgroundSettingsRepository` 在既有 `background_prefs` 加 6 个玻璃键 + `glassStateFlow` 聚合(复用 `frost_intensity` × 32 换算半径,零 Room 迁移);UI 侧 `glassPanel` Modifier 从 `LocalGlassSettings`/`LocalBackdrop` 读配置与壁纸源(见 [core 模块](./core.md)),三区域(侧边栏/输入框/内容面板)接线在 MainActivity 与 AIChatPanel/ChatInputBar。 + +### 敏感信息 + +`AIProviderEntity.apiKey` 明文存 Room(本地 App 私有目录);备份导出走 [backup 模块](./backup.md) 的 AES-GCM 加密。 diff --git "a/.monkeycode/docs/\346\250\241\345\235\227/terminal.md" "b/.monkeycode/docs/\346\250\241\345\235\227/terminal.md" new file mode 100644 index 00000000..e3c72bc0 --- /dev/null +++ "b/.monkeycode/docs/\346\250\241\345\235\227/terminal.md" @@ -0,0 +1,56 @@ +# terminal — 终端与执行后端模块 + +提供多标签终端会话、后台命令执行与进程保活;把「本地 PRoot 容器」与「远程 SSH」两种后端统一到同一套 Termux 会话抽象上。 + +## 结构 + +``` +feature/terminal/ +├── domain/ +│ ├── TerminalSessionProvider.kt # 终端后端接口(tab 管理 + 事件流) +│ ├── TerminalSessionManager.kt # 本地实现:进程内会话池 +│ ├── RemoteTerminalSessionManager.kt # 远程实现:sshj shell channel +│ ├── SshShellBackend.kt # 实现 Termux SessionBackend 接 sshj +│ ├── DelegatingTerminalSessionProvider.kt# 按 ExecutionModeHolder 分发 +│ ├── TerminalTab.kt # tab 数据模型(Running/Finished) +│ ├── TerminalKeepaliveService.kt # 前台 Service 保活 +│ ├── KeepaliveWorker.kt # @HiltWorker 周期兜底拉起 +│ └── font/、model/ # 字体管理、主题预设 +└── presentation/ + ├── TerminalScreen.kt / TerminalViewModel.kt + ├── TerminalClients.kt # AppTerminalSessionClient 接 session 回调 + └── TerminalSettingsSheet.kt +``` + +## 关键文件 + +| 文件 | 目的 | +|------|------| +| `domain/TerminalSessionProvider.kt` | 后端接口:`startBackgroundCommand` / `sendInput` / `getTabOutput` / `listTabs` / `closeTab` / `tabFinishedEvents` | +| `domain/TerminalSessionManager.kt` | 本地会话唯一所有者;退出码兜底监控(解析 `[command exited: N]` 标记,规避 proot 不退出导致 onFinished 不回调) | +| `domain/SshShellBackend.kt` | 远程的关键粘合层:把 sshj `Session.Shell` 包装成 Termux emulator 的输入/输出/resize | +| `domain/TerminalKeepaliveService.kt` | 前台低优先级通知;会话计数归零自动停;`onTaskRemoved` 划卡即停 | +| `domain/KeepaliveWorker.kt` | WorkManager 周期探测保活 Service 被杀则拉起 | + +## 依赖 + +**本模块依赖**: +- `:terminal-emulator` / `:terminal-view` — Termux 派生模块(仿真核心 + 渲染 View) +- `feature/agent` — `RemoteSshConnection`(共享 sshj client) +- `feature/settings` — 保活开关、执行模式 + +**依赖本模块的**: +- `feature/agent` — `TerminalSessionTool`(AI 后台终端工具) +- `MainActivity` / `WorkbenchPane` — 终端 UI 挂载 + +## 规范 + +### 代码模式 + +**统一抽象**:本地与远程共享同一个 Termux `TerminalSession` + `TerminalEmulator`,区别只在 `SessionBackend`——本地 fork PTY 进程,远程实现 `SessionBackend` 接 sshj。新增后端时实现 `SessionBackend` 并在委托层加分支。 + +**保活链路**:`AIEditorApp` 监听用户开关统一启停 `TerminalKeepaliveService`(START/STOP_SESSION 按会话计数;ENABLE/DISABLE_PERSISTENT 用户常驻)→ `KeepaliveWorker` 周期兜底 → `START_STICKY` 重建时从 `KeepaliveSettingsRepository` 恢复状态。 + +### 错误处理 + +tab 结束码以输出标记解析兜底为准(proot 限制);SSH 断线由 `RemoteSshConnection` 统一重连。 diff --git "a/.monkeycode/docs/\346\250\241\345\235\227/workspace.md" "b/.monkeycode/docs/\346\250\241\345\235\227/workspace.md" new file mode 100644 index 00000000..12550ed5 --- /dev/null +++ "b/.monkeycode/docs/\346\250\241\345\235\227/workspace.md" @@ -0,0 +1,60 @@ +# workspace — 工作区与文件访问模块 + +管理项目工作区(本地私有目录 / 远程 SSH 目录),提供统一的文件读写后端,并承担 SAF 导出、SFTP/FTP 同步与内置 FTP 服务端。 + +## 结构 + +``` +feature/workspace/ +├── domain/ +│ ├── FileAccessProvider.kt # 文件读写后端接口 +│ ├── LocalFileAccess.kt # 本地实现(fsync + 回读校验) +│ ├── RemoteSftpFileAccess.kt # 远程实现(SSH exec channel) +│ ├── DelegatingFileAccess.kt # 按执行模式分发 +│ ├── WorkspacePathMapper.kt # 容器路径 ⇄ 宿主路径(profile 感知) +│ ├── WorkspaceRepository.kt # 工作区/项目管理(data/repository/) +│ ├── UriPathResolver.kt # SAF uri → 路径 +│ └── remote/ # 同步子系统 +│ ├── SyncEngine.kt # FileObserver 监听 + gitignore + 防抖批量上传 +│ ├── SftpSyncClient.kt / FtpSyncClient.kt / LocalSyncClient.kt +│ └── FtpServerManager.kt # 内置 FTP 服务端 +├── data/ +│ ├── local/ # RemoteConnectionDao/Entity、RemoteMountEntity +│ └── provider/WorkspaceDocumentsProvider.kt # SAF DocumentsProvider +└── presentation/ # 工作区选择、远程连接管理 UI +``` + +## 关键文件 + +| 文件 | 目的 | +|------|------| +| `domain/FileAccessProvider.kt` | 接口:read / write / list / delete / copy / rename / mkdirs 等,路径统一为 AI 视角的容器路径(`~/workspace/...`) | +| `domain/RemoteSftpFileAccess.kt` | 用 SSH exec channel 执行 `cat` / 重定向 / base64 读写远程文件——刻意避开 sshj 0.38.0 SFTP 的必现 Buffer 溢出 bug | +| `domain/WorkspacePathMapper.kt` | 容器内路径 ⇄ 宿主真实路径互转:`~/workspace` → bind mount 工作区;其它容器绝对路径 → 当前 profile rootfs | +| `data/repository/WorkspaceRepository.kt` | 本地项目在 `filesDir/projects/`(ext4 支持 symlink);远程为 SSH 服务器 `remoteWorkspacePath` 子目录;当前工作区名持久化 DataStore | +| `data/provider/WorkspaceDocumentsProvider.kt` | 把 `filesDir` 暴露给系统文件 app,白名单只放 `projects/` 与 `aicode/` | +| `domain/remote/SyncEngine.kt` | 文件同步引擎:FileObserver + gitignore/自定义忽略 + 防抖批量上传 + 断线重连 | + +## 依赖 + +**本模块依赖**: +- `feature/agent` — `RemoteSshConnection` +- `feature/settings` — `ExecutionModeHolder`、容器 profile +- `core/util` — `GitIgnoreMatcher` + +**依赖本模块的**: +- `feature/agent` — AI 文件工具(readFile / writeFile / editFile / search 等) +- `feature/editor` — 文件树与编辑器读写 +- `feature/git` — 工作区目录定位 + +## 规范 + +### 代码模式 + +**路径体系**:上层一律传 AI 视角容器路径,`WorkspacePathMapper` 负责映射到宿主真实路径。直接操作宿主绝对路径会破坏容器/远程一致性。 + +**写侧完整性**:本地写文件 fsync + 回读长度校验;远程写经 base64 编码规避 shell 转义问题。 + +### 错误处理 + +远程文件操作失败统一带 stderr 上下文抛出,由 AI 工具层转为 `ToolResult.Error` 回传。 diff --git a/.monkeycode/specs/2026-09-13-frosted-glass-background/design.md b/.monkeycode/specs/2026-09-13-frosted-glass-background/design.md new file mode 100644 index 00000000..0857df5e --- /dev/null +++ b/.monkeycode/specs/2026-09-13-frosted-glass-background/design.md @@ -0,0 +1,215 @@ +# 磨砂玻璃背景技术设计 + +## 1. 描述 + +本设计扩展现有全局自定义背景能力,为背景图片增加可调模糊,并让主要内容区域呈现半透明磨砂材质。设置继续由 `BackgroundSettingsRepository` 统一存储,界面状态由 `SettingsViewModel` 暴露,全局效果由 `MainActivity` 根 Compose 图层消费。 + +设计采用渐进增强策略:支持原�生模糊时启用实时效果,不支持时仅使用半透明遮罩,不引入软件位图模糊及额外图片缓存。 + +## 2. 设计决策 + +### 2.1 使用现有 DataStore + +磨砂强度与背景路径、透明度属于同一功能域,继续存入 `background_prefs`,避免增加新的存储结构或数据库迁移。 + +### 2.2 使用单一归一化强度 + +持久化值采用 `0f..1f`。设置界面显示为 `0..100%`,渲染层将归一化值映射为模糊半径和内容面板透明度。该方式避免把设备相关 dp 值写入持久化数据。 + +### 2.3 渐进增强 + +支持 Compose 原生模糊时,在背景图片图层应用模糊修饰符。旧版或不支持环境不执行模糊,仅调整前景表面透明度,避免昂贵的软件位图处理。 + +### 2.4 最小化内容面板改造 + +第一阶段在应用根内容图层统一提供磨砂材质参数,不逐个重写所有 Card、导航栏和功能页面。若现有页面使用完全不透明背景导致效��果不可见,实施阶段仅调整主要根容器和公共表面组件,避免无关 UI 改造。 + +## 3. 架构 + +```mermaid +flowchart LR + Sheet[BackgroundImageSheet] -->|强度变化| VM[SettingsViewModel] + VM -->|节流写入| Repo[BackgroundSettingsRepository] + Repo --> Store[(background_prefs DataStore)] + Store -->|frostIntensityFlow| Activity[MainActivity] + Activity --> Background[背景图片模糊图层] + Activity --> Content[半��透明内容图层] + Activity --> Fallback[旧版半透明降级] +``` + +## 4. 组件与接口 + +### 4.1 BackgroundSettingsRepository + +文件:`app/src/main/java/com/aicode/feature/settings/data/repository/BackgroundSettingsRepository.kt` + +新增职责: + +- 定义磨砂强度 DataStore key。 +- 提供默认值、最小值和最大值。 +- 暴露 `frostIntensityFlow: Flow`。 +- 提供 `setFrostIntensity(intensity: Float)`,写入前限制到合法范围�。 +- 提供 UI 百分比与归一化值的映射函数。 + +建议数据约束: + +```text +持久化范围: 0.0 .. 1.0 +默认值: 0.5 +背景模糊半径: 0.dp .. 24.dp +``` + +最终半径可在实现时根据 Compose 和项目最低 SDK 的实际支持能力微调,但不得改变持久化语义。 + +### 4.2 SettingsViewModel + +文件:`app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt` + +新增职责: + +- 收集 `frostIntensityFlow` 并暴露��只读状态。 +- 提供 `setFrostIntensity`。 +- 复用背景透明度设置的节流模式,避免滑块拖动时产生过量 DataStore 写入。 + +### 4.3 BackgroundImageSheet + +文件:`app/src/main/java/com/aicode/feature/settings/presentation/component/BackgroundImageSheet.kt` + +新增参数: + +```kotlin +frostIntensity: Float +onFrostIntensityChange: (Float) -> Unit +``` + +交互设计: + +- 在透明度滑块下方增加磨砂强度标题、百分比和滑块。 +- 弹窗打开时使用持久化状态初始化本地滑块值。 +- 拖动时更新本地值并调用回调,实现实时预览。 +- 未选择背景图片时可以保留滑块设置,但视觉效果不启用。 + +### 4.4 SettingsScreen + +文件:`app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt` + +负责向 `BackgroundImageSheet` 传入强度状态与事件,并在背景设置摘要中继续优先展示现有背景状态,不扩大列表项信息密度��。 + +### 4.5 MainActivity 根渲染层 + +文件:`app/src/main/java/com/aicode/MainActivity.kt` + +根图层调整为以下顺序: + +1. 主题背景色。 +2. 自定义背景图片及原生模糊。 +3. 半透明材质遮罩或主要内容表面。 +4. 应用导航和页面内容。 + +背景图片仍只解码一次,模糊强度变化不得作为 `produceState` 的图片解码 key。 + +```mermaid +flowchart TB + Theme[主题背景色] + Image[背景图片 + 可选原生模�糊] + Tint[主题相关半透明遮罩] + App[AppNavigation 内容] + Theme --> Image --> Tint --> App +``` + +## 5. 数据模型 + +DataStore 新增一项: + +```text +key: frost_intensity +type: Float +range: 0.0 .. 1.0 +default: 0.5 +``` + +读取和写入均执行 `coerceIn(0f, 1f)`。现有用户没有该 key 时自动使用默认值,不需要显式迁移。 + +## 6. 平台兼容策略 + +- 使用编译期可用且能在项目最低 SDK 安全运行的 Compose API。 +- 原生模糊能力只在受支持的平台启用。 +- 不支持时不创建软件模糊位图,也不增加 RenderScript 依赖。 +- 降级路径仍使用主题相关的半透明表面色,以保证前景文本对比度。 +- Dialog 属于独立窗口,不承诺继承 Activity 根图层的模糊效果。 + +## 7. 正确性属性 + +### CP-1 范围闭包 + +对任意输入强度,存储值和渲染值始终位于 `0f..1f`。 + +### CP-2 零强度恒等性 + +当强度为�零时,背景图片不应用模糊,且不得因效果管线改变图片尺寸或裁剪方式。 + +### CP-3 状态恢复 + +写入任意合法强度后重新创建 Activity,收集到的强度必须与写入值一致。 + +### CP-4 无背景隔离 + +当背景路径为空时,磨砂设置不得创建图片解码任务或覆盖正常主题背景。 + +### CP-5 降级安全 + +在不支持原生模糊的环境中,任意合法强度不得触发崩溃,页面交互保持可用。 + +### CP-6 解码稳定 + +仅改变磨砂强度时,背景图片不得重复解码。 + +## 8. 错误处理 + +- DataStore 值非法:限制到合法范围并继续渲染。 +- 图片文件不存在或解码失败:保持现有空图片行为,不启用背景效果。 +- 原生模糊不可用:自动使用半透明降级效果。 +- 快速拖动滑块:界面状态实时变化,持久化写入通过节流合并。 +- 应用切换主题:半透明遮罩颜色跟随当前 Material 主题重新计算。 + +## 9. 测试策略 + +### 9.1 单元测试 + +- 百分比与归一化强度双向映射。 +- 小于最小值和大于最大值时的范围限制。 +- 默认值读取。 +- `setFrostIntensity` 的持久化结果。 + +### 9.2 Compose/UI 测试 + +- 背景设置弹窗显示磨砂强度标签、百分比和滑块。 +- 拖动滑块触发正确的归一化回调值。 +- 外部状态变化后滑块显示同步更新。 +- 无背景时移除按钮行为保持不变。 + +### 9.3 构建与人工验证 + +- 运行 `./gradlew :app:testUniversalDebugUnitTest`。 +- 运行 `./gradlew :app:assembleUniversalDebug`。 +- 在支持原生模糊的设备上验证 0%、中间值和 100%。 +- 在不支持原生模糊的设备或模拟器上验证半透明降级和交互稳定性。 +- 验证重启恢复、主题切换、移除并重新选择背景。 + +## 10. 风险与缓解 + +### 风险 1:内容页面存在不透明容器 + +部分页面可能使用完全不透明 Surface,导致�根层背景不可见。实施时先定位主要公共容器,仅调整阻断效果的根级表面,避免逐页面重构。 + +### 风险 2:模糊对 GPU 性能有影响 + +限制最大模糊半径,不使用软件位图模糊,且背景保持静态单次解码。 + +### 风险 3:不同 Android 版本视觉不一致 + +接受渐进增强差异,以可读性和稳定性优先。旧版明确降级为半透明效果,不承诺等效模糊。 + +## 11. 实施边界 + +本设计阶段仅创��建规格文档,不修改应用代码。后续应使用实施计划将工作拆分为存储层、状态层、设置 UI、根渲染层、资源和测试任务。 diff --git a/.monkeycode/specs/2026-09-13-frosted-glass-background/requirements.md b/.monkeycode/specs/2026-09-13-frosted-glass-background/requirements.md new file mode 100644 index 00000000..20dcea40 --- /dev/null +++ b/.monkeycode/specs/2026-09-13-frosted-glass-background/requirements.md @@ -0,0 +1,97 @@ +# 磨砂玻璃背景功能需求 + +## 1. 背景 + +当前应用支持全局自定义背景图片及透明度调节,但背景仅作为顶层水印显示,缺少模糊和半透明材质效果。本功能在保留现有背景能力的基础上,为背景图片和内容面板增加可调节的磨砂玻璃效果。 + +## 2. 范围 + +### 2.1 包含 + +- 背景图片模糊效果。 +- 内容面板半透明磨砂效果。 +- 磨砂强度调节和持久化。 +- 设置界面实时预览。 +- 不支持原生模糊的平台降级处理。 + +### 2.2 不包含 + +- 用户自定义每个页面或组件的独立磨砂强度。 +- 软件位图模糊算法。 +- 改变现有背景图片选择、复制和移除流程。 +- 对独立 Dialog 窗口强制应用全局模糊。 + +## 3. 功能需求 + +### FR-1 磨砂强度持久化 + +应用 SHALL 将磨砂强度保存到�现有背景设置 DataStore 中。 + +IF 已保存的磨砂强度缺失或超出合法范围,应用 SHALL 使用默认值或将其限制到合法范围。 + +### FR-2 强度调节 + +WHEN 用户打开背景图片设置弹窗,应用 SHALL 显示磨砂强度滑块和当前百分比。 + +WHEN 用户拖动磨砂强度滑块,应用 SHALL 立即更新界面预览,并以节流方式持久化最终值。 + +### FR-3 背景图片效果 + +WHILE 已设置背景图片且磨砂强度大于零,应��用 SHALL 对背景图片应用与强度对应的模糊效果。 + +WHEN 磨砂强度为零,应用 SHALL 显示未模糊的背景图片。 + +### FR-4 内容面板效果 + +WHILE 已设置背景图片,应用 SHALL 使主要内容区域呈现半透明材质,使背景能够适度透出且文字保持可读。 + +WHEN 用户调整磨砂强度,应用 SHALL 同步调整内容面板的磨砂视觉强度。 + +### FR-5 平台兼容 + +IF 当前 Android 版本或渲染环境不支持原生实时��模糊,应用 SHALL 跳过实时模糊并保留半透明遮罩效果。 + +IF 原生模糊应用失败,应用 SHALL 保持页面可操作,不得崩溃或阻断导航。 + +### FR-6 与现有设置兼容 + +应用 SHALL 保留现有背景图片路径和透明度设置。 + +WHEN 用户移除背景图片,应用 SHALL 停止显示背景及相关磨砂视觉效果,但 SHALL 保留用户设置的磨砂强度供后续背景复用。 + +## 4. 非功能需求 + +### NFR-1 性能 + +- 调节�滑块期间不得重复解码背景图片。 +- 模糊参数变化应通过 Compose 状态更新完成。 +- 低版本设备不得使用高成本的软件位图模糊作为降级方案。 + +### NFR-2 可用性 + +- 滑块使用 0% 到 100% 的用户可理解范围。 +- 默认强度应能体现磨砂效果,但不得明显降低文本可读性。 +- 中文和英文资源必须同步提供。 + +### NFR-3 稳定性 + +- DataStore 中的非法数值必须被限制到合法范围。 +- 背景图��片解码失败时应保持现有空背景降级行为。 +- 不支持模糊的设备应稳定显示半透明效果。 + +## 5. 验收标准 + +1. 用户可以在背景设置弹窗中看到并调节磨砂强度。 +2. 调节后效果立即更新,重启应用后设置仍然生效。 +3. 支持原生模糊的设备显示背景模糊和内容面板半透明效果。 +4. 不支持原生模糊的设备不崩溃,并显示半透明降级效果。 +5. 强度设为 0% 时不执行背景模糊。 +6. 移除背景后不再显示磨砂背景,重新选择背景时恢复已保存强度。 +7. 原有背景选择、透明度调节和移除功能保持可用。 + +## 6. EARS 与 INCOSE 检查 + +- 每项需求使用 SHALL 明确约束系统行为。 +- 事件驱动需求使用 WHEN,状态驱动需求使用 WHILE,异常路径使用 IF。 +- 每项需求只描述一个可验证的行为,不使用“尽量”“适当”等不可测试措辞。 +- 性能、兼容性和降级行为均有对应验收�条件。 diff --git a/.monkeycode/specs/2026-09-13-frosted-glass-background/tasklist.md b/.monkeycode/specs/2026-09-13-frosted-glass-background/tasklist.md new file mode 100644 index 00000000..129dd0ea --- /dev/null +++ b/.monkeycode/specs/2026-09-13-frosted-glass-background/tasklist.md @@ -0,0 +1,96 @@ +# 磨砂玻璃背景实施计划 + +- [ ] 1. 实现磨砂强度存储契约 + - [ ] 1.1 扩展 `BackgroundSettingsRepository` + - 在现有 `background_prefs` 中增加 `frost_intensity` Float key + - 定义 `0f..1f` 合法范围和默认值 `0.5f` + - 暴露 `frostIntensityFlow`,读取时限制非法值 + - 实现 `setFrostIntensity`,写入时限制非法值 + - 提供强度与 UI 百分比之间的双向映射 + - 保持移除背景时不清除磨砂强度 + - 需求:FR-1、FR-6、NFR-3 + + - [ ]* 1.2 编写存储与映射单元测试 + - 验证默认值、上下界限制和合法值持久化 + - 验证百分比与归一化值的双向映射 + - 验证移除背景不会清除磨砂强度 + - 需求:FR-1、FR-6、NFR-3 + +- [ ] 2. 接入设置界面状态与事件 + - [ ] 2.1 扩展 `SettingsViewModel` + - 收集 `frostIntensityFlow` 并暴露只读状态 + - 增加磨砂强度持久化 Job + - 实现 `setFrostIntensity`,立即更新内存状态并节流写入 DataStore + - 在 ViewModel 清理阶段正确取消待执行 Job + - 需求:FR-1、FR-2、NFR-1 + + - [ ] 2.2 将磨砂状态接入 `SettingsScreen` + - 收集 ViewModel 暴露的磨砂强度 + - 向背景设置弹窗传入强度和变更回调 + - 保持现有背景摘要、选择和移除行为不变 + - 需求:FR-2、FR-6 + +- [ ] 3. 实现磨砂强度设置交互 + - [ ] 3.1 扩展 `BackgroundImageSheet` + - 增加磨砂强度和变更回调参数 + - 在透明度滑块下方增加磨砂强度标题、百分比和滑块 + - 使用持久化状态初始化和同步本地滑块状态 + - 拖动时即时调用回调,实现实时预览 + - 背景未设置时允许预先保存强度 + - 需求:FR-2、NFR-2 + + - [ ] 3.2 增加中文和英文字符串资源 + - 增加磨砂强度标签及必要��的无障碍文案 + - 同步默认资源和 `values-zh-rCN` 资源 + - 遵循现有背景设置字符串命名约定 + - 需求:FR-2、NFR-2 + + - [ ]* 3.3 编写设置弹窗 Compose 测试 + - 验证磨砂标签、百分比和滑块可见 + - 验证拖动滑块输出正确的归一化值 + - 验证外部状态变化后滑块同步更新 + - 验证现有选择和移除行为未回归 + - 需求:FR-2、FR-6 + +- [ ] 4. 实现全局磨砂渲染 + - [ ] 4.1 重构 `MainActivity` 背景图层顺序 + - 收集磨砂强度状态 + - 将图层调整为主题背景、背景图片、半透明材质、应用内容 + - 仅在背景路径有效时创建背景图片和磨砂图层 + - 保持 `ContentScale.Crop` 和现有透明度语义 + - 确保磨砂强度不作为背景解码 `produceState` 的 key + - 需求:FR-3、FR-4、FR-6、NFR-1 + + - [ ] 4.2 应用背景图片原生模糊 + - 将归一化强度映射到最大 24.dp 的受限模糊半径 + - 强度为零时不应用模糊 + - 只在平台和 Compose 渲染环境支持时启用原生模糊 + - 不引入 RenderScript、软件位图模糊或额外图片缓存 + - 需求:FR-3、FR-5、NFR-1、NFR-3 + + - [ ] 4.3 应用内容面板半透明材质与降级效果 + - 根据当前主题和磨砂强度计算半透明表面色 + - 支持原生模糊时与背景模糊共同形成磨砂视觉 + - 不支持原生模糊时仅保留半透明遮罩 + - 无背景时保��持当前主题背景和页面视觉不变 + - 确保遮罩不拦截触摸和导航交互 + - 需求:FR-4、FR-5、FR-6、NFR-3 + +- [ ] 5. 完成核心功能集成检查 + - [ ] 5.1 检查设置与渲染链路 + - 确认背景设置弹窗、ViewModel、Repository 和 MainActivity 使用同一强度语义 + - 确认强度范围、默认值和 UI 百分比一致 + - 确认移除背景后停止渲染效果但保留强度 + - 确认仅调整强度不会触发背景图片重新解�码 + - 需求:FR-1 至 FR-6、NFR-1、NFR-3 + + - [ ]* 5.2 执行自动化测试与构建验证 + - 运行 `./gradlew :app:testUniversalDebugUnitTest` + - 运行 `./gradlew :app:assembleUniversalDebug` + - 修复本功能引入的编译错误和测试回归 + - 需求:FR-1 至 FR-6 + + - [ ]* 5.3 同步项目文档 + - 使用 `project-wiki` 同步 settings 模块文档 + - 记录新增 DataStore 设置、ViewModel 状态和全局渲染行为 + - 需求:FR-1 至 FR-6 diff --git a/.monkeycode/specs/2026-09-16-liquid-glass-materials/design.md b/.monkeycode/specs/2026-09-16-liquid-glass-materials/design.md new file mode 100644 index 00000000..66d95bb1 --- /dev/null +++ b/.monkeycode/specs/2026-09-16-liquid-glass-materials/design.md @@ -0,0 +1,189 @@ +# 玻璃材质系统技术设计 + +Feature Name: liquid-glass-materials +Updated: 2026-09-16 + +## 1. 描述 + +基于 Kyant0/AndroidLiquidGlass(backdrop)库建立三档玻璃材质系统。壁纸层标记为 backdrop 源(静态、录制一次、零逐帧开销),玻璃面板通过 `glassPanel` Modifier 消费效果链:磨砂 = `blur`;水玻璃 = `blur` + 自定义 AGSL 水波折射;液体玻璃 = `vibrancy` + `blur` + `lens`(透镜折射 + 色散)。染色表面由 `onDrawSurface` 承担,完全替代半透明背景。 + +## 2. 设计决策 + +### 2.1 复用 backdrop 库(`io.github.kyant0:backdrop:1.0.6`) + +选型结论(调研于 2026-09-16): + +| 候选 | 结论 | +| --- | --- | +| Kyant0/AndroidLiquidGlass | 采纳:3.8k stars、持续发版、Compose 原生、minSdk 21、内置 blur/lens/vibrancy | +| QmDeve/AndroidLiquidGlassView | 排除:View 体系,与 Compose 架构不匹配 | +| Haze | 排除:仅模糊,折射/透镜需自研补齐 | +| neilyich/glassmorphism-compose | 排除:功能弱 | + +**版本锁定 1.0.6**:2.0+ 编译于 compileSdk 37,与项目 AGP 8.9.3(最高支持 SDK 36)冲突;1.0.6 编译于 compileSdk 36、jvmToolchain 21,与项目完全兼容。API 表面(drawBackdrop / effects DSL / blur / lens / vibrancy / rememberLayerBackdrop / layerBackdrop)与 2.0 一致。 + +**1.0.6 与 2.0 API 差异**:1.0.6 的 effects 无 `runtimeShaderEffect`(2.0 新增)。水玻璃档改用平台 API `android.graphics.RenderEffect.createRuntimeShaderEffect(RuntimeShader, uniformShaderName)` 构造后经库的 `effect(RenderEffect)` 链入效果链——`createChainEffect` 顺序保证 RuntimeShader 采样到上游 blur 输出,与 2.0 语义等价。 + +库的效果链约束:`color filter ⇒ blur ⇒ lens` 顺序生效;`lens` 需要 `CornerBasedShape`;效果仅 Android 12+,`RuntimeShader` 类效果需 Android 13+。 + +### 2.2 平台门槛 API 33,不做降级 + +AGSL 是 Android 13 平台能力。`glassPanel` Modifier 内部 `Build.VERSION.SDK_INT < 33` 时直接透传原 Modifier,三档效果只考虑 API 33+ 一种路径,实现最简。`targetSdk 28` 锁定不受影响(平台 API 按运行时版本判断,与 targetSdk 无关;项目 `compileSdk` 已满足)。 + +### 2.3 仅壁纸层作为 backdrop 源 + +壁纸是静态内容,backdrop 源录制一次即可,玻璃面板折射零逐帧重录制开销。v1 明确不采样应用内容层(逐帧重录制 + 自反馈伪影风险)。 + +### 2.4 复用 `frost_intensity` 键,DataStore 新增 5 键 + +统一模糊半径直接复用现有键(0..1 归一化,渲染时 × 32dp),三档共用,旧设置无感迁移,零 Room 迁移。染色自动跟随 `colorScheme.surface`,不单独存键。 + +`background_prefs` 新增键: + +| 键 | 类型 | 默认 | 含义 | +| --- | --- | --- | --- | +| `glass_enabled` | Boolean | false | 玻璃总开关 | +| `glass_mode` | String | "frosted" | 档位:frosted / water / liquid | +| `glass_sidebar_enabled` | Boolean | true | 侧边栏区域开关 | +| `glass_input_enabled` | Boolean | true | 输入框区域开关 | +| `glass_content_enabled` | Boolean | true | 内容面板区域开关 | +| `water_wave_animated` | Boolean | false | 水波流动动画开关 | + +### 2.5 不做崩溃自愈 + +用户决策:不加 RenderThread SIGSEGV 崩溃标记与启动回退机制。AGSL shader 在编译期通过 `RuntimeShader` 构造校验 + 真机验证覆盖风险。 + +### 2.6 v1 不迁移既有伪玻璃组件 + +`FloatingTabBar` 等渐变模拟玻璃的组件保持不动,新系统仅覆盖壁纸 backdrop + 三区域玻璃面板,后续版本再迁移。 + +## 3. 架构 + +```mermaid +flowchart LR + Sheet["BackgroundImageSheet 设置面板"] -->|"档位/半径/开关"| VM["SettingsViewModel"] + VM -->|"节流写入"| Repo["BackgroundSettingsRepository"] + Repo --> Store[("background_prefs DataStore")] + Store -->|"glassState Flow"| Activity["MainActivity"] + Activity -->|"glassState"| Local["LocalGlassSettings CompositionLocal"] + Local --> GP["glassPanel Modifier"] + Wallpaper["壁纸 Image"] -->|"Modifier.backdrop() 源标记"| BD["Backdrop 状态"] + BD --> GP + GP --> F1["FROSTED: blur"] + GP --> F2["WATER: blur + runtimeShaderEffect(WaterWaveShader)"] + GP --> F3["LIQUID: vibrancy + blur + lens"] + F1 --> Tint["onDrawSurface 主题染色 alpha>=0.3"] + F2 --> Tint + F3 --> Tint +``` + +## 4. 组件与接口 + +### 4.1 `core/ui/glass/GlassMode.kt` + +```kotlin +enum class GlassMode { FROSTED, WATER, LIQUID } + +data class GlassSettings( + val enabled: Boolean, + val mode: GlassMode, + val sidebarEnabled: Boolean, + val inputEnabled: Boolean, + val contentEnabled: Boolean, + val radiusDp: Float, // frost_intensity * 32f + val waterWaveAnimated: Boolean, +) + +val LocalGlassSettings = staticCompositionLocalOf { GlassSettings.DISABLED } +``` + +### 4.2 `core/ui/glass/GlassPanel.kt` — glassPanel Modifier + +项目侧封装(与库自带 `Modifier.glassPanel` 命名衔接): + +```kotlin +fun Modifier.glassPanel( + backdrop: Backdrop, + shape: CornerBasedShape, + settings: GlassSettings = LocalGlassSettings.current, +): Modifier +``` + +职责: + +1. `settings.enabled == false || SDK < 33` → 返回原 Modifier(R4) +2. 按档位组装效果链(顺序遵循 color filter ⇒ blur ⇒ lens): + - FROSTED:`blur(radiusPx)` + - WATER:`blur(radiusPx)` + `runtimeShaderEffect(WaterWaveShader)` + - LIQUID:`vibrancy()` + `blur(radiusPx)` + `lens(refractionHeight = min(radiusPx / 2, shape.minCornerRadius), chromaticAberration = true)` +3. `onDrawSurface` 绘制 `colorScheme.surface.copy(alpha = max(0.3f, tintAlpha))`;LIQUID 档额外绘制边缘高光描边 +4. 区域开关判断由调用方接线处完成(Modifier 保持无状态) + +### 4.3 `core/ui/glass/WaterWaveShader.kt` — 水波 AGSL + +- AGSL 源码字符串:正弦叠加波浪对上游 shader 采样坐标做 UV 偏移(折射) +- 1.0.6 无 `runtimeShaderEffect`,水玻璃档在 `GlassPanel` 内 `remember` 一个 `android.graphics.RuntimeShader`,effects block 中 `setFloatUniform` 后用 `RenderEffect.createRuntimeShaderEffect(shader, "uContent")` + 库 `effect()` 链入;`uniformShaderName = uContent` 链式采样上游 blur 输出 +- 静态模式 `uTime` 固定值([WATER_WAVE_STATIC_TIME]),动画模式 `withFrameNanos` 时间驱动相位 + +### 4.4 `BackgroundSettingsRepository` 扩展 + +- 新增 6 个键的 Flow 与 setter(`setGlassEnabled` / `setGlassMode` / `setGlassPanelAreaEnabled` / `setWaterWaveAnimated`) +- `glassStateFlow: Flow` 聚合流:`combine` 各键 + `frostIntensityFlow` 换算 `radiusDp` +- `glass_mode` 反序列化容错:未知值回退 `FROSTED`(R5.4) + +### 4.5 `SettingsViewModel` 扩展 + +- `glassState: StateFlow`(聚合自 repository) +- 写入方法节流模式对齐现有 `frostIntensityWriteJob` 写法 + +### 4.6 MainActivity 接线 + +- 壁纸 `Image` 加 `Modifier.backdrop()`(源标记) +- 根容器包 `Backdrop(backdropState)` scope,`CompositionLocalProvider(LocalGlassSettings provides glassState)` 包裹内容 +- 侧边栏 / 输入框 / 内容面板容器分别接 `Modifier.glassPanel(...)`,区域开关在接线处短路 + +### 4.7 设置 UI(BackgroundImageSheet 扩展) + +- 总开关 `AppSwitch`(关闭时隐藏子项) +- 材质档位:`SegmentedTabs`(磨砂 / 水玻璃 / 液体玻璃) +- 半径滑条:现有磨砂强度滑条原地升级,文案改"玻璃模糊半径",0..100% ↔ 0..32dp +- 三区域开关:3 个 `AppSwitch` 行 +- 水波动画开关:仅 water 档显示 +- API < 33:显示"玻璃材质需要 Android 13+"提示行,全部设置项禁用 + +## 5. 数据模型 + +见 2.4 键表。`GlassSettings` 为不可变 data class,`DISABLED` 静态实例作为 CompositionLocal 默认值(enabled=false)。 + +## 6. 正确性属性 + +1. `radiusDp = frostIntensity * 32f`,全链路唯一换算点在 repository 聚合流 +2. 效果链顺序恒为 color filter ⇒ blur ⇒ lens / runtimeShader +3. API < 33 时 `glassPanel` 输出与输入 Modifier 恒等 +4. 染色 alpha 恒 >= 0.3 +5. `glass_mode` 解析结果恒 ∈ {FROSTED, WATER, LIQUID} +6. 区域开关为 false 时该区域无任何 backdrop 消费节点 + +## 7. 错误处理 + +| 场景 | 处理 | +| --- | --- | +| API < 33 | Modifier 透传,设置项禁用并提示系统版本要求 | +| `glass_mode` 未知值 | 回退 FROSTED | +| AGSL 构造异常 | WaterWaveShader 回退纯 blur(try-catch 一次性校验) | +| DataStore 读失败 | Flow 抛错由 `catch` 算子回退默认值(沿用仓库既有模式) | + +## 8. 测试策略 + +- 单元测试(JVM):Repository 键读写与默认值、`glass_mode` 容错解析、`radiusDp` 换算、`GlassSettings` 聚合 +- 编译验证:每里程碑冒烟编译 `./gradlew :app:assembleUniversalDebug` +- 真机验证(API 33+):三档切换视觉、三区域开关、水波动画启停、半径滑杆实时性、深浅主题可读性、重启配置保留 +- 真机验证(API 26-30):确认行为与现状一致(无玻璃、壁纸模糊照旧) + +## 9. 参考 + +[^1]: (Website) - [Kyant0/AndroidLiquidGlass GitHub](https://github.com/Kyant0/AndroidLiquidGlass) +[^2]: (Website) - [Backdrop 文档:Backdrop effects](https://kyant.gitbook.io/backdrop/api/backdrop-effects) +[^3]: (Filename#L37) - `app/src/main/java/com/aicode/feature/settings/data/repository/BackgroundSettingsRepository.kt` +[^4]: (Filename#L256) - `app/src/main/java/com/aicode/MainActivity.kt` +[^5]: (Filename#L106) - `app/src/main/java/com/aicode/core/ui/FloatingTabBar.kt` diff --git a/.monkeycode/specs/2026-09-16-liquid-glass-materials/requirements.md b/.monkeycode/specs/2026-09-16-liquid-glass-materials/requirements.md new file mode 100644 index 00000000..dd450a16 --- /dev/null +++ b/.monkeycode/specs/2026-09-16-liquid-glass-materials/requirements.md @@ -0,0 +1,92 @@ +# 玻璃材质系统(磨砂 / 水玻璃 / 液体玻璃)需求文档 + +## 介绍 + +在现有磨砂玻璃背景(`2026-09-13-frosted-glass-background`)基础上,建立三档玻璃材质系统:磨砂玻璃、水玻璃(静态水波折射)、液体玻璃(透镜折射 + 色散)。玻璃材质全局作用于侧边栏、输入框、内容面板三个区域,用户可在背景设置中配置档位、半径与区域开关。 + +- 技术底座:Kyant0/AndroidLiquidGlass(`io.github.kyant0:backdrop:2.0.1`,Compose 原生 Modifier API) +- 平台门槛:AGSL / RuntimeShader 需 Android 13(API 33)+,**不做降级**,API < 33 直接不启用玻璃效果 +- 采样范围:仅壁纸层作为 backdrop 源(静态内容,录制一次),折射壁纸 + +## 术语表 + +- **玻璃材质(GlassMaterial)**:三档视觉材质之一 — 磨砂(Frosted)/ 水玻璃(Water)/ 液体玻璃(Liquid) +- **Backdrop 源**:被玻璃面板折射采样的背景图层,本项目中为壁纸层 +- **玻璃面板(GlassPanel)**:应用 `glassPanel` Modifier 的 UI 区域(侧边栏 / 输入框 / 内容面板) +- **染色表面(Tint Surface)**:玻璃面板上叠加的主题色半透明层,保证前景文字可读 +- **水波折射**:通过自定义 AGSL RuntimeShader 对 backdrop 采样坐标做波浪形偏移产生的折射效果 + +## 需求 + +### R1 三档材质渲染 + +**用户故事**:作为用户,我想要在磨砂、水玻璃、液体玻璃三档材质之间切换,以获得从轻量到旗舰的玻璃视觉效果。 + +#### 验收标准 + +1. WHEN 玻璃总开关开启且档位为磨砂,玻璃面板 SHALL 应用 `blur(radius)` 效果并叠加主题色染色表面 +2. WHEN 档位为水玻璃,玻璃面板 SHALL 应用 `blur(radius)` 与自定义 AGSL 水波折射(`runtimeShaderEffect`)并叠加染色表面 +3. WHEN 档位为液体玻璃,玻璃面板 SHALL 应用 `vibrancy()`、`blur(radius)`、`lens(透镜折射, 色散=true)` 效果并叠加染色表面与边缘高光 +4. WHEN 半径参数变化,玻璃面板 SHALL 在 0dp 至 32dp 范围内实时反映模糊半径变化 + +### R2 全局三区域独立生效 + +**用户故事**:作为用户,我想要分别控制侧边栏、输入框、内容面板是否呈现玻璃材质,以平衡视觉效果与可读性。 + +#### 验收标准 + +1. WHEN 总开关开启且某区域开关开启,系统 SHALL 在该区域容器渲染玻璃材质 +2. WHEN 某区域开关关闭,系统 SHALL 在该区域维持现有主题背景渲染 +3. WHEN 三区域开关均关闭,系统 SHALL 不渲染任何玻璃面板(壁纸模糊等既有行为保持现状) + +### R3 水波动画可选 + +**用户故事**:作为用户,我想要水玻璃的水纹默认静止以省电,并可选开启流动动画。 + +#### 验收标准 + +1. WHILE 水玻璃档位生效且动画开关关闭,水波折射 SHALL 呈现固定相位的水纹纹理,无逐帧渲染开销 +2. WHILE 动画开关开启且玻璃面板处于可见状态,水波折射 SHALL 以时间驱动相位持续流动 +3. WHILE 玻璃面板不可见或应用处于后台,水波动画 SHALL 停止产生动画帧 + +### R4 平台门槛(不做降级) + +**用户故事**:作为用户,在低版本 Android 上我希望应用行为与现状完全一致。 + +#### 验收标准 + +1. IF 设备 API < 33,`glassPanel` Modifier SHALL 原样透传(不施加任何玻璃效果),现有壁纸模糊与半透明主题行为保持不变 +2. IF 设备 API < 33,设置界面 SHALL 显示"玻璃材质需要 Android 13+"提示并禁用全部玻璃设置项 +3. WHEN 设备 API >= 33 且总开关开启,系统 SHALL 按档位渲染完整玻璃效果 + +### R5 配置持久化与迁移 + +**用户故事**:作为用户,我的玻璃配置应在重启后保留,且现有磨砂强度设置无感延续。 + +#### 验收标准 + +1. 系统 SHALL 将玻璃总开关、档位、三区域开关、水波动画开关持久化到 `background_prefs` DataStore +2. 系统 SHALL 复用现有 `frost_intensity` 键(0..1)作为统一模糊半径来源,渲染半径 = 值 × 32dp,零 Room 迁移 +3. WHEN 用户调整半径,新值 SHALL 在旧键上持久化,旧版语义(磨砂强度百分比)同步延续 +4. IF 读取到未知的 `glass_mode` 值,系统 SHALL 回退为磨砂档 + +### R6 可读性染色 + +**用户故事**:作为用户,我希望玻璃面板上的文字在任何壁纸上都清晰可读。 + +#### 验收标准 + +1. 玻璃面板 SHALL 使用当前主题 `colorScheme.surface` 色系生成染色表面,深浅主题自动适配 +2. 染色表面 alpha SHALL 不低于 0.3,保证最弱档位下前景对比度 +3. WHEN 档位为液体玻璃,玻璃面板 SHALL 在边缘绘制 1dp 主题相关高光描边 + +### R7 设置界面 + +**用户故事**:作为用户,我想在背景图设置面板内完成全部玻璃配置。 + +#### 验收标准 + +1. 背景设置面板 SHALL 在现有磨砂强度滑条位置扩展材质档位选择(磨砂 / 水玻璃 / 液体玻璃) +2. 玻璃总开关关闭时,设置面板 SHALL 隐藏档位、半径、区域开关、水波动画等子设置项 +3. WHILE 档位为水玻璃,设置面板 SHALL 显示水波动画开关;其他档位 SHALL 隐藏该开关 +4. 所有用户可见文案 SHALL 同步写入中英双语 `strings.xml`,代码经 `stringResource` 引用 diff --git a/.monkeycode/specs/2026-09-16-liquid-glass-materials/tasklist.md b/.monkeycode/specs/2026-09-16-liquid-glass-materials/tasklist.md new file mode 100644 index 00000000..0ccafb9b --- /dev/null +++ b/.monkeycode/specs/2026-09-16-liquid-glass-materials/tasklist.md @@ -0,0 +1,51 @@ +# 玻璃材质系统实施计划 + +Feature Name: liquid-glass-materials +Updated: 2026-09-16 + +依赖顺序:M1 → M2 → M3 → M4 → M5 → M6 串行推进;每个里程碑末尾有编译/测试检查点,通过后才进入下一里程碑。 + +```text +[TODOLIST] +T1 app/build.gradle.kts 引入 io.github.kyant0:backdrop:1.0.6(锁定 1.0.6:2.0+ 需 SDK 37 与项目 AGP 8.9.3 冲突,1.0.6 编译于 SDK 36 兼容)✅ +T2 新建 core/ui/glass/GlassMode.kt(枚举 + GlassSettings + LocalGlassSettings + LocalBackdrop + GlassPanelArea + isEnabled 扩展)✅ +T3 BackgroundSettingsRepository 新增 6 键 Flow/setter + glassStateFlow 聚合(radiusDp = frost × 32)✅ +T4 SettingsViewModel 新增 glassState 聚合与写入方法 ✅ +T5 数据层单元测试(键默认值/容错解析/半径换算/聚合)✅ +T6 GlassPanel.kt glassPanel Modifier(三档效果链 + onDrawSurface 染色 + API33 门槛,backdrop 从 LocalBackdrop 读)✅ +T7 WaterWaveShader.kt 水波 AGSL(1.0.6 无 runtimeShaderEffect,改用平台 RenderEffect.createRuntimeShaderEffect + 库 effect() 链入)✅ +T8 M3 冒烟编译 ./gradlew :app:assembleUniversalDebug ✅ +T9 MainActivity 壁纸层 Modifier.layerBackdrop() 源标记 + rememberLayerBackdrop + CompositionLocalProvider ✅ +T10 侧边栏容器接线 glassPanel(窄屏 ModalDrawerSheet + 大屏 Box,容器色条件透明)✅ +T11 输入框容器接线 glassPanel(ChatInputBar L265 条件替代 background)✅ +T12 内容面板容器接线 glassPanel(AIChatPanel 消息列表 weight Box)✅ +T13 M4 编译验证 ./gradlew :app:assembleUniversalDebug ✅ +T14 BackgroundImageSheet 扩展:总开关 + 档位 SegmentedTabs + API<33 提示 ✅ +T15 BackgroundImageSheet 扩展:三区域开关 + 水波动画开关(water 档显示)✅ +T16 双语 strings.xml 新增 13 条玻璃设置文案 ✅ +T17 M5 编译验证 ./gradlew :app:assembleUniversalDebug ✅ +T18 水波动画生命周期停帧(withFrameNanos 随 Compose frame clock 自动挂起)✅ +T19 全量单测 ./gradlew :app:testUniversalDebugUnitTest ✅ +T20 真机验证包 app/build/outputs/apk/universal/debug/app-universal-debug.apk ✅(待真机三档/三区域/动画/半径/深浅主题/重启保留/API26-30 现状一致 验证) +T21 文档同步 docs-site/docs/guide/appearance.md 玻璃材质章节 ✅ +T22 prompts 同步检查(玻璃为 UI 渲染层,无 agent 工具行为变化,无需同步)✅ +T23 汇报 + 提交(Conventional Commits)⏳ +``` + +## 里程碑 + +| 里程碑 | 任务 | 检查点 | +| --- | --- | --- | +| M1 基础 | T1-T2 | T1 依赖解析成功(库兼容性检查点:minSdk 21 与项目 26 无冲突,Compose BOM 2026.01 兼容) | +| M2 数据层 | T3-T5 | T5 单测通过 | +| M3 渲染核心 | T6-T8 | T8 冒烟编译通过 | +| M4 三区域接线 | T9-T13 | T13 编译通过 | +| M5 设置 UI | T14-T17 | T17 编译通过 + 双语键齐备 | +| M6 收尾 | T18-T23 | T19 全量单测通过;T20 真机验证通过后才进入 T21 | + +## 关键约束提醒 + +- 冒烟编译只用 `:app:assembleUniversalDebug`,禁用聚合任务 +- 提交信息遵循 Conventional Commits(scope 建议 `ui` / `settings` / `core`) +- API < 33 路径行为必须与现状完全一致(回归红线) +- 用户可见文案禁用 .kt 硬编码中文,一律双语 strings.xml diff --git a/.monkeycode/specs/notification-quick-actions/design.md b/.monkeycode/specs/notification-quick-actions/design.md new file mode 100644 index 00000000..7f3db6c3 --- /dev/null +++ b/.monkeycode/specs/notification-quick-actions/design.md @@ -0,0 +1,77 @@ +# 通知快捷操作 + +Feature Name: notification-quick-actions +Updated: 2026-09-13 + +## Description + +App 在后台时,AI 等待工具权限/计划批准会发系统通知并带快捷操作按钮;点击按钮经 BroadcastReceiver 路由回 ViewModel 直接解决请求,通知随之取消。 + +## Architecture + +```mermaid +graph TD + A["ViewModel 观察 pendingRequest/pendingApproval"] --> B{"App 在后台?"} + B -- "是" --> C["AgentInteractionNotificationManager 发通知(带 action)"] + B -- "否" --> D["App 内面板处理"] + C --> E["NotificationActionReceiver"] + E --> F["manager 路由到注册的 resolver"] + F --> G["resolveToolPermission / approvePlanAndBuild"] + G --> H["取消通知"] +``` + +## Components and Interfaces + +### 1. AgentInteractionNotificationManager(单例,新文件) + +- 职责:发/取消两类交互通知;持有当前待决请求的 resolver 回调(由 ViewModel 注册)。 +- 通知 id:权限 101、计划 102(复用 `agent_complete` 通道,IMPORTANCE_HIGH)。 +- 接口: + - `notifyPermission(request: PendingToolPermission, onChoice: (PermissionChoice) -> Unit)` + - `notifyPlan(sessionTitle: String, onApprove: () -> Unit)` + - `cancelPermission() / cancelPlan()` +- 通知 action:`PendingIntent.getBroadcast` → `NotificationActionReceiver`,extras 带 action 类型;权限按钮直接携带选择(APPROVE/DENY)。 + +### 2. NotificationActionReceiver(Hilt BroadcastReceiver,新文件) + +- `@AndroidEntryPoint`,manifest 注册。 +- 收到 action → 调用 manager 的路由方法;resolver 回调已失效(App 内已解决/进程重启)时仅取消通知并 FileLogger 记录。 +- 权限选择映射:`ACTION_PERMISSION_APPROVE` → PermissionChoice.APPROVE、`ACTION_PERMISSION_DENY` → PermissionChoice.DENY(以既有 PermissionChoice 枚举为准)。 + +### 3. ViewModel 接线 + +- collect `toolPermissionManager.pendingRequest` 与 `planApprovalManager.pendingApproval`: + - 变为非空且 App 在后台(`ProcessLifecycleOwner` 状态判断,复用完成通知的前台判断)→ manager.notifyXxx 并注册 resolver 回调。 + - 变为空 → manager.cancelXxx。 +- resolver 回调实现:权限 → `resolveToolPermission(id, choice)`;计划 → `approvePlanAndBuild()`。 +- App 内解决入口(`resolveToolPermission`/`approvePlanAndBuild`/`refinePlan`/`stopAgent`)已自然把 pending 置空,collect 分支统一 cancel,无需在每处单独调。 + +### 4. Manifest + +- `NotificationActionReceiver` 注册到 `AndroidManifest.xml`(`exported="false"`)。 + +## Data Models + +- 无新表;extras 传 action 类型字符串,请求 id 由 manager 内存持有(通知与请求同生命周期)。 + +## Correctness Properties + +- 通知操作与 App 内操作走同一解决路径(`resolveToolPermission`/`approvePlanAndBuild`),状态单一事实源。 +- resolver 回调失效时 receiver 只取消通知,保证状态一致。 +- 权限请求被记忆模式自动放行时 pending 不产生,无需通知。 + +## Error Handling + +- 进程重启后残留通知:App 启动时(AIEditorApp)cancel 两类通知 id,避免僵尸按钮。 +- 通知发送失败(渠道不存在等):runCatching + FileLogger,不影响主流程。 + +## Test Strategy + +- Receiver/manager 路由:单测覆盖 resolver 失效时仅取消通知。 +- ViewModel 接线:冒烟编译 + 真机验证(后台等待 → 通知 → 点批准 → 工具继续执行)。 + +## References + +[^1]: `app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt:796` — 既有完成通知模式 +[^2]: `app/src/main/java/com/aicode/AIEditorApp.kt:381` — 通知通道创建 +[^3]: `app/src/main/java/com/aicode/feature/agent/domain/tool/ToolPermissionManager.kt:23` — pendingRequest 状态 diff --git a/.monkeycode/specs/notification-quick-actions/requirements.md b/.monkeycode/specs/notification-quick-actions/requirements.md new file mode 100644 index 00000000..a3c66a5b --- /dev/null +++ b/.monkeycode/specs/notification-quick-actions/requirements.md @@ -0,0 +1,52 @@ +# Requirements Document + +## Introduction + +通知快捷操作:AI 等待工具权限批准或计划批准且 App 在后台时,发出带快捷操作按钮的通知,用户在通知上直接批准/拒绝,减少回 App 打断,配合 AUTO/TARGET 全自动执行流。 + +## Glossary + +- **权限请求**: AI 调用需授权工具时产生的 `PendingToolPermission`,App 内以权限面板展示并等待用户选择。 +- **计划批准**: PLAN 模式下 AI 申请切换到 BUILD 时产生的 `PlanApprovalRequest`,App 内以计划审批面板展示。 +- **后台**: App 进程存活但不在前台(`ProcessLifecycleOwner` 未处于 STARTED 及以上)。 + +## Requirements + +### Requirement 1: 权限等待通知 + +**User Story:** AS 用户,I want AI 等待权限时收到通知,so that 我在通知上直接处理而不必回 App。 + +#### Acceptance Criteria + +1. WHEN AI 产生权限请求且 App 处于后台,系统 SHALL 发出含「批准」「拒绝」快捷操作按钮的通知。 +2. WHEN 通知展示,系统 SHALL 正文展示工具名与权限摘要。 +3. WHILE App 处于前台,系统 SHALL 以 App 内权限面板处理权限请求(不发快捷操作通知)。 + +### Requirement 2: 通知上执行权限选择 + +**User Story:** AS 用户,I want 点通知按钮直接批准/拒绝,so that 一键解决等待。 + +#### Acceptance Criteria + +1. WHEN 用户点击通知上的「批准」,系统 SHALL 以批准选择解决该权限请求并取消通知。 +2. WHEN 用户点击通知上的「拒绝」,系统 SHALL 以拒绝选择解决该权限请求并取消通知。 +3. IF 通知到达时对应权限请求已被解决(App 内处理过),系统 SHALL 忽略该操作且状态保持一致。 + +### Requirement 3: 计划批准通知 + +**User Story:** AS 用户,I want AI 申请切到 BUILD 时收到通知,so that 及时批准开始实施。 + +#### Acceptance Criteria + +1. WHEN AI 产生计划批准请求且 App 处于后台,系统 SHALL 发出含「批准」快捷操作按钮的通知。 +2. WHEN 用户点击通知上的「批准」,系统 SHALL 批准计划并切换到 BUILD 模式,同时取消通知。 +3. WHILE App 处于前台,系统 SHALL 以 App 内计划审批面板处理(反馈/拒绝仍回 App 内完成)。 + +### Requirement 4: 状态一致与清理 + +**User Story:** AS 用户,I want 通知与 App 内状态保持一致,so that 处理过的请求不再重复弹出。 + +#### Acceptance Criteria + +1. WHEN 权限或计划请求已在任意入口被解决,系统 SHALL 取消对应通知。 +2. WHEN 新的权限/计划请求替换旧请求(同会话串行),系统 SHALL 更新通知内容并复用同一通知 id。 diff --git a/.monkeycode/specs/permission-mode-target/design.md b/.monkeycode/specs/permission-mode-target/design.md new file mode 100644 index 00000000..b65f9325 --- /dev/null +++ b/.monkeycode/specs/permission-mode-target/design.md @@ -0,0 +1,377 @@ +# TARGET 目标驱动权限模式 + +Feature Name: permission-mode-target +Updated: 2026-09-12 + +## Description + +在现有三档权限模式(BUILD / PLAN / AUTO)之外新增第四档 **TARGET(目标驱动)**。用户进入时设定目标声明,AI 自主规划并执行工具调用直到目标达成或失败,期间免逐步弹窗授权(授权策略等同 AUTO,保留灾难命令防护与检查点回滚),由步数上限与连续失败阈值约束终止。TARGET 与现有三档完全互切。 + +**与现有模式差异**: + +| 模式 | 授权 | 终止 | 工具可进入 | 达成声明 | +|------|------|------|-----------|---------| +| BUILD | 逐步弹窗 | 无 | 是 | — | +| PLAN | 物理拦截写 | 计划批准 | 是 | — | +| AUTO | 全放行 + 灾难防护 | 仅用户手动 | 否(仅手动) | — | +| TARGET | 全放行 + 灾难防护 | 达成 / 失败 / 步数 / 用户中断 | 是(需 goal 参数) | `completeGoal` 工具 | + +## Architecture + +### 关键设计决策 + +1. **达成声明用独立 `completeGoal` 工具**:相比复用 `switchMode(mode=BUILD)`,专用工具语义明确("声明达成"与"切换模式"职责分离)、授权策略独立(`ASK` 弹窗展示 summary 供确认)、可强制 `summary` 参数。代价是内置工具数 19 → 20,需同步 `di/AgentModule.kt`、`INTERFACES.md`、`prompts/`。 +2. **阈值接入 DataStore**:初版即用 `TargetModeSettingsRepository`(分域 `target_mode_prefs`,默认 50/5)而非硬编码常量,用户可在设置页调整以适配不同复杂度任务。workflow 注入仓库取当前值。 +3. **终止判定在 workflow 层**:`ToolPermissionPolicyEngine` 只做单次工具调用的 ALLOW/DENY/ASK 判定(TARGET 复用 AUTO 逻辑),步数与失败计数由 `StatefulAgentWorkflow` 在工具循环层维护,职责不越界。 +4. **灾难拦截不计入 failCount**:`failCount` 只统计工具实际执行后返回 `ToolResult.Error` 的次数;策略引擎的 DENY(如灾难命令)是预防性阻断,非执行失败。 + +### 模式判定与执行流 + +```mermaid +flowchart TD + Enter["用户/AI 进入 TARGET"] --> SetGoal["设定目标声明 goalStatement"] + SetGoal --> Active["TARGET 激活 stepCount=0 failCount=0"] + Active --> Loop["工具循环"] + Loop --> Eval["ToolPermissionPolicyEngine.evaluate mode=TARGET"] + Eval -->|"ALLOW 同 AUTO + 灾难防护"| Exec["执行工具"] + Eval -->|"DENY 灾难命令"| Block["拦截"] + Exec --> Count{"步数/失败计数判定"} + Count -->|"成功 stepCount+1 failCount=0"| Loop + Count -->|"失败 failCount+1"| FailCheck{"failCount >= 阈值?"} + Count -->|"stepCount >= 上限"| Stop1["终止: 步数超限"] + FailCheck -->|"是"| Stop2["终止: 连续失败"] + FailCheck -->|"否"| Loop + Loop -->|"AI 调用 completeGoal"| Confirm["用户确认达成 summary"] + Confirm --> Stop3["终止: 达成 回切 BUILD"] + Loop -->|"用户切换模式"| Stop4["终止: 用户中断"] +``` + +### 授权策略复用 + +TARGET 在 `ToolPermissionPolicyEngine.evaluate()` 中的授权判定**复用 AUTO 分支逻辑**(放行全部 + `checkCatastrophicRm` 灾难防护)。终止条件(步数、连续失败)不放在策略引擎——策略引擎只负责单次工具调用的 ALLOW/DENY/ASK 判定,步数与失败计数由 `StatefulAgentWorkflow` 在工具循环层维护。 + +## Components and Interfaces + +### 1. AgentMode 枚举扩展 + +`app/src/main/java/com/aicode/feature/agent/domain/model/ChatSession.kt:6` + +```kotlin +enum class AgentMode { + BUILD, + PLAN, + AUTO, + TARGET // 目标驱动:AI 依据 goalStatement 自主执行,达成或失败即终止 +} +``` + +新增 `TARGET` 枚举值。`ChatSessionEntity.toDomain()` 已用 `runCatching { AgentMode.valueOf(mode) }` 解析,旧数据不受影响。 + +### 2. ChatSession 领域模型扩展 + +`ChatSession.kt:15` 新增字段: + +```kotlin +data class ChatSession( + // ... 现有字段 + val goalStatement: String? = null, // TARGET 模式的目标声明 + val goalTerminationReason: String? = null, // 终止原因:ACHIEVED/FAILED/STEP_LIMIT/INTERRUPTED + val goalStepCount: Int = 0, // 当前 TARGET 执行的工具调用步数 + val goalFailCount: Int = 0 // 连续失败计数 +) +``` + +### 3. ToolPermissionPolicyEngine 扩展 + +`ToolPermissionPolicyEngine.kt:64` 的 `evaluate()`: + +在现有 AUTO 分支(第 70 行)之前或并列加入 TARGET 分支。TARGET 的授权逻辑与 AUTO 完全一致(放行 + 灾难防护),可抽取共用方法: + +```kotlin +if (mode == AgentMode.AUTO || mode == AgentMode.TARGET) { + if (isShellTool(toolName, args)) { + val command = ((args["command"] ?: args["input"]) as? JsonPrimitive)?.content + if (command != null) { + val analysis = ShellCommandParser.analyze(command) + val catastrophicReason = checkCatastrophicRm(analysis.segments) + if (catastrophicReason != null) { + return EvalResult(Verdict.DENY, emptyList(), denyReason = catastrophicReason) + } + } + } + return EvalResult(Verdict.ALLOW, emptyList()) +} +``` + +> 注意:PLAN 分支(第 66 行)对 TARGET 不生效——TARGET 允许写操作。判定顺序保持 PLAN 拦截在前、AUTO/TARGET 放行在后。 + +### 4. StatefulAgentWorkflow 终止条件维护 + +`feature/agent/domain/workflow/StatefulAgentWorkflow.kt` 的工具循环层新增 TARGET 步数与失败计数: + +- 每次工具执行后 `goalStepCount + 1`,成功则 `goalFailCount = 0`,失败则 `goalFailCount + 1`。 +- `goalStepCount >= MAX_STEP_BUDGET` → 终止,`goalTerminationReason = STEP_LIMIT`。 +- `goalFailCount >= MAX_CONSECUTIVE_FAILURES` → 终止,`goalTerminationReason = FAILED`。 +- AI 声明达成(通过约定消息标记或 `switchMode` 回切 BUILD)→ 提示用户确认 → `goalTerminationReason = ACHIEVED`,切回 BUILD。 +- 用户手动切换模式 → `goalTerminationReason = INTERRUPTED`。 + +阈值经 DataStore 持久化与读取(见组件 11 `TargetModeSettingsRepository`),workflow 注入该仓库取当前值: + +```kotlin +data class TargetModeThresholds( + val maxStepBudget: Int = 50, + val maxConsecutiveFailures: Int = 5 +) +``` + +workflow 工具循环每次读取 `TargetModeSettingsRepository.thresholds.first()` 后比对 `goalStepCount` / `goalFailCount`。 + +### 5. SwitchModeTool 扩展 + +`SwitchModeTool.kt:31` 参数扩展: + +```kotlin +override val parameters: Map = mapOf( + "mode" to ToolParameter( + name = "mode", + type = ParameterType.STRING, + description = "目标模式,必须是 'PLAN'、'BUILD' 或 'TARGET'", + required = true, + enum = listOf("PLAN", "BUILD", "TARGET") + ), + "reason" to ToolParameter(/* 不变 */), + "goal" to ToolParameter( + name = "goal", + type = ParameterType.STRING, + description = "切换到 TARGET 模式时的目标声明,描述 AI 需自主达成的目标。仅 mode=TARGET 时必需", + required = false + ) +) +``` + +`executeWithContext()` 逻辑调整(`SwitchModeTool.kt:47`): + +- `mode=target` 且缺少 `goal` → 返回 `ToolResult.Error(..., "MISSING_GOAL")`。 +- `mode=target` 且有 `goal` → `chatSessionDao.upsert(sessionEntity.copy(mode = TARGET, goalStatement = goal, goalStepCount = 0, goalFailCount = 0))`。 +- AUTO 仍保持仅手动进入(第 63 行逻辑不变)。 +- 从 TARGET 通过工具切出(到 PLAN/BUILD)→ 记录 `goalTerminationReason = INTERRUPTED`。 + +> 目标**达成声明**不走 SwitchModeTool,改用专用 `completeGoal` 工具(见组件 10),语义更明确、授权策略独立。 + +### 6. completeGoal 工具(新增第 20 个内置工具) + +新增 `app/src/main/java/com/aicode/feature/agent/domain/tool/mode/CompleteGoalTool.kt`: + +```kotlin +class CompleteGoalTool @Inject constructor( + private val chatSessionDao: ChatSessionDao +) : AbstractContextualTool() { + + override val name = "completeGoal" + override val description = "声明当前 TARGET 模式的目标已达成。调用后 AiCode 会提示用户确认,确认即退出 TARGET 回到 BUILD。仅在 TARGET 模式可用。" + override val permissionPolicy = ToolPermissionPolicy.ASK + override val capabilities = setOf(ToolCapability.MODIFY_SESSION_STATE) + + override val parameters: Map = mapOf( + "summary" to ToolParameter( + name = "summary", + type = ParameterType.STRING, + description = "目标达成总结,说明完成的工作与最终状态,展示给用户确认", + required = true + ) + ) +} +``` + +`executeWithContext()`: +- 非 TARGET 模式调用 → `ToolResult.Error(..., "NOT_IN_TARGET_MODE")`。 +- TARGET 模式且带 `summary` → 标记 `goalTerminationReason = ACHIEVED`,切回 BUILD,返回成功提示用户确认。 +- `buildPermissionRequest()` 展示 summary 供用户确认是否达成。 + +注册于 `di/AgentModule.kt`(内置工具清单从 19 → 20),同步 `INTERFACES.md` 内置工具表。 + +### 7. TargetModeSettingsRepository(DataStore) + +`feature/settings/data/repository/` 新增 `TargetModeSettingsRepository`,DataStore 分域 `target_mode_prefs`: + +```kotlin +data class TargetModeThresholds( + val maxStepBudget: Int = 50, + val maxConsecutiveFailures: Int = 5 +) + +class TargetModeSettingsRepository @Inject constructor( + private val dataStore: DataStore +) { + val thresholds: Flow = dataStore.data.map { prefs -> + TargetModeThresholds( + maxStepBudget = prefs[MAX_STEP_BUDGET] ?: 50, + maxConsecutiveFailures = prefs[MAX_CONSECUTIVE_FAILURES] ?: 5 + ) + } + suspend fun setMaxStepBudget(v: Int) + suspend fun setMaxConsecutiveFailures(v: Int) + private companion object { val MAX_STEP_BUDGET = intPreferencesKey("max_step_budget"); val MAX_CONSECUTIVE_FAILURES = intPreferencesKey("max_consecutive_failures") } +} +``` + +`StatefulAgentWorkflow` 注入该仓库,工具循环读取当前阈值。设置页新增阈值调整入口(见组件 9 UI)。 + +### 8. 数据库迁移 + +`AgentDatabase.kt:37` `SCHEMA_VERSION` 从 52 递增到 53。 + +新增文件 `app/src/main/assets/migrations/53_add_target_mode_fields.sql`: + +```sql +ALTER TABLE chat_sessions ADD COLUMN goalStatement TEXT; +ALTER TABLE chat_sessions ADD COLUMN goalTerminationReason TEXT; +ALTER TABLE chat_sessions ADD COLUMN goalStepCount INTEGER NOT NULL DEFAULT 0; +ALTER TABLE chat_sessions ADD COLUMN goalFailCount INTEGER NOT NULL DEFAULT 0; +``` + +由 `MigrationLoader` + `SqlScriptSplitter` 按语句切分执行,成功记入 `migration_history`。现有会话的新列默认值为 NULL / 0,向后兼容。 + +`ChatSessionEntity` 同步新增对应字段(`mode` 仍为 String,存 `AgentMode.TARGET.name`)。 + +### 9. 提示词资产 + +新增 `app/src/main/assets/prompts/82-target-mode.md`,命名接续 `81-auto-mode.md`(90- 段是子代理基线,82 落在模式段内)。 + +提示词内容要点(面向 LLM): +- 当前处于 TARGET 模式,目标声明为 `{goalStatement}`。 +- 可自主调用工具执行,无需等待逐步授权。 +- 目标达成后调用 `completeGoal(summary=...)` 声明完成并退出。 +- 连续失败或步数将尽时,主动总结进度并建议用户介入。 +- 仍受灾难命令防护(递归删除根目录类 rm 被物理拦截)。 + +加载机制复用现有 `prompts/` 分片组装(按模式注入对应分片)。 + +### 10. UI 入口与状态条 + +- 模式切换入口(现有 BUILD/PLAN/AUTO 选择器)新增 TARGET 选项。 +- TARGET 选中时展示目标声明输入界面(文本框 + 确认)。 +- 会话顶部状态条持续展示:目标声明摘要 + `stepCount / MAX_STEP_BUDGET` 步数 + `failCount`。 +- 终止时弹出终止原因提示(达成 / 失败 / 步数超限 / 中断)。 +- 设置页新增 TARGET 阈值调整入口(最大步数 / 连续失败阈值),读写 `TargetModeSettingsRepository`。 + +### 11. 双语文案 + +`values/strings.xml` 与 `values-en/strings.xml` 新增(命名语义化英文小写下划线): + +| key | 中文 | 英文 | +|-----|------|------| +| `mode_target` | 目标驱动 | Target | +| `mode_target_goal_hint` | 描述 AI 需达成的目标 | Describe the goal for AI to achieve | +| `mode_target_step_count` | 步数 %1$d / %2$d | Steps %1$d / %2$d | +| `mode_target_terminated_achieved` | 目标已达成 | Goal achieved | +| `mode_target_terminated_failed` | 因连续失败终止 | Terminated due to consecutive failures | +| `mode_target_terminated_step_limit` | 因步数超限终止 | Terminated due to step limit | +| `mode_target_terminated_interrupted` | 已中断目标执行 | Goal execution interrupted | + +代码用 `stringResource(R.string.xxx)` 引用,禁止 `.kt` 硬编码中文。 + +## Data Models + +### ChatSessionEntity 变更 + +| 字段 | 类型 | 默认 | 说明 | +|------|------|------|------| +| `goalStatement` | String? | NULL | 目标声明 | +| `goalTerminationReason` | String? | NULL | ACHIEVED/FAILED/STEP_LIMIT/INTERRUPTED | +| `goalStepCount` | Int | 0 | 工具调用步数 | +| `goalFailCount` | Int | 0 | 连续失败计数 | + +`mode` 字段不变(String,存枚举 name),新增 `TARGET` 取值。 + +### 终止原因枚举 + +`feature/agent/domain/model/` 新增: + +```kotlin +enum class GoalTerminationReason { + ACHIEVED, // 目标达成(completeGoal 工具触发) + FAILED, // 连续失败 + STEP_LIMIT, // 步数超限 + INTERRUPTED // 用户中断(手动切换模式) +} +``` + +### TargetModeSettings(DataStore) + +| 配置项 | DataStore key | 类型 | 默认 | 说明 | +|--------|--------------|------|------|------| +| 最大步数上限 | `max_step_budget` | Int | 50 | 单次 TARGET 执行工具调用上限 | +| 连续失败阈值 | `max_consecutive_failures` | Int | 5 | 连续工具失败达此值即终止 | + +## Correctness Properties + +- **授权等价性**:TARGET 单次工具调用的 ALLOW/DENY 判定结果与 AUTO 完全一致(同一代码路径)。 +- **终止可达性**:任一 TARGET 会话必在三类终止条件之一触发后退出,不会无限执行(步数上限是硬约束)。 +- **检查点不丢**:TARGET 退出(含中断)后,已产生的检查点快照保留,可回滚。 +- **旧数据兼容**:迁移后现有 BUILD/PLAN/AUTO 会话的 `mode` 值不被篡改,新列为 NULL/0 默认值。 +- **AUTO 进入约束不变**:AUTO 仍仅手动进入,TARGET 不影响 AUTO 的既有约束。 +- **灾难防护不降级**:TARGET 保留 `checkCatastrophicRm` 全部规则。 + +## Error Handling + +| 场景 | 处理 | +|------|------| +| `switchMode` 缺 `goal` 进 TARGET | 返回 `ToolResult.Error(..., "MISSING_GOAL")`,提示提供目标 | +| 目标声明为空进 TARGET | UI 拒绝激活,提示填写 | +| 连续失败达阈值 | 终止,`goalTerminationReason=FAILED`,通知用户失败原因 | +| 步数超限 | 终止,`goalTerminationReason=STEP_LIMIT`,提示目标未在预算内达成 | +| 灾难命令 | 策略引擎 DENY,`failCount` 不计入(拦截非执行失败) | +| 工具执行异常 | 计入 `failCount`,沿用 workflow 事件流上报 | +| 迁移失败 | `MigrationLoader` 整体回滚,启动报错 | + +> 设计决策:灾难命令拦截(DENY)不计入 `failCount`——`failCount` 只统计工具实际执行后返回 `ToolResult.Error` 的次数,拦截是预防性阻断非执行失败。 + +## Test Strategy + +| 测试对象 | 方式 | 覆盖 | +|---------|------|------| +| `ToolPermissionPolicyEngine` TARGET 分支 | JVM 单测 + MockK | TARGET 放行、灾难命令 DENY、与 AUTO 等价 | +| `SwitchModeTool` | JVM 单测 | `mode=target` 缺 goal 报错、有 goal 持久化、从 TARGET 切出记录中断 | +| `completeGoal` | JVM 单测 | TARGET 内声明达成、非 TARGET 拒绝、缺 summary 报错 | +| `TargetModeSettingsRepository` | JVM 单测 | 默认 50/5、读写持久化、Flow 即时生效 | +| 步数/失败计数 | `kotlinx-coroutines-test` `runTest` | 步数超限终止、连续失败终止、成功重置 failCount | +| 数据库迁移 53 | Robolectric + `MigrationTestHelper`(universalDebug) | 新列存在、旧数据兼容、默认值正确 | +| 迁移对账 | `python3 scripts/check_migrations.py` | 编号连续、SCHEMA_VERSION 一致 | + +验证命令: + +```bash +./gradlew :app:assembleUniversalDebug +./gradlew :app:testUniversalDebugUnitTest +python3 scripts/check_migrations.py +``` + +## References + +- `app/src/main/java/com/aicode/feature/agent/domain/model/ChatSession.kt:6` — AgentMode 枚举 +- `app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt:64` — evaluate 与 AUTO 分支 +- `app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt:165` — checkCatastrophicRm 灾难防护 +- `app/src/main/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeTool.kt:31` — 参数定义 +- `app/src/main/java/com/aicode/feature/agent/data/local/entity/ChatSessionEntity.kt:20` — mode 字段 +- `app/src/main/java/com/aicode/feature/agent/data/local/database/AgentDatabase.kt:37` — SCHEMA_VERSION +- `app/src/main/assets/prompts/80-plan-mode.md` / `81-auto-mode.md` — 提示词命名规范 +- `.monkeycode/docs/专有概念/Agent权限模式.md` — 权限模式概念 +- `.monkeycode/docs/INTERFACES.md:146` — 权限与授权接口 +- `CLAUDE.md` — 资产同步硬规则、迁移连续编号约束 + +## 实施步骤摘要 + +1. `AgentMode` 加 `TARGET` + `GoalTerminationReason` 枚举 + `ChatSession` 加 4 字段 +2. `ChatSessionEntity` 加 4 字段 + `toDomain`/`fromDomain` 映射 +3. `AgentDatabase.SCHEMA_VERSION` → 53 + 新建 `53_add_target_mode_fields.sql` +4. `ToolPermissionPolicyEngine` AUTO 分支扩展为含 TARGET(共用灾难防护) +5. `SwitchModeTool` 参数加 `goal`、enum 加 TARGET、`executeWithContext` 逻辑扩展 +6. 新增 `CompleteGoalTool`(第 20 个内置工具)+ `di/AgentModule.kt` 注册 +7. 新增 `TargetModeSettingsRepository`(DataStore,默认 50/5) +8. `StatefulAgentWorkflow` 注入阈值仓库,工具循环加步数/失败计数与终止判定 +9. 新建 `prompts/82-target-mode.md` +10. UI 模式选择器加 TARGET + 目标输入界面 + 顶部状态条 + 设置页阈值入口 +11. 双语 `strings.xml` 新增文案 +12. `docs-site/docs/` 补用户文档 + 侧栏索引 +13. 单测 + 冒烟编译 + 迁移对账 diff --git a/.monkeycode/specs/permission-mode-target/requirements.md b/.monkeycode/specs/permission-mode-target/requirements.md new file mode 100644 index 00000000..970bfe8c --- /dev/null +++ b/.monkeycode/specs/permission-mode-target/requirements.md @@ -0,0 +1,185 @@ +# 需求文档:TARGET 目标驱动模式 + +## 简介 + +在现有三档权限模式(BUILD / PLAN / AUTO)之外,新增第四档 **TARGET(目标驱动)** 模式。用户进入该模式时设定一个明确目标,AI 自主规划并执行工具调用直到目标达成或失败,期间免逐步弹窗授权,但保留灾难性命令防护与检查点回滚。TARGET 与现有三档完全互切。 + +**动机**:处理多步骤复杂任务时,BUILD 逐步授权打断执行流,AUTO 无终止条件易失控。TARGET 填补「自主执行 + 明确终止」的中间地带,并由专用 `completeGoal` 工具声明达成、DataStore 可配置阈值约束终止。 + +## 术语表 + +- **TARGET 模式**:目标驱动权限模式。AI 依据目标声明自主执行,达成或失败即终止。 +- **目标声明(Goal Statement)**:进入 TARGET 模式时由用户输入或 AI 提炼、用户确认的明确目标描述,持久化到会话。 +- **终止条件(Termination Condition)**:触发 TARGET 模式退出的判定,包括目标达成、连续失败、最大步数上限、用户手动切换。 +- **最大步数上限(Max Step Budget)**:单次 TARGET 执行允许的工具调用次数上限,超出即判定失败终止。 +- **检查点(Checkpoint)**:文件改动前的自动快照,见 [检查点概念](../../docs/专有概念/检查点.md)。 +- **灾难命令防护**:对递归删除根目录类 `rm` 等高危命令的物理拦截,AUTO 与 TARGET 共用。 +- **AgentMode**:领域模型,`ChatSession` 携带的权限模式枚举(BUILD / PLAN / AUTO / TARGET)。 + +## 现状参考 + +| 方面 | 现有实现 | 位置 | +|------|---------|------| +| 模式定义 | `AgentMode` 枚举(BUILD/PLAN/AUTO) | `feature/agent/domain/model/` | +| 策略引擎 | `ToolPermissionPolicyEngine.resolve()` 判定 ALLOW/DENY/ASK | `feature/agent/domain/permission/ToolPermissionPolicyEngine.kt` | +| 模式切换工具 | `switchMode`(PLAN/BUILD,AUTO 仅手动) | `feature/agent/domain/tool/mode/SwitchModeTool.kt` | +| 内置工具数 | 19 个(TARGET 需新增 `completeGoal` 为第 20 个) | `feature/agent/domain/tool/` + `di/AgentModule.kt` | +| 计划批准流 | `PlanApprovalManager` | `feature/agent/domain/` | +| 持久化 | `ChatSessionEntity` 模式字段 | `feature/agent/data/local/entity/ChatSessionEntity.kt` | +| 提示词 | `80-plan-mode.md`、`81-auto-mode.md` | `app/src/main/assets/prompts/` | +| 灾难防护 | AUTO 模式 rm 防护 | `ToolPermissionPolicyEngine.kt` | +| 检查点 | 每条消息建节点、改前抓快照 | `feature/agent/domain/checkpoint/CheckpointManager.kt` | + +## 需求 + +### 需求 1:进入 TARGET 模式并设定目标 + +**用户故事**:作为开发者,我希望设定一个明确目标让 AI 自主达成,以便处理多步骤复杂任务时不必逐步授权。 + +#### 验收标准 + +1. WHEN 用户选择 TARGET 模式,AiCode SHALL 提示用户输入目标声明或确认 AI 提炼的目标声明。 +2. IF 目标声明为空,AiCode SHALL 拒绝进入 TARGET 模式并提示用户填写目标。 +3. WHILE 处于 TARGET 模式,AiCode SHALL 将目标声明持久化到所属会话。 +4. WHEN 用户从 BUILD 或 PLAN 或 AUTO 切换到 TARGET,AiCode SHALL 要求设定目标声明后激活 TARGET。 +5. WHEN AI 通过 `switchMode` 工具请求切换到 TARGET,AiCode SHALL 经授权策略校验后提示用户提供目标声明。 + +### 需求 2:自主执行与终止条件 + +**用户故事**:作为开发者,我希望 TARGET 模式在达成或失败时自动终止,以便资源不被无限占用且结果可控。 + +#### 验收标准 + +1. WHILE 处于 TARGET 模式,AiCode SHALL 免逐步弹窗授权执行工具调用(授权策略等同 AUTO)。 +2. WHILE 处于 TARGET 模式,AiCode SHALL 保留灾难性命令防护(递归删除根目录类 `rm` 物理拦截)。 +3. WHEN AI 声明目标已达成并经用户确认,AiCode SHALL 退出 TARGET 模式回到 BUILD。 +4. IF 连续工具调用失败次数达到连续失败阈值,AiCode SHALL 停止执行并将失败原因通知用户。 +5. IF 单次 TARGET 执行的工具调用次数达到最大步数上限,AiCode SHALL 停止执行并提示用户目标未在预算内达成。 +6. WHEN TARGET 模式因终止条件退出,AiCode SHALL 记录终止原因(达成 / 失败 / 步数超限 / 用户中断)到会话。 + +### 需求 3:模式互切与中断 + +**用户故事**:作为开发者,我希望在任意模式间切换到 TARGET,并能随时中断目标执行切回其他模式。 + +#### 验收标准 + +1. WHEN 用户从 TARGET 切换到 BUILD 或 PLAN 或 AUTO,AiCode SHALL 中止当前目标执行并记录中断点。 +2. WHEN 用户从 BUILD 切换到 TARGET,AiCode SHALL 进入目标设定流程。 +3. WHEN 用户从 PLAN 切换到 TARGET,AiCode SHALL 将已批准计划作为候选目标声明。 +4. WHEN 用户从 AUTO 切换到 TARGET,AiCode SHALL 要求设定目标声明以约束后续执行。 +5. WHILE 处于 TARGET 模式且用户中断执行,AiCode SHALL 保留已产生的检查点供回滚。 + +### 需求 4:检查点与回滚 + +**用户故事**:作为开发者,我希望目标模式执行期间保留检查点,以便自主改动可整体回滚。 + +#### 验收标准 + +1. WHILE 处于 TARGET 模式,AiCode SHALL 对每个文件改动创建检查点快照。 +2. WHEN 用户在 TARGET 会话结束后选择回滚,AiCode SHALL 提供按目标会话维度的整体回滚。 +3. WHILE 处于 TARGET 模式,AiCode SHALL 在目标达成时创建聚合检查点节点标记整体改动范围。 + +### 需求 5:数据持久化与迁移 + +**用户故事**:作为开发者,我希望目标模式的目标声明与执行状态持久化,以便重启后可恢复与审计。 + +#### 验收标准 + +1. WHILE 会话处于 TARGET 模式,AiCode SHALL 持久化目标声明、终止原因与步数计数到 `ChatSessionEntity`。 +2. WHEN 数据库升级到目标 schema 版本,AiCode SHALL 通过文件式迁移脚本为 `chat_sessions` 表扩展目标相关字段。 +3. WHEN 迁移执行,AiCode SHALL 保证现有 BUILD/PLAN/AUTO 会话的模式字段值不被篡改。 +4. IF 迁移脚本编号不连续,AiCode SHALL 经 `check_migrations.py` 拦截并报错。 + +### 需求 6:提示词与资产同步 + +**用户故事**:作为开发者,我希望 TARGET 模式有专属提示词指导 AI 行为,并随代码同步。 + +#### 验收标准 + +1. WHEN TARGET 模式启用,AiCode SHALL 加载 `app/src/main/assets/prompts/82-target-mode.md` 提示词分片。 +2. WHILE 处于 TARGET 模式,AiCode SHALL 向 LLM 注入目标声明与终止条件约束。 +3. WHEN TARGET 模式行为调整,AiCode SHALL 同步更新 `82-target-mode.md` 与 `prompts/` 下相关索引。 +4. WHEN TARGET 模式上线,AiCode SHALL 在 `docs-site/docs/` 补充用户使用文档并加入侧栏索引。 + +### 需求 7:UI 入口与双语文案 + +**用户故事**:作为用户,我希望在模式切换入口看到 TARGET 选项,并以我的语言呈现文案。 + +#### 验收标准 + +1. WHEN 用户打开模式切换入口,AiCode SHALL 展示 BUILD / PLAN / AUTO / TARGET 四个选项。 +2. WHEN TARGET 选项被选中,AiCode SHALL 展示目标声明输入界面。 +3. WHILE 处于 TARGET 模式,AiCode SHALL 在会话顶部持续展示当前目标声明与步数计数。 +4. WHEN TARGET 相关用户可见文案新增,AiCode SHALL 写入 `values/strings.xml`(中文)与 `values-en/strings.xml`(英文)并以 `stringResource(R.string.xxx)` 引用。 +5. IF `.kt` 文件中出现硬编码中文 UI 文案,AiCode SHALL 视为缺陷。 + +### 需求 8:SwitchModeTool 扩展(TARGET 进入) + +**用户故事**:作为 AI,我希望通过 `switchMode` 工具切换到 TARGET 并设定目标,以便在对话中自主进入目标驱动执行。 + +#### 验收标准 + +1. WHEN `switchMode` 工具收到 `mode=target` 参数,AiCode SHALL 经授权策略校验后激活 TARGET。 +2. WHEN `switchMode` 工具收到 `goal` 参数,AiCode SHALL 将其作为目标声明持久化。 +3. IF `switchMode` 工具收到 `mode=target` 但缺少 `goal` 参数,AiCode SHALL 返回工具错误提示要求提供目标。 +4. WHEN 用户从 TARGET 通过 `switchMode` 切换到其他模式,AiCode SHALL 记录中断并保留检查点。 + +### 需求 9:completeGoal 工具(目标达成声明) + +**用户故事**:作为 AI,我希望通过专用工具声明目标已达成,以便语义明确地结束 TARGET 执行并提示用户确认。 + +#### 验收标准 + +1. WHEN AI 在 TARGET 模式调用 `completeGoal` 工具,AiCode SHALL 展示达成总结并提示用户确认。 +2. WHEN 用户确认达成,AiCode SHALL 退出 TARGET 模式回到 BUILD 并记录 `goalTerminationReason = ACHIEVED`。 +3. IF `completeGoal` 工具在非 TARGET 模式被调用,AiCode SHALL 返回工具错误提示当前不在目标模式。 +4. WHEN `completeGoal` 工具被调用,AiCode SHALL 要求提供 `summary` 参数描述完成的工作与最终状态。 +5. WHEN `completeGoal` 工具注册,AiCode SHALL 在内置工具清单中登记为第 20 个工具并同步 `INTERFACES.md`。 + +### 需求 10:阈值 DataStore 可配置 + +**用户故事**:作为用户,我希望调整最大步数与连续失败阈值,以便适配不同复杂度的目标任务。 + +#### 验收标准 + +1. WHEN 用户打开设置页 TARGET 配置入口,AiCode SHALL 展示最大步数与连续失败阈值当前值(默认 50 / 5)。 +2. WHEN 用户修改阈值,AiCode SHALL 经 DataStore 持久化并即时生效于后续 TARGET 执行。 +3. WHILE 处于 TARGET 执行中,AiCode SHALL 读取当前阈值用于终止判定。 + +## 范围边界 + +### 包含 + +- `AgentMode` 枚举新增 TARGET + `GoalTerminationReason` 枚举 +- `ToolPermissionPolicyEngine` 扩展 TARGET 分支(授权策略等同 AUTO) +- `SwitchModeTool` 扩展 `goal` 参数 +- 新增 `completeGoal` 工具(第 20 个内置工具)声明目标达成 +- `ChatSessionEntity` 新增目标相关字段 + 迁移脚本 +- `TargetModeSettingsRepository`(DataStore,阈值默认 50/5,可配置) +- `prompts/82-target-mode.md` 新增 +- UI 模式切换入口新增 TARGET 选项 + 目标输入界面 + 会话顶部状态条 + 设置页阈值入口 +- 双语 `strings.xml` 文案 +- `docs-site` 用户文档 + +### 不包含 + +- 目标声明的自然语言解析与拆解(AI 自行在对话中理解,本需求只做存储与注入) +- 跨会话的目标复用(每个 TARGET 会话独立) +- 目标达成的自动判定算法(依赖 AI 声明 + 用户确认) +- 子代理在 TARGET 模式下的特殊调度(沿用现有子代理机制) + +## 约束 + +- `targetSdk` 锁定 28(项目硬约束,本需求不触碰)。 +- 迁移脚本编号必须连续,接续当前 `SCHEMA_VERSION` 最大编号。 +- `prompts/` 与 `docs-site/` 同步是硬规则(见 CLAUDE.md 资产同步)。 +- 用户可见中文文案必须进双语 `strings.xml`。 + +## 验收方式 + +- 纯 JVM 单测 + MockK 打桩覆盖 `ToolPermissionPolicyEngine` 的 TARGET 分支判定。 +- `SwitchModeTool` 与 `completeGoal` 工具参数校验与模式守卫单测。 +- `TargetModeSettingsRepository` DataStore 读写单测。 +- 数据库迁移测试用 Robolectric + `MigrationTestHelper`(universalDebug 变体)。 +- `check_migrations.py` 对账通过。 +- 冒烟编译 `./gradlew :app:assembleUniversalDebug` 通过。 diff --git a/.monkeycode/specs/permission-mode-target/tasklist.md b/.monkeycode/specs/permission-mode-target/tasklist.md new file mode 100644 index 00000000..9628adf0 --- /dev/null +++ b/.monkeycode/specs/permission-mode-target/tasklist.md @@ -0,0 +1,38 @@ +# 实施任务清单:TARGET 目标驱动权限模式 + +依据 `design.md` 的实施步骤摘要拆解。每完成一项勾选 `[x]`。 + +## 任务列表 + +- [x] **T1** AgentMode 枚举加 `TARGET` + 新增 `GoalTerminationReason` 枚举 + `ChatSession` 加 4 字段(goalStatement / goalTerminationReason / goalStepCount / goalFailCount) + - `app/src/main/java/com/aicode/feature/agent/domain/model/ChatSession.kt:6` +- [x] **T2** `ChatSessionEntity` 加 4 字段 + `toDomain`/`fromDomain` 映射 + - `app/src/main/java/com/aicode/feature/agent/data/local/entity/ChatSessionEntity.kt:14` +- [x] **T3** `AgentDatabase.SCHEMA_VERSION` 52 → 53 + 新建 `53_add_target_mode_fields.sql`(迁移对账通过:8..53 共 46 个,编号连续) + - `app/src/main/java/com/aicode/feature/agent/data/local/database/AgentDatabase.kt:37` + - `app/src/main/assets/migrations/53_add_target_mode_fields.sql` +- [x] **T4** `ToolPermissionPolicyEngine` AUTO 分支扩展为含 TARGET(共用灾难防护) + - `app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt:70` +- [x] **T5** `SwitchModeTool` 参数加 `goal`、enum 加 `TARGET`、`executeWithContext` 逻辑扩展(含从 TARGET 切出记录 INTERRUPTED) + - `app/src/main/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeTool.kt:31` +- [x] **T6** 新增 `CompleteGoalTool`(第 20 个内置工具)+ `di/AgentModule.kt` 注册 + - `app/src/main/java/com/aicode/feature/agent/domain/tool/mode/CompleteGoalTool.kt`(新建) +- [x] **T7** 新增 `TargetModeSettingsRepository`(DataStore 分域 `target_mode_prefs`,默认 50/5) + - `app/src/main/java/com/aicode/feature/settings/data/repository/TargetModeSettingsRepository.kt`(新建) +- [x] **T8** `StatefulAgentWorkflow` 注入阈值仓库 + ChatSessionDao,工具循环加步数/失败计数与终止判定 + `buildModeReminder`/`buildModeSwitchNotice` 加 TARGET 分支 + - `app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt` +- [x] **T9** 新建 `prompts/82-target-mode.md` 提示词 + - `app/src/main/assets/prompts/82-target-mode.md`(新建) +- [~] **T10** UI:模式选择器加 TARGET + 目标输入对话框(已完成);会话顶部状态条 + 设置页阈值入口(待后续增强) +- [x] **T11** 双语 `strings.xml` 新增文案(values/ + values-en/) +- [x] **T12** `docs-site/docs/guide/modes.md` 更新为四模式 + `overview.md` 索引更新 +- [ ] **T13** 单测 + 冒烟编译 `./gradlew :app:assembleUniversalDebug` + 迁移对账(迁移对账已通过;编译/单测受阻于环境缺 JDK/Android SDK) + +## 依赖与顺序 + +T1 → T2 → T3(模型与迁移先行,编译期 schema 校验依赖) +T4 / T5 / T6 相互独立,可并行 +T7 → T8(workflow 依赖阈值仓库) +T9 / T10 / T11 资源层,依赖代码层完成 +T12 文档,独立 +T13 验证,全部代码完成后执行 diff --git a/.monkeycode/specs/session-search/design.md b/.monkeycode/specs/session-search/design.md new file mode 100644 index 00000000..3adf41f2 --- /dev/null +++ b/.monkeycode/specs/session-search/design.md @@ -0,0 +1,83 @@ +# 会话内搜索 + +Feature Name: session-search +Updated: 2026-09-13 + +## Description + +两个搜索入口:会话列表顶部的标题过滤框,与聊天区顶部的消息全文搜索面板。消息搜索查全部已落库历史(Room LIKE 查询),点击结果跳转定位并高亮。 + +## Architecture + +```mermaid +graph TD + A["会话列表搜索框"] --> B["sessionTitleFilter 状态"] + B --> C["会话条目按标题过滤"] + D["标题栏搜索按钮"] --> E["消息搜索面板"] + E --> F["searchMessages(query)"] + F --> G["AgentMessageDao.searchMessages"] + G --> H["结果列表"] + H --> I["jumpToMessage(id)"] + I --> J["listState.scrollToItem + 高亮"] +``` + +## Components and Interfaces + +### 1. 会话列表标题过滤 + +- 位置:会话页(侧边栏)列表顶部,新增搜索框。 +- `AIAgentViewModel` 新增 `sessionTitleFilter: MutableStateFlow` 与 setter;会话列表渲染处按 `title.contains(filter, ignoreCase = true)` 过滤。 +- 过滤仅作用于显示,搜索词按会话切换保留全局状态(清空即恢复)。 + +### 2. 消息搜索面板 + +- 位置:聊天顶部标题栏新增搜索图标,点击展开消息搜索面板(AnimatedVisibility,复用悬浮层面板样式)。 +- 面板:搜索输入框 + 结果列表(LazyColumn,至多 50 条)。 +- 结果条目:角色图标(用户/助手/工具)+ 匹配片段(命中词附近 ±40 字符,命中词高亮色)+ 相对时间。 + +### 3. 数据层 + +- `AgentMessageDao` 新增: +```kotlin +@Query("SELECT id, role, content, timestamp FROM agent_messages WHERE sessionId = :sessionId AND content LIKE '%' || :query || '%' ESCAPE '\\' ORDER BY timestamp DESC LIMIT :limit") +suspend fun searchMessages(sessionId: String, query: String, limit: Int): List +``` +- `AgentMessageSearchRow` 数据类(id/role/content/timestamp);查询前由 ViewModel 对 `query` 做 LIKE 转义(`\%`、`\_`、`\\`)。 +- 结果投影只取 4 列,避免整条消息(含 thinkingBlocksJson 大字段)进内存。 + +### 4. ViewModel + +- `searchMessages(query: String)`:转义 → DAO 查询 → 映射为 UI 结果(含片段裁剪)。 +- `jumpToMessage(id: String)`:在 `messages`(已加载分页)中找 index;未命中则按需扩大 `_messageLimit` 并等待流刷新后重试(至多 2 轮);命中后 `listState.scrollToItem(index)` 并设置 `highlightMessageId`。 +- `highlightMessageId` 状态:跳转时设置,5 秒后自动清除。 + +### 5. 高亮渲染 + +- `MessageBubbles` / 消息渲染处接收 `highlightMessageId: String?`,命中的气泡背景叠加高亮色(tertiaryContainer)。 + +## Data Models + +- `AgentMessageSearchRow(id: String, role: String, content: String, timestamp: Long)` +- `MessageSearchResult(id, role, snippet: String, timestamp: Long)` + +## Correctness Properties + +- 搜索仅查已落库消息;正在流式输出的未落库文本不出现在结果中。 +- LIKE 转义保证 `%`、`_`、`\` 按字面量匹配。 +- 跳转的 index 与 LazyColumn item 顺序一致(chatItems 顺序)。 + +## Error Handling + +- 查询为空 → 清空结果面板。 +- DAO 查询失败 → FileLogger 记录,结果面板显示空态。 +- 跳转目标经 2 轮扩页仍未命中 → 提示「消息可能已被清理」。 + +## Test Strategy + +- DAO 查询:Room in-memory 测试覆盖 LIKE 命中、转义、limit、排序(如基建允许)。 +- ViewModel:搜索转义函数纯单测;跳转扩页逻辑依赖 Compose 状态,冒烟编译 + 真机验证。 + +## References + +[^1]: `app/src/main/java/com/aicode/feature/agent/data/local/dao/AgentMessageDao.kt` — 既有分页查询 +[^2]: `app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt` — 消息列表与 chatItems diff --git a/.monkeycode/specs/session-search/requirements.md b/.monkeycode/specs/session-search/requirements.md new file mode 100644 index 00000000..b49980b5 --- /dev/null +++ b/.monkeycode/specs/session-search/requirements.md @@ -0,0 +1,53 @@ +# Requirements Document + +## Introduction + +会话内搜索:在会话列表按标题过滤会话,并在当前会话内按全文搜索历史消息、点击跳转定位并高亮,解决「会话和消息多了以后找回之前的内容只能靠翻」的痛点。 + +## Glossary + +- **会话列表**: AI 页侧边栏「会话页」中的会话条目列表。 +- **消息全文搜索**: 在当前会话的全部已落库历史消息(用户/助手/工具结果)中按内容包含匹配。 +- **跳转定位**: 滚动消息列表使目标消息进入视口并高亮显示。 + +## Requirements + +### Requirement 1: 会话标题过滤 + +**User Story:** AS 用户,I want 在会话列表按标题过滤会话,so that 快速找到目标会话。 + +#### Acceptance Criteria + +1. WHEN 用户在会话列表搜索框输入文本,系统 SHALL 按标题不区分大小写包含匹配过滤会话条目。 +2. WHEN 用户清空搜索框文本,系统 SHALL 显示全部会话。 +3. WHILE 搜索框有文本,系统 SHALL 对新建会话等列表操作保持正常(过滤仅作用于显示)。 + +### Requirement 2: 消息全文搜索 + +**User Story:** AS 用户,I want 在当前会话内搜索历史消息,so that 找回之前的内容。 + +#### Acceptance Criteria + +1. WHEN 用户提交搜索查询,系统 SHALL 在当前会话全部已落库消息中按内容不区分大小写包含匹配,返回至多 50 条结果。 +2. WHEN 返回搜索结果,系统 SHALL 每条结果展示角色、匹配片段与相对时间。 +3. WHILE AI 正在流式输出,系统 SHALL 允许搜索已落库历史消息。 +4. IF 查询命中空字符转义(`%`、`_`),系统 SHALL 按字面量匹配。 + +### Requirement 3: 跳转定位与高亮 + +**User Story:** AS 用户,I want 点击搜索结果跳转到对应消息,so that 看到完整上下文。 + +#### Acceptance Criteria + +1. WHEN 用户点击某条搜索结果,系统 SHALL 滚动消息列表使该消息进入视口并高亮显示。 +2. IF 目标消息未在当前已加载分页中,系统 SHALL 扩大消息加载范围直至包含该消息后跳转。 +3. WHEN 高亮显示超过 5 秒或用户滚动列表,系统 SHALL 清除高亮。 + +### Requirement 4: 空态与无结果 + +**User Story:** AS 用户,I want 搜索无结果时得到明确反馈,so that 知道如何调整查询。 + +#### Acceptance Criteria + +1. IF 搜索查询无命中,系统 SHALL 显示无结果提示。 +2. IF 当前会话无历史消息,系统 SHALL 显示空会话提示且搜索入口不可用。 diff --git a/aicode-docs.zip b/aicode-docs.zip new file mode 100644 index 00000000..21a8b2f7 Binary files /dev/null and b/aicode-docs.zip differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cbb0c57c..c86443e1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -282,6 +282,9 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") implementation("androidx.compose.animation:animation") + // 液体玻璃材质(backdrop:blur/lens/AGSL 效果链)——锁定 1.0.6:compileSdk 36 与项目 AGP 8.9.3 兼容(2.0+ 要求 SDK 37) + implementation("io.github.kyant0:backdrop:1.0.6") + // Vico 图表(Token 统计趋势图) implementation("com.patrykandpatrick.vico:compose-m3:2.4.4") diff --git a/app/schemas/com.aicode.feature.agent.data.local.database.AgentDatabase/53.json b/app/schemas/com.aicode.feature.agent.data.local.database.AgentDatabase/53.json new file mode 100644 index 00000000..83f89853 --- /dev/null +++ b/app/schemas/com.aicode.feature.agent.data.local.database.AgentDatabase/53.json @@ -0,0 +1,919 @@ +{ + "formatVersion": 1, + "database": { + "version": 53, + "identityHash": "bfdbfde9f7ce93eb035a804a3b0b84e5", + "entities": [ + { + "tableName": "agent_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sessionId` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `toolCallsJson` TEXT, `toolCallId` TEXT, `toolName` TEXT, `toolArgs` TEXT, `isError` INTEGER NOT NULL, `reasoning` TEXT, `signature` TEXT, `attachmentsJson` TEXT, `isCompacted` INTEGER NOT NULL, `isContextSummary` INTEGER NOT NULL, `isCompactionMarker` INTEGER NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `thinkingBlocksJson` TEXT, `cachedInputTokens` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toolCallsJson", + "columnName": "toolCallsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "toolCallId", + "columnName": "toolCallId", + "affinity": "TEXT" + }, + { + "fieldPath": "toolName", + "columnName": "toolName", + "affinity": "TEXT" + }, + { + "fieldPath": "toolArgs", + "columnName": "toolArgs", + "affinity": "TEXT" + }, + { + "fieldPath": "isError", + "columnName": "isError", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reasoning", + "columnName": "reasoning", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "attachmentsJson", + "columnName": "attachmentsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "isCompacted", + "columnName": "isCompacted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isContextSummary", + "columnName": "isContextSummary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCompactionMarker", + "columnName": "isCompactionMarker", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thinkingBlocksJson", + "columnName": "thinkingBlocksJson", + "affinity": "TEXT" + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_agent_messages_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_agent_messages_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ] + }, + { + "tableName": "chat_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `workspacePath` TEXT NOT NULL, `mode` TEXT NOT NULL, `reasoningEffort` TEXT NOT NULL, `providerId` TEXT, `model` TEXT, `totalInputTokens` INTEGER NOT NULL, `totalOutputTokens` INTEGER NOT NULL, `lastInputTokens` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `parentId` TEXT, `subagentType` TEXT, `goalStatement` TEXT, `goalTerminationReason` TEXT, `goalStepCount` INTEGER NOT NULL, `goalFailCount` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workspacePath", + "columnName": "workspacePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reasoningEffort", + "columnName": "reasoningEffort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "providerId", + "columnName": "providerId", + "affinity": "TEXT" + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "totalInputTokens", + "columnName": "totalInputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalOutputTokens", + "columnName": "totalOutputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastInputTokens", + "columnName": "lastInputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "subagentType", + "columnName": "subagentType", + "affinity": "TEXT" + }, + { + "fieldPath": "goalStatement", + "columnName": "goalStatement", + "affinity": "TEXT" + }, + { + "fieldPath": "goalTerminationReason", + "columnName": "goalTerminationReason", + "affinity": "TEXT" + }, + { + "fieldPath": "goalStepCount", + "columnName": "goalStepCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "goalFailCount", + "columnName": "goalFailCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chat_sessions_workspacePath", + "unique": false, + "columnNames": [ + "workspacePath" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_sessions_workspacePath` ON `${TABLE_NAME}` (`workspacePath`)" + } + ] + }, + { + "tableName": "ai_providers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `apiKey` TEXT NOT NULL, `multiKeyEnabled` INTEGER NOT NULL, `apiKeys` TEXT NOT NULL, `keyRotationStrategy` TEXT NOT NULL, `keyFailoverThreshold` INTEGER NOT NULL, `keyCooldownMinutes` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `defaultModel` TEXT NOT NULL, `models` TEXT NOT NULL, `selectedModel` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `useFullUrl` INTEGER NOT NULL, `useResponseApi` INTEGER NOT NULL, `anthropicCacheBreakpoints` INTEGER NOT NULL, `openaiChatCacheKey` INTEGER NOT NULL, `balanceScriptPath` TEXT NOT NULL, `balanceRefreshInterval` INTEGER NOT NULL, `customHeaders` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `proxyEnabled` INTEGER NOT NULL, `proxyType` TEXT NOT NULL, `proxyHost` TEXT NOT NULL, `proxyPort` INTEGER NOT NULL, `proxyUsername` TEXT NOT NULL, `proxyPassword` TEXT NOT NULL, `scriptParams` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiKey", + "columnName": "apiKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "multiKeyEnabled", + "columnName": "multiKeyEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiKeys", + "columnName": "apiKeys", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyRotationStrategy", + "columnName": "keyRotationStrategy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyFailoverThreshold", + "columnName": "keyFailoverThreshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCooldownMinutes", + "columnName": "keyCooldownMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultModel", + "columnName": "defaultModel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "models", + "columnName": "models", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedModel", + "columnName": "selectedModel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useFullUrl", + "columnName": "useFullUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useResponseApi", + "columnName": "useResponseApi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "anthropicCacheBreakpoints", + "columnName": "anthropicCacheBreakpoints", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "openaiChatCacheKey", + "columnName": "openaiChatCacheKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceScriptPath", + "columnName": "balanceScriptPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceRefreshInterval", + "columnName": "balanceRefreshInterval", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customHeaders", + "columnName": "customHeaders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyEnabled", + "columnName": "proxyEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyType", + "columnName": "proxyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyHost", + "columnName": "proxyHost", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyPort", + "columnName": "proxyPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyUsername", + "columnName": "proxyUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyPassword", + "columnName": "proxyPassword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptParams", + "columnName": "scriptParams", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "remote_connections", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `protocol` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `authType` TEXT NOT NULL, `authData` TEXT NOT NULL, `passphrase` TEXT, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authType", + "columnName": "authType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authData", + "columnName": "authData", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "remote_mounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `connectionId` TEXT NOT NULL, `remotePath` TEXT NOT NULL, `localMountPath` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `autoConnect` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`connectionId`) REFERENCES `remote_connections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connectionId", + "columnName": "connectionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localMountPath", + "columnName": "localMountPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoConnect", + "columnName": "autoConnect", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_remote_mounts_connectionId", + "unique": false, + "columnNames": [ + "connectionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_mounts_connectionId` ON `${TABLE_NAME}` (`connectionId`)" + } + ], + "foreignKeys": [ + { + "table": "remote_connections", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "connectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "todo_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sessionId` TEXT NOT NULL, `subject` TEXT NOT NULL, `description` TEXT NOT NULL, `status` TEXT NOT NULL, `priority` INTEGER NOT NULL, `order` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "session_checkpoints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sessionId` TEXT NOT NULL, `userMessageId` TEXT NOT NULL, `promptSnippet` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userMessageId", + "columnName": "userMessageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "promptSnippet", + "columnName": "promptSnippet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_session_checkpoints_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_session_checkpoints_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ] + }, + { + "tableName": "checkpoint_file_snapshots", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `checkpointId` TEXT NOT NULL, `filePath` TEXT NOT NULL, `snapshotRelativePath` TEXT NOT NULL, `changeType` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checkpointId", + "columnName": "checkpointId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "snapshotRelativePath", + "columnName": "snapshotRelativePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeType", + "columnName": "changeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_checkpoint_file_snapshots_checkpointId", + "unique": false, + "columnNames": [ + "checkpointId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_checkpoint_file_snapshots_checkpointId` ON `${TABLE_NAME}` (`checkpointId`)" + } + ] + }, + { + "tableName": "llm_call_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sessionId` TEXT, `providerId` TEXT, `model` TEXT, `reasoningEffort` TEXT, `kind` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `cacheCreationTokens` INTEGER NOT NULL, `ttfbMillis` INTEGER, `durationMillis` INTEGER, `status` TEXT NOT NULL, `errorMessage` TEXT, `stopReason` TEXT, `retryCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "providerId", + "columnName": "providerId", + "affinity": "TEXT" + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "reasoningEffort", + "columnName": "reasoningEffort", + "affinity": "TEXT" + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cacheCreationTokens", + "columnName": "cacheCreationTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ttfbMillis", + "columnName": "ttfbMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "durationMillis", + "columnName": "durationMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "stopReason", + "columnName": "stopReason", + "affinity": "TEXT" + }, + { + "fieldPath": "retryCount", + "columnName": "retryCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_llm_call_records_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_llm_call_records_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + }, + { + "name": "index_llm_call_records_provider_model", + "unique": false, + "columnNames": [ + "providerId", + "model" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_llm_call_records_provider_model` ON `${TABLE_NAME}` (`providerId`, `model`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bfdbfde9f7ce93eb035a804a3b0b84e5')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0b91e53d..c69b5889 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -71,6 +71,11 @@ android:enabled="true" android:exported="false" android:foregroundServiceType="dataSync" /> + + diff --git a/app/src/main/assets/api.official.json b/app/src/main/assets/api.official.json index 6113c498..084cfb85 100644 --- a/app/src/main/assets/api.official.json +++ b/app/src/main/assets/api.official.json @@ -1 +1 @@ -{"anthropic":{"models":{"claude-sonnet-4-6":{"id":"claude-sonnet-4-6","name":"Claude Sonnet 4.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","max"]},{"type":"budget_tokens","min":1024}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":3,"output":15,"cache_read":0.3,"cache_write":3.75}},"claude-opus-5":{"id":"claude-opus-5","name":"Claude Opus 5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-opus-4-5":{"id":"claude-opus-4-5","name":"Claude Opus 4.5 (latest)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]},{"type":"budget_tokens","min":1024}],"limit":{"context":200000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-fable-5-1":{"id":"claude-fable-5-1","name":"Claude Fable 5.1","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":10,"output":50,"cache_read":0.25,"cache_write":12.5}},"claude-opus-4-6":{"id":"claude-opus-4-6","name":"Claude Opus 4.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","max"]},{"type":"budget_tokens","min":1024}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-sonnet-4-5-20250929":{"id":"claude-sonnet-4-5-20250929","name":"Claude Sonnet 4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":1024}],"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":3,"output":15,"cache_read":0.3,"cache_write":3.75}},"claude-opus-4-7":{"id":"claude-opus-4-7","name":"Claude Opus 4.7","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-haiku-4-5-20251001":{"id":"claude-haiku-4-5-20251001","name":"Claude Haiku 4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":1024}],"limit":{"context":200000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1,"output":5,"cache_read":0.1,"cache_write":1.25}},"claude-fable-5":{"id":"claude-fable-5","name":"Claude Fable 5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":10,"output":50,"cache_read":1,"cache_write":12.5}},"claude-haiku-4-5":{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5 (latest)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":1024}],"limit":{"context":200000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1,"output":5,"cache_read":0.1,"cache_write":1.25}},"claude-sonnet-4-5":{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5 (latest)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":1024}],"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":3,"output":15,"cache_read":0.3,"cache_write":3.75}},"claude-opus-4-8":{"id":"claude-opus-4-8","name":"Claude Opus 4.8","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-sonnet-5":{"id":"claude-sonnet-5","name":"Claude Sonnet 5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":10,"cache_read":0.2,"cache_write":2.5}},"claude-opus-4-5-20251101":{"id":"claude-opus-4-5-20251101","name":"Claude Opus 4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]},{"type":"budget_tokens","min":1024}],"limit":{"context":200000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-opus-4-1":{"id":"claude-opus-4-1","name":"Claude Opus 4.1 (latest)","tool_call":true,"reasoning":true,"limit":{"context":200000,"output":32000},"modalities":{"input":["text","image","pdf"]}},"claude-opus-4-1-20250805":{"id":"claude-opus-4-1-20250805","name":"Claude Opus 4.1","tool_call":true,"reasoning":true,"limit":{"context":200000,"output":32000},"modalities":{"input":["text","image","pdf"]}}}},"deepseek":{"models":{"deepseek-v4-flash-vision-exp":{"id":"deepseek-v4-flash-vision-exp","name":"DeepSeek V4 Flash Vision Exp","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"]},"cost":{"input":0.15,"output":0.6,"reasoning":0.6,"cache_read":0.003}},"deepseek-v4-flash":{"id":"deepseek-v4-flash","name":"DeepSeek V4 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"]},"cost":{"input":0.15,"output":0.6,"reasoning":0.6,"cache_read":0.003}},"deepseek-v4-pro":{"id":"deepseek-v4-pro","name":"DeepSeek V4 Pro","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.435,"output":0.87,"reasoning":0.87,"cache_read":0.003625}},"deepseek-flash":{"id":"deepseek-flash","name":"DeepSeek V4.1 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"]},"cost":{"input":0.15,"output":0.6,"reasoning":0.6,"cache_read":0.003}},"deepseek-chat":{"id":"deepseek-chat","name":"DeepSeek Chat","tool_call":true,"reasoning":false,"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028}},"deepseek-reasoner":{"id":"deepseek-reasoner","name":"DeepSeek Reasoner","tool_call":true,"reasoning":true,"reasoning_options":[],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.14,"output":0.28,"reasoning":0.28,"cache_read":0.0028}}}},"google":{"models":{"gemma-4-26b-a4b-it":{"id":"gemma-4-26b-a4b-it","name":"Gemma 4 26B A4B IT","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image"]}},"gemini-3.1-pro-preview-customtools":{"id":"gemini-3.1-pro-preview-customtools","name":"Gemini 3.1 Pro Preview Custom Tools","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"tiers":[{"input":4,"output":18,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4}}},"gemini-3.1-flash-lite-image":{"id":"gemini-3.1-flash-lite-image","name":"Nano Banana 2 Lite","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","high"]}],"limit":{"context":65536,"output":65536},"modalities":{"input":["text","image"]},"cost":{"input":0.25,"output":30}},"lyria-3-clip-preview":{"id":"lyria-3-clip-preview","name":"Lyria 3 Clip Preview","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image"]},"cost":{"input":0,"output":0}},"gemini-2.5-flash-image":{"id":"gemini-2.5-flash-image","name":"Nano Banana","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.3,"output":30,"cache_read":0.075}},"deep-research-max-preview-04-2026":{"id":"deep-research-max-preview-04-2026","name":"Deep Research Max Preview (Apr-21-2026)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"tiers":[{"input":4,"output":18,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4}}},"gemini-3-pro-image":{"id":"gemini-3-pro-image","name":"Nano Banana Pro","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high"]}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":2,"output":120}},"gemini-3.1-pro-preview":{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"tiers":[{"input":4,"output":18,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4}}},"deep-research-preview-04-2026":{"id":"deep-research-preview-04-2026","name":"Deep Research Preview (Apr-21-2026)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"tiers":[{"input":4,"output":18,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4}}},"gemini-2.5-flash-lite":{"id":"gemini-2.5-flash-lite","name":"Gemini 2.5 Flash-Lite","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","min":512,"max":24576}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","audio","video","pdf"]},"cost":{"input":0.1,"output":0.4,"cache_read":0.01,"input_audio":0.3}},"gemini-2.5-computer-use-preview-10-2025":{"id":"gemini-2.5-computer-use-preview-10-2025","name":"Gemini 2.5 Computer Use Preview 10-2025","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image"]},"cost":{"input":1.25,"output":10,"tiers":[{"input":2.5,"output":15,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":15}}},"gemini-3.6-flash":{"id":"gemini-3.6-flash","name":"Gemini 3.6 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.75,"output":3.75,"cache_read":0.075,"input_audio":0.75}},"gemini-3.1-flash-lite":{"id":"gemini-3.1-flash-lite","name":"Gemini 3.1 Flash Lite","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.25,"output":1.5,"cache_read":0.025,"input_audio":0.5}},"gemini-3.1-flash-live-preview":{"id":"gemini-3.1-flash-live-preview","name":"Gemini 3.1 Flash Live Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.75,"output":4.5,"input_audio":3,"output_audio":12}},"gemini-2.5-pro-preview-tts":{"id":"gemini-2.5-pro-preview-tts","name":"Gemini 2.5 Pro Preview TTS","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":16384},"modalities":{"input":["text"]},"cost":{"input":1,"output":20}},"gemini-3.5-flash":{"id":"gemini-3.5-flash","name":"Gemini 3.5 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":1.5,"output":9,"cache_read":0.15,"input_audio":1.5}},"veo-3.1-generate-preview":{"id":"veo-3.1-generate-preview","name":"Veo 3.1","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":480,"output":8192},"modalities":{"input":["text","image"]}},"gemini-3.1-flash-lite-preview":{"id":"gemini-3.1-flash-lite-preview","name":"Gemini 3.1 Flash Lite Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.25,"output":1.5,"cache_read":0.025,"input_audio":0.5}},"veo-3.1-fast-generate-preview":{"id":"veo-3.1-fast-generate-preview","name":"Veo 3.1 fast","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":480,"output":8192},"modalities":{"input":["text","image","video"]}},"gemini-2.5-flash-preview-tts":{"id":"gemini-2.5-flash-preview-tts","name":"Gemini 2.5 Flash Preview TTS","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":10}},"gemini-embedding-001":{"id":"gemini-embedding-001","name":"Gemini Embedding 001","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":2048,"output":1},"modalities":{"input":["text"]},"cost":{"input":0.15,"output":0}},"gemini-3.1-flash-image":{"id":"gemini-3.1-flash-image","name":"Nano Banana 2","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","high"]}],"limit":{"context":65536,"output":65536},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":0.5,"output":60}},"gemini-3.5-flash-lite":{"id":"gemini-3.5-flash-lite","name":"Gemini 3.5 Flash Lite","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.3,"output":2.5,"cache_read":0.03}},"gemini-3-pro-image-preview":{"id":"gemini-3-pro-image-preview","name":"Nano Banana Pro","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":2,"output":120}},"gemini-3.1-flash-tts-preview":{"id":"gemini-3.1-flash-tts-preview","name":"Gemini 3.1 Flash TTS Preview","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":8192,"output":16384},"modalities":{"input":["text"]},"cost":{"input":1,"output":20}},"veo-3.1-lite-generate-preview":{"id":"veo-3.1-lite-generate-preview","name":"Veo 3.1 lite","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":480,"output":8192},"modalities":{"input":["text","image"]}},"gemini-flash-lite-latest":{"id":"gemini-flash-lite-latest","name":"Gemini Flash-Lite Latest","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.3,"output":2.5,"cache_read":0.03}},"gemma-4-31b-it":{"id":"gemma-4-31b-it","name":"Gemma 4 31B IT","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image"]}},"gemini-embedding-2":{"id":"gemini-embedding-2","name":"Gemini Embedding 2","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":8192,"output":1},"modalities":{"input":["text","image","audio","video","pdf"]},"cost":{"input":0.2,"output":0,"input_audio":6.5}},"gemini-3-flash-preview":{"id":"gemini-3-flash-preview","name":"Gemini 3 Flash Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"input_audio":1}},"gemini-3.8-flash":{"id":"gemini-3.8-flash","name":"Gemini 3.8 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.75,"output":3.75,"cache_read":0.075,"input_audio":0.75}},"gemini-3.5-live-translate-preview":{"id":"gemini-3.5-live-translate-preview","name":"Gemini 3.5 Live Translate Preview","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":32768},"modalities":{"input":["audio"]},"cost":{"input":3.5,"output":21,"input_audio":3.5,"output_audio":21}},"lyria-3-pro-preview":{"id":"lyria-3-pro-preview","name":"Lyria 3 Pro Preview","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image"]},"cost":{"input":0,"output":0}},"gemini-3.7-flash":{"id":"gemini-3.7-flash","name":"Gemini 3.7 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.75,"output":3.75,"cache_read":0.075,"input_audio":0.75}},"gemini-2.5-pro":{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":128,"max":32768}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","audio","video","pdf"]},"cost":{"input":1.25,"output":10,"cache_read":0.125,"tiers":[{"input":2.5,"output":15,"cache_read":0.25,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":15,"cache_read":0.25}}},"gemini-flash-latest":{"id":"gemini-flash-latest","name":"Gemini Flash Latest","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.75,"output":3.75,"cache_read":0.075,"input_audio":0.75}},"gemini-3.1-flash-image-preview":{"id":"gemini-3.1-flash-image-preview","name":"Nano Banana 2","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","high"]}],"limit":{"context":65536,"output":65536},"modalities":{"input":["text","image","pdf"]},"cost":{"input":0.5,"output":60}},"gemini-omni-flash-preview":{"id":"gemini-omni-flash-preview","name":"Gemini Omni Flash Preview","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":1.5,"output":17.5}},"gemini-2.5-flash":{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","min":0,"max":24576}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","audio","video","pdf"]},"cost":{"input":0.3,"output":2.5,"cache_read":0.03,"input_audio":1}},"gemini-robotics-er-1.6-preview":{"id":"gemini-robotics-er-1.6-preview","name":"Gemini Robotics-ER 1.6 Preview","tool_call":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","min":0}],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":1,"output":5,"input_audio":2}},"gemini-2.0-flash":{"id":"gemini-2.0-flash","name":"Gemini 2.0 Flash","tool_call":true,"reasoning":false,"limit":{"context":1048576,"output":8192},"modalities":{"input":["text","image","audio","video","pdf"]}},"gemini-3-pro-preview":{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro Preview","tool_call":true,"reasoning":true,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]}},"gemini-2.0-flash-lite":{"id":"gemini-2.0-flash-lite","name":"Gemini 2.0 Flash-Lite","tool_call":true,"reasoning":false,"limit":{"context":1048576,"output":8192},"modalities":{"input":["text","image","audio","video","pdf"]}}}},"xai":{"models":{"grok-4.3":{"id":"grok-4.3","name":"Grok 4.3","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high"]}],"limit":{"context":1000000,"output":30000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.25,"output":2.5,"cache_read":0.2,"tiers":[{"input":2.5,"output":5,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":5,"cache_read":0.4}}},"grok-4.20-0309-reasoning":{"id":"grok-4.20-0309-reasoning","name":"Grok 4.20 (Reasoning)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":1000000,"output":30000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.25,"output":2.5,"cache_read":0.2,"tiers":[{"input":2.5,"output":5,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":5,"cache_read":0.4}}},"grok-4.20-multi-agent-0309":{"id":"grok-4.20-multi-agent-0309","name":"Grok 4.20 Multi-Agent","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh"]}],"limit":{"context":1000000,"output":30000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.25,"output":2.5,"cache_read":0.2,"tiers":[{"input":2.5,"output":5,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":5,"cache_read":0.4}}},"grok-imagine-image":{"id":"grok-imagine-image","name":"Grok Imagine Image","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":16000,"output":0},"modalities":{"input":["text","image","pdf"]}},"grok-imagine-video":{"id":"grok-imagine-video","name":"Grok Imagine Video","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":1024,"output":0},"modalities":{"input":["text","image","video","pdf"]}},"grok-4.5":{"id":"grok-4.5","name":"Grok 4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":500000,"output":500000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":6,"cache_read":0.3,"tiers":[{"input":4,"output":12,"cache_read":0.6,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":12,"cache_read":0.6}}},"grok-build-0.1":{"id":"grok-build-0.1","name":"Grok Build 0.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":256000,"output":256000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1,"output":2,"cache_read":0.2,"tiers":[{"input":2,"output":4,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2,"output":4,"cache_read":0.4}}},"grok-imagine-video-1.5":{"id":"grok-imagine-video-1.5","name":"Grok Imagine Video 1.5","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":1024,"output":0},"modalities":{"input":["text","image","audio","pdf"]}},"grok-imagine-image-2.0":{"id":"grok-imagine-image-2.0","name":"Grok Imagine Image 2.0","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":64000,"output":0},"modalities":{"input":["text","image","pdf"]}},"grok-4.6":{"id":"grok-4.6","name":"Grok 4.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh"]}],"limit":{"context":500000,"output":500000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":6,"cache_read":0.5,"tiers":[{"input":4,"output":12,"cache_read":1,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":12,"cache_read":1}}},"grok-imagine-image-quality":{"id":"grok-imagine-image-quality","name":"Grok Imagine Image Quality","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":16000,"output":0},"modalities":{"input":["text","image","pdf"]}},"grok-4.20-0309-non-reasoning":{"id":"grok-4.20-0309-non-reasoning","name":"Grok 4.20 (Non-Reasoning)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1000000,"output":30000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.25,"output":2.5,"cache_read":0.2,"tiers":[{"input":2.5,"output":5,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":5,"cache_read":0.4}}}}},"minimax":{"models":{"MiniMax-M2":{"id":"MiniMax-M2","name":"MiniMax-M2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2}},"MiniMax-M2.1":{"id":"MiniMax-M2.1","name":"MiniMax-M2.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.03,"cache_write":0.375}},"MiniMax-M2.5":{"id":"MiniMax-M2.5","name":"MiniMax-M2.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.03,"cache_write":0.375}},"MiniMax-M2.5-highspeed":{"id":"MiniMax-M2.5-highspeed","name":"MiniMax-M2.5-highspeed","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.4,"cache_read":0.06,"cache_write":0.375}},"MiniMax-M3":{"id":"MiniMax-M3","name":"MiniMax-M3","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":1048576,"output":512000},"modalities":{"input":["text","image","video"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"tiers":[{"input":0.6,"output":2.4,"cache_read":0.12,"tier":{"type":"context","size":512000}}],"context_over_200k":{"input":0.6,"output":2.4,"cache_read":0.12}}},"MiniMax-M2.7-highspeed":{"id":"MiniMax-M2.7-highspeed","name":"MiniMax-M2.7-highspeed","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.4,"cache_read":0.06,"cache_write":0.375}},"MiniMax-M2.7":{"id":"MiniMax-M2.7","name":"MiniMax-M2.7","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"cache_write":0.375}}}},"minimax-cn":{"models":{"MiniMax-M2":{"id":"MiniMax-M2","name":"MiniMax-M2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2}},"MiniMax-M2.1":{"id":"MiniMax-M2.1","name":"MiniMax-M2.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.03,"cache_write":0.375}},"MiniMax-M2.5":{"id":"MiniMax-M2.5","name":"MiniMax-M2.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.03,"cache_write":0.375}},"MiniMax-M2.5-highspeed":{"id":"MiniMax-M2.5-highspeed","name":"MiniMax-M2.5-highspeed","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.4,"cache_read":0.06,"cache_write":0.375}},"MiniMax-M3":{"id":"MiniMax-M3","name":"MiniMax-M3","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":1048576,"output":512000},"modalities":{"input":["text","image","video"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"tiers":[{"input":0.6,"output":2.4,"cache_read":0.12,"tier":{"type":"context","size":512000}}],"context_over_200k":{"input":0.6,"output":2.4,"cache_read":0.12}}},"MiniMax-M2.7-highspeed":{"id":"MiniMax-M2.7-highspeed","name":"MiniMax-M2.7-highspeed","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.4,"cache_read":0.06,"cache_write":0.375}},"MiniMax-M2.7":{"id":"MiniMax-M2.7","name":"MiniMax-M2.7","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"cache_write":0.375}}}},"moonshotai":{"models":{"kimi-k2.7-code-highspeed":{"id":"kimi-k2.7-code-highspeed","name":"Kimi K2.7 Code HighSpeed","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":1.9,"output":8,"cache_read":0.38}},"kimi-k2.6":{"id":"kimi-k2.6","name":"Kimi K2.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.95,"output":4,"cache_read":0.16}},"kimi-k2.7-code":{"id":"kimi-k2.7-code","name":"Kimi K2.7 Code","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.95,"output":4,"cache_read":0.19}},"kimi-k3":{"id":"kimi-k3","name":"Kimi K3","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1048576,"output":131072},"modalities":{"input":["text","image","video"]},"cost":{"input":3,"output":15,"cache_read":0.3}},"kimi-k2-0711-preview":{"id":"kimi-k2-0711-preview","name":"Kimi K2 0711","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}},"kimi-k2-thinking-turbo":{"id":"kimi-k2-thinking-turbo","name":"Kimi K2 Thinking Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":1.15,"output":8,"cache_read":0.15}},"kimi-k2.5":{"id":"kimi-k2.5","name":"Kimi K2.5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.6,"output":3,"cache_read":0.1}},"kimi-k2-0905-preview":{"id":"kimi-k2-0905-preview","name":"Kimi K2 0905","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}},"kimi-k2-turbo-preview":{"id":"kimi-k2-turbo-preview","name":"Kimi K2 Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":2.4,"output":10,"cache_read":0.6}},"kimi-k2-thinking":{"id":"kimi-k2-thinking","name":"Kimi K2 Thinking","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}}}},"moonshotai-cn":{"models":{"kimi-k3":{"id":"kimi-k3","name":"Kimi K3","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1048576,"output":131072},"modalities":{"input":["text","image","video"]},"cost":{"input":3,"output":15,"cache_read":0.3}},"kimi-k2.7-code":{"id":"kimi-k2.7-code","name":"Kimi K2.7 Code","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.95,"output":4,"cache_read":0.19}},"kimi-k2.6":{"id":"kimi-k2.6","name":"Kimi K2.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.95,"output":4,"cache_read":0.16}},"kimi-k2.7-code-highspeed":{"id":"kimi-k2.7-code-highspeed","name":"Kimi K2.7 Code HighSpeed","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":1.9,"output":8,"cache_read":0.38}},"kimi-k2-thinking":{"id":"kimi-k2-thinking","name":"Kimi K2 Thinking","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}},"kimi-k2-turbo-preview":{"id":"kimi-k2-turbo-preview","name":"Kimi K2 Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":2.4,"output":10,"cache_read":0.6}},"kimi-k2-0905-preview":{"id":"kimi-k2-0905-preview","name":"Kimi K2 0905","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}},"kimi-k2.5":{"id":"kimi-k2.5","name":"Kimi K2.5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.6,"output":3,"cache_read":0.1}},"kimi-k2-thinking-turbo":{"id":"kimi-k2-thinking-turbo","name":"Kimi K2 Thinking Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":1.15,"output":8,"cache_read":0.15}},"kimi-k2-0711-preview":{"id":"kimi-k2-0711-preview","name":"Kimi K2 0711","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}}}},"openai":{"models":{"gpt-5-nano":{"id":"gpt-5-nano","name":"GPT-5 Nano","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":0.05,"output":0.4,"cache_read":0.005}},"gpt-4.1-nano":{"id":"gpt-4.1-nano","name":"GPT-4.1 nano","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1047576,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.1,"output":0.4,"cache_read":0.025}},"gpt-4o-2024-05-13":{"id":"gpt-4o-2024-05-13","name":"GPT-4o (2024-05-13)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":4096},"modalities":{"input":["text","image"]},"cost":{"input":5,"output":15}},"gpt-5-pro":{"id":"gpt-5-pro","name":"GPT-5 Pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["high"]}],"limit":{"context":400000,"input":272000,"output":272000},"modalities":{"input":["text","image"]},"cost":{"input":15,"output":120}},"chatgpt-image-latest":{"id":"chatgpt-image-latest","name":"chatgpt-image-latest","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]}},"gpt-5.6-sol":{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":4,"output":20,"cache_read":0.4,"cache_write":5,"tiers":[{"input":8,"output":30,"cache_read":0.8,"cache_write":10,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":8,"output":30,"cache_read":0.8,"cache_write":10}}},"gpt-4o-2024-08-06":{"id":"gpt-4o-2024-08-06","name":"GPT-4o (2024-08-06)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image"]},"cost":{"input":2.5,"output":10,"cache_read":1.25}},"gpt-6-astra":{"id":"gpt-6-astra","name":"GPT-6 Astra","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":10,"output":50,"cache_read":1,"cache_write":12.5,"tiers":[{"input":20,"output":75,"cache_read":2,"cache_write":25,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":20,"output":75,"cache_read":2,"cache_write":25}}},"gpt-5.2-pro":{"id":"gpt-5.2-pro","name":"GPT-5.2 Pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":21,"output":168}},"gpt-5.3-codex-spark":{"id":"gpt-5.3-codex-spark","name":"GPT-5.3 Codex Spark","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":128000,"input":100000,"output":32000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"gpt-4.1-mini":{"id":"gpt-4.1-mini","name":"GPT-4.1 mini","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1047576,"output":32768},"modalities":{"input":["text","image","pdf"]},"cost":{"input":0.4,"output":1.6,"cache_read":0.1}},"gpt-5.4":{"id":"gpt-5.4","name":"GPT-5.4","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2.5,"output":15,"cache_read":0.25,"tiers":[{"input":5,"output":22.5,"cache_read":0.5,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":5,"output":22.5,"cache_read":0.5}}},"gpt-4-turbo":{"id":"gpt-4-turbo","name":"GPT-4 Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":4096},"modalities":{"input":["text","image"]},"cost":{"input":10,"output":30}},"gpt-5.1":{"id":"gpt-5.1","name":"GPT-5.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":1.25,"output":10,"cache_read":0.125}},"o1":{"id":"o1","name":"o1","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":15,"output":60,"cache_read":7.5}},"gpt-4o":{"id":"gpt-4o","name":"GPT-4o","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2.5,"output":10,"cache_read":1.25}},"gpt-5.6-luna":{"id":"gpt-5.6-luna","name":"GPT-5.6 Luna","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":0.2,"output":1.2,"cache_read":0.02,"cache_write":0.25,"tiers":[{"input":0.4,"output":1.8,"cache_read":0.04,"cache_write":0.5,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":0.4,"output":1.8,"cache_read":0.04,"cache_write":0.5}}},"gpt-5.3-codex":{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"gpt-4o-mini":{"id":"gpt-4o-mini","name":"GPT-4o mini","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image","pdf"]},"cost":{"input":0.15,"output":0.6,"cache_read":0.075}},"gpt-image-1.5":{"id":"gpt-image-1.5","name":"gpt-image-1.5","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]}},"o1-pro":{"id":"o1-pro","name":"o1-pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image"]},"cost":{"input":150,"output":600}},"gpt-4.1":{"id":"gpt-4.1","name":"GPT-4.1","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1047576,"output":32768},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":8,"cache_read":0.5}},"text-embedding-ada-002":{"id":"text-embedding-ada-002","name":"text-embedding-ada-002","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":8192,"output":1536},"modalities":{"input":["text"]},"cost":{"input":0.1,"output":0}},"gpt-image-1":{"id":"gpt-image-1","name":"gpt-image-1","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]}},"gpt-5.4-nano":{"id":"gpt-5.4-nano","name":"GPT-5.4 nano","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":0.2,"output":1.25,"cache_read":0.02}},"gpt-5.5-pro":{"id":"gpt-5.5-pro","name":"GPT-5.5 Pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["medium","high","xhigh"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":30,"output":180,"tiers":[{"input":60,"output":270,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":60,"output":270}}},"gpt-image-1-mini":{"id":"gpt-image-1-mini","name":"gpt-image-1-mini","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]}},"gpt-5.4-mini":{"id":"gpt-5.4-mini","name":"GPT-5.4 mini","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":0.75,"output":4.5,"cache_read":0.075}},"gpt-image-2":{"id":"gpt-image-2","name":"gpt-image-2","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]},"cost":{"input":5,"output":30,"cache_read":1.25}},"gpt-3.5-turbo":{"id":"gpt-3.5-turbo","name":"GPT-3.5-turbo","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16385,"output":4096},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":1.5,"cache_read":0}},"gpt-5.6":{"id":"gpt-5.6","name":"GPT-5.6","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":4,"output":20,"cache_read":0.4,"cache_write":5,"tiers":[{"input":8,"output":30,"cache_read":0.8,"cache_write":10,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":8,"output":30,"cache_read":0.8,"cache_write":10}}},"text-embedding-3-small":{"id":"text-embedding-3-small","name":"text-embedding-3-small","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":8191,"output":1536},"modalities":{"input":["text"]},"cost":{"input":0.02,"output":0}},"gpt-5-mini":{"id":"gpt-5-mini","name":"GPT-5 Mini","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":0.25,"output":2,"cache_read":0.025}},"gpt-5.4-pro":{"id":"gpt-5.4-pro","name":"GPT-5.4 Pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["medium","high","xhigh"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":30,"output":180,"tiers":[{"input":60,"output":270,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":60,"output":270}}},"text-embedding-3-large":{"id":"text-embedding-3-large","name":"text-embedding-3-large","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":8191,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.13,"output":0}},"gpt-5.6-terra":{"id":"gpt-5.6-terra","name":"GPT-5.6 Terra","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"cache_write":2.5,"tiers":[{"input":4,"output":18,"cache_read":0.4,"cache_write":5,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4,"cache_write":5}}},"gpt-4":{"id":"gpt-4","name":"GPT-4","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":8192},"modalities":{"input":["text"]},"cost":{"input":30,"output":60}},"gpt-5.2":{"id":"gpt-5.2","name":"GPT-5.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"gpt-5":{"id":"gpt-5","name":"GPT-5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":1.25,"output":10,"cache_read":0.125}},"gpt-5.2-chat-latest":{"id":"gpt-5.2-chat-latest","name":"GPT-5.2 Chat","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["medium"]}],"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"o4-mini":{"id":"o4-mini","name":"o4-mini","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image"]},"cost":{"input":1.1,"output":4.4,"cache_read":0.275}},"gpt-realtime-2.1":{"id":"gpt-realtime-2.1","name":"GPT-Realtime-2.1","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high","xhigh"]}],"limit":{"context":128000,"input":96000,"output":32000},"modalities":{"input":["text","audio","image"]},"cost":{"input":4,"output":24,"cache_read":0.4,"input_audio":32,"output_audio":64}},"o3-mini":{"id":"o3-mini","name":"o3-mini","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text"]},"cost":{"input":1.1,"output":4.4,"cache_read":0.55}},"o3":{"id":"o3","name":"o3","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":8,"cache_read":0.5}},"o3-pro":{"id":"o3-pro","name":"o3-pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image"]},"cost":{"input":20,"output":80}},"gpt-5.3-chat-latest":{"id":"gpt-5.3-chat-latest","name":"GPT-5.3 Chat (latest)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"gpt-5.5":{"id":"gpt-5.5","name":"GPT-5.5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":30,"cache_read":0.5,"tiers":[{"input":10,"output":45,"cache_read":1,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":10,"output":45,"cache_read":1}}},"gpt-4o-2024-11-20":{"id":"gpt-4o-2024-11-20","name":"GPT-4o (2024-11-20)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image"]},"cost":{"input":2.5,"output":10,"cache_read":1.25}}}},"alibaba":{"models":{"qwen3.7-max":{"id":"qwen3.7-max","name":"Qwen3.7 Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"]},"cost":{"input":2.5,"output":7.5,"cache_read":0.5,"cache_write":3.125}},"qwen2-5-72b-instruct":{"id":"qwen2-5-72b-instruct","name":"Qwen2.5 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":5.6}},"deepseek-v4-flash-0731":{"id":"deepseek-v4-flash-0731","name":"DeepSeek V4 Flash 0731","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.2,"output":0.4,"cache_read":0.04}},"qwen3-coder-plus":{"id":"qwen3-coder-plus","name":"Qwen3 Coder Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1,"output":5}},"qwen3-next-80b-a3b-thinking":{"id":"qwen3-next-80b-a3b-thinking","name":"Qwen3-Next 80B-A3B (Thinking)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":6}},"qwen2-5-omni-7b":{"id":"qwen2-5-omni-7b","name":"Qwen2.5-Omni 7B","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.1,"output":0.4,"input_audio":6.76}},"qwen-mt-turbo":{"id":"qwen-mt-turbo","name":"Qwen-MT Turbo","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.16,"output":0.49}},"qwen-vl-max":{"id":"qwen-vl-max","name":"Qwen-VL Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.8,"output":3.2}},"qwen3-next-80b-a3b-instruct":{"id":"qwen3-next-80b-a3b-instruct","name":"Qwen3-Next 80B-A3B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":2}},"qwen3-coder-flash":{"id":"qwen3-coder-flash","name":"Qwen3 Coder Flash","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.5}},"qwen3-14b":{"id":"qwen3-14b","name":"Qwen3 14B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.35,"output":1.4,"reasoning":4.2}},"qwen-max":{"id":"qwen-max","name":"Qwen Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":8192},"modalities":{"input":["text"]},"cost":{"input":1.6,"output":6.4}},"qwen3.6-plus":{"id":"qwen3.6-plus","name":"Qwen3.6 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"cache_write":0.625,"tiers":[{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5,"tier":{"type":"context","size":256000}}],"context_over_200k":{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5}}},"qwen-vl-plus":{"id":"qwen-vl-plus","name":"Qwen-VL Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.21,"output":0.63}},"qwen-omni-turbo-realtime":{"id":"qwen-omni-turbo-realtime","name":"Qwen-Omni Turbo Realtime","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio"]},"cost":{"input":0.27,"output":1.07,"input_audio":4.44,"output_audio":8.89}},"qwen3.5-27b":{"id":"qwen3.5-27b","name":"Qwen3.5 27B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.3,"output":2.4}},"qwen3.5-35b-a3b":{"id":"qwen3.5-35b-a3b","name":"Qwen3.5 35B-A3B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.25,"output":2}},"qwen-flash":{"id":"qwen-flash","name":"Qwen Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.05,"output":0.4}},"glm-5.2":{"id":"glm-5.2","name":"GLM-5.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","minimal","low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":4.4,"cache_read":0.28,"cache_write":0}},"qwen-turbo":{"id":"qwen-turbo","name":"Qwen Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.05,"output":0.2,"reasoning":0.5}},"qwen3-livetranslate-flash-realtime":{"id":"qwen3-livetranslate-flash-realtime","name":"Qwen3-LiveTranslate Flash Realtime","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":53248,"output":4096},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":10,"output":10,"input_audio":10,"output_audio":38}},"qwen3-vl-30b-a3b":{"id":"qwen3-vl-30b-a3b","name":"Qwen3-VL 30B-A3B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.2,"output":0.8,"reasoning":2.4}},"qwen-vl-ocr":{"id":"qwen-vl-ocr","name":"Qwen-VL OCR","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":34096,"output":4096},"modalities":{"input":["text","image"]},"cost":{"input":0.72,"output":0.72}},"qwen3-32b":{"id":"qwen3-32b","name":"Qwen3 32B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.7,"output":2.8,"reasoning":8.4}},"qwq-plus":{"id":"qwq-plus","name":"QwQ Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.8,"output":2.4}},"qwen3-vl-plus":{"id":"qwen3-vl-plus","name":"Qwen3-VL Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.2,"output":1.6,"reasoning":4.8}},"qwen2-5-7b-instruct":{"id":"qwen2-5-7b-instruct","name":"Qwen2.5 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.175,"output":0.7}},"qwen3-coder-30b-a3b-instruct":{"id":"qwen3-coder-30b-a3b-instruct","name":"Qwen3-Coder 30B-A3B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.45,"output":2.25,"tiers":[{"input":0.75,"output":3.75,"tier":{"type":"context","size":32000}},{"input":1.2,"output":6,"tier":{"type":"context","size":128000}}]}},"qwen3.5-397b-a17b":{"id":"qwen3.5-397b-a17b","name":"Qwen3.5 397B-A17B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.6,"output":3.6}},"qwen3.6-27b":{"id":"qwen3.6-27b","name":"Qwen3.6 27B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.6,"output":3.6}},"qwen3-asr-flash":{"id":"qwen3-asr-flash","name":"Qwen3-ASR Flash","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":53248,"output":4096},"modalities":{"input":["audio"]},"cost":{"input":0.035,"output":0.035}},"qwen3-omni-flash":{"id":"qwen3-omni-flash","name":"Qwen3-Omni Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":65536,"output":16384},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.43,"output":1.66,"input_audio":3.81,"output_audio":15.11}},"qwen2-5-vl-72b-instruct":{"id":"qwen2-5-vl-72b-instruct","name":"Qwen2.5-VL 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":2.8,"output":8.4}},"qwen3.6-35b-a3b":{"id":"qwen3.6-35b-a3b","name":"Qwen3.6 35B-A3B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.248,"output":1.485}},"qwen3-max":{"id":"qwen3-max","name":"Qwen3 Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1.2,"output":6}},"qwen-plus":{"id":"qwen-plus","name":"Qwen Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.4,"output":1.2,"reasoning":4}},"qwen3.5-122b-a10b":{"id":"qwen3.5-122b-a10b","name":"Qwen3.5 122B-A10B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.4,"output":3.2}},"qwen3-omni-flash-realtime":{"id":"qwen3-omni-flash-realtime","name":"Qwen3-Omni Flash Realtime","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":65536,"output":16384},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.52,"output":1.99,"input_audio":4.57,"output_audio":18.13}},"qwen2-5-vl-7b-instruct":{"id":"qwen2-5-vl-7b-instruct","name":"Qwen2.5-VL 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.35,"output":1.05}},"qwen3.6-flash":{"id":"qwen3.6-flash","name":"Qwen3.6 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.1875,"output":1.125,"cache_write":0.234375}},"qwen3.8-flash":{"id":"qwen3.8-flash","name":"Qwen3.8 Flash","tool_call":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","xhigh"]},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video"]},"cost":{"input":0.15,"output":0.47,"cache_read":0.016,"cache_write":0.2}},"qwen2-5-14b-instruct":{"id":"qwen2-5-14b-instruct","name":"Qwen2.5 14B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.35,"output":1.4}},"qwen3.6-max-preview":{"id":"qwen3.6-max-preview","name":"Qwen3.6 Max Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1.3,"output":7.8,"cache_read":0.13,"cache_write":1.625}},"qwen-plus-character-ja":{"id":"qwen-plus-character-ja","name":"Qwen Plus Character (Japanese)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":512},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":1.4}},"qwen3.8-max":{"id":"qwen3.8-max","name":"Qwen3.8 Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","xhigh"]},{"type":"budget_tokens","min":0,"max":262144}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":2,"output":6,"cache_read":0.25,"cache_write":2.5}},"qwen3-8b":{"id":"qwen3-8b","name":"Qwen3 8B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.18,"output":0.7,"reasoning":2.1}},"qwen3-235b-a22b":{"id":"qwen3-235b-a22b","name":"Qwen3 235B-A22B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.7,"output":2.8,"reasoning":8.4}},"qwen3.7-plus":{"id":"qwen3.7-plus","name":"Qwen3.7 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"cache_write":0.625,"tiers":[{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5,"tier":{"type":"context","size":256000}}],"context_over_200k":{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5}}},"qwen-omni-turbo":{"id":"qwen-omni-turbo","name":"Qwen-Omni Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.07,"output":0.27,"input_audio":4.44,"output_audio":8.89}},"qwen3-coder-480b-a35b-instruct":{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen3-Coder 480B-A35B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1.5,"output":7.5,"tiers":[{"input":2.7,"output":13.5,"tier":{"type":"context","size":32000}},{"input":4.5,"output":22.5,"tier":{"type":"context","size":128000}}]}},"qwen2-5-32b-instruct":{"id":"qwen2-5-32b-instruct","name":"Qwen2.5 32B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.7,"output":2.8}},"qwen3-vl-235b-a22b":{"id":"qwen3-vl-235b-a22b","name":"Qwen3-VL 235B-A22B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.7,"output":2.8,"reasoning":8.4}},"qwen3.5-plus":{"id":"qwen3.5-plus","name":"Qwen3.5 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.4,"output":2.4,"reasoning":2.4}},"qwen-mt-plus":{"id":"qwen-mt-plus","name":"Qwen-MT Plus","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":8192},"modalities":{"input":["text"]},"cost":{"input":2.46,"output":7.37}},"qvq-max":{"id":"qvq-max","name":"QVQ Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":1.2,"output":4.8}}}},"alibaba-cn":{"models":{"qwen3.7-max":{"id":"qwen3.7-max","name":"Qwen3.7 Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"]},"cost":{"input":2.5,"output":7.5,"cache_read":0.5,"cache_write":3.125}},"qwen2-5-72b-instruct":{"id":"qwen2-5-72b-instruct","name":"Qwen2.5 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":1.721}},"deepseek-r1-distill-qwen-7b":{"id":"deepseek-r1-distill-qwen-7b","name":"DeepSeek R1 Distill Qwen 7B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.072,"output":0.144}},"qwen3-next-80b-a3b-thinking":{"id":"qwen3-next-80b-a3b-thinking","name":"Qwen3-Next 80B-A3B (Thinking)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":1.434}},"deepseek-v3":{"id":"deepseek-v3","name":"DeepSeek V3","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":65536,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":1.147}},"qwen2-5-omni-7b":{"id":"qwen2-5-omni-7b","name":"Qwen2.5-Omni 7B","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.087,"output":0.345,"input_audio":5.448}},"qwen-mt-turbo":{"id":"qwen-mt-turbo","name":"Qwen-MT Turbo","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.101,"output":0.28}},"deepseek-v3-1":{"id":"deepseek-v3-1","name":"DeepSeek V3.1","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":1.721}},"qwen-deep-research":{"id":"qwen-deep-research","name":"Qwen Deep Research","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":7.742,"output":23.367}},"qwen-vl-max":{"id":"qwen-vl-max","name":"Qwen-VL Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.23,"output":0.574}},"qwen3-next-80b-a3b-instruct":{"id":"qwen3-next-80b-a3b-instruct","name":"Qwen3-Next 80B-A3B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.574}},"qwen3-coder-flash":{"id":"qwen3-coder-flash","name":"Qwen3 Coder Flash","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.574}},"qwen3-14b":{"id":"qwen3-14b","name":"Qwen3 14B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.574,"reasoning":1.434}},"qwen-max":{"id":"qwen-max","name":"Qwen Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.345,"output":1.377}},"qwen3.6-plus":{"id":"qwen3.6-plus","name":"Qwen3.6 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"cache_write":0.625,"tiers":[{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5,"tier":{"type":"context","size":256000}}],"context_over_200k":{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5}}},"moonshot-kimi-k2-instruct":{"id":"moonshot-kimi-k2-instruct","name":"Moonshot Kimi K2 Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":2.294}},"qwen-vl-plus":{"id":"qwen-vl-plus","name":"Qwen-VL Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.115,"output":0.287}},"qwen-omni-turbo-realtime":{"id":"qwen-omni-turbo-realtime","name":"Qwen-Omni Turbo Realtime","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio"]},"cost":{"input":0.23,"output":0.918,"input_audio":3.584,"output_audio":7.168}},"kimi-k2.6":{"id":"kimi-k2.6","name":"Moonshot Kimi K2.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":262144,"output":16384},"modalities":{"input":["text","image","video"]},"cost":{"input":0.929,"output":3.858}},"qwen-flash":{"id":"qwen-flash","name":"Qwen Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.022,"output":0.216}},"glm-5.2":{"id":"glm-5.2","name":"GLM-5.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text"]},"cost":{"input":1.1,"output":3.851,"cache_read":0.275,"cache_write":0}},"qwen-turbo":{"id":"qwen-turbo","name":"Qwen Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":1000000,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.044,"output":0.087,"reasoning":0.431}},"qwen3-vl-30b-a3b":{"id":"qwen3-vl-30b-a3b","name":"Qwen3-VL 30B-A3B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.108,"output":0.431,"reasoning":1.076}},"qwen-vl-ocr":{"id":"qwen-vl-ocr","name":"Qwen-VL OCR","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":34096,"output":4096},"modalities":{"input":["text","image"]},"cost":{"input":0.717,"output":0.717}},"tongyi-intent-detect-v3":{"id":"tongyi-intent-detect-v3","name":"Tongyi Intent Detect V3","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":1024},"modalities":{"input":["text"]},"cost":{"input":0.058,"output":0.144}},"deepseek-v4-flash":{"id":"deepseek-v4-flash","name":"DeepSeek V4 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028}},"kimi-k2-thinking":{"id":"kimi-k2-thinking","name":"Moonshot Kimi K2 Thinking","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":262144,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":2.294}},"qwen3-32b":{"id":"qwen3-32b","name":"Qwen3 32B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":1.147,"reasoning":2.868}},"qwq-plus":{"id":"qwq-plus","name":"QwQ Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.23,"output":0.574}},"qwen3.5-flash":{"id":"qwen3.5-flash","name":"Qwen3.5 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.172,"output":1.72,"reasoning":1.72}},"qwen3-vl-plus":{"id":"qwen3-vl-plus","name":"Qwen3-VL Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.143353,"output":1.433525,"reasoning":4.300576}},"deepseek-v3-2-exp":{"id":"deepseek-v3-2-exp","name":"DeepSeek V3.2 Exp","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.431}},"qwen2-5-7b-instruct":{"id":"qwen2-5-7b-instruct","name":"Qwen2.5 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.072,"output":0.144}},"qwen3-coder-30b-a3b-instruct":{"id":"qwen3-coder-30b-a3b-instruct","name":"Qwen3-Coder 30B-A3B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.216,"output":0.861,"tiers":[{"input":0.323,"output":1.291,"tier":{"type":"context","size":32000}},{"input":0.538,"output":2.151,"tier":{"type":"context","size":128000}}]}},"qwen2-5-math-7b-instruct":{"id":"qwen2-5-math-7b-instruct","name":"Qwen2.5-Math 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":4096,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.287}},"qwen3.5-397b-a17b":{"id":"qwen3.5-397b-a17b","name":"Qwen3.5 397B-A17B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.172,"output":1.032,"reasoning":1.032,"tiers":[{"input":0.43,"output":2.58,"reasoning":2.58,"tier":{"type":"context","size":128000}}]}},"qwq-32b":{"id":"qwq-32b","name":"QwQ 32B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"deepseek-r1":{"id":"deepseek-r1","name":"DeepSeek R1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":2.294}},"qwen3-asr-flash":{"id":"qwen3-asr-flash","name":"Qwen3-ASR Flash","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":53248,"output":4096},"modalities":{"input":["audio"]},"cost":{"input":0.032,"output":0.032}},"qwen3-omni-flash":{"id":"qwen3-omni-flash","name":"Qwen3-Omni Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":65536,"output":16384},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.058,"output":0.23,"input_audio":3.584,"output_audio":7.168}},"deepseek-r1-distill-qwen-1-5b":{"id":"deepseek-r1-distill-qwen-1-5b","name":"DeepSeek R1 Distill Qwen 1.5B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0,"output":0}},"qwen3.7-flash":{"id":"qwen3.7-flash","name":"Qwen3.7 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"input":991000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.02962,"output":0.1185,"cache_read":0.002962,"cache_write":0.03703,"tiers":[{"input":0.08887,"output":0.35549,"cache_read":0.008887,"cache_write":0.11109,"tier":{"type":"context","size":32000}},{"input":0.17774,"output":0.71098,"cache_read":0.017774,"cache_write":0.22218,"tier":{"type":"context","size":256000}}]}},"qwen2-5-vl-72b-instruct":{"id":"qwen2-5-vl-72b-instruct","name":"Qwen2.5-VL 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":2.294,"output":6.881}},"deepseek-r1-0528":{"id":"deepseek-r1-0528","name":"DeepSeek R1 0528","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":2.294}},"deepseek-r1-distill-qwen-32b":{"id":"deepseek-r1-distill-qwen-32b","name":"DeepSeek R1 Distill Qwen 32B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"qwen3-max":{"id":"qwen3-max","name":"Qwen3 Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.861,"output":3.441}},"qwen2-5-coder-32b-instruct":{"id":"qwen2-5-coder-32b-instruct","name":"Qwen2.5-Coder 32B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"deepseek-r1-distill-llama-70b":{"id":"deepseek-r1-distill-llama-70b","name":"DeepSeek R1 Distill Llama 70B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"qwen2-5-math-72b-instruct":{"id":"qwen2-5-math-72b-instruct","name":"Qwen2.5-Math 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":4096,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":1.721}},"qwen-plus":{"id":"qwen-plus","name":"Qwen Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.115,"output":0.287,"reasoning":1.147,"cache_read":0.012,"cache_write":0.144}},"MiniMax-M2.5":{"id":"MiniMax-M2.5","name":"MiniMax-M2.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2}},"qwen3-omni-flash-realtime":{"id":"qwen3-omni-flash-realtime","name":"Qwen3-Omni Flash Realtime","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":65536,"output":16384},"modalities":{"input":["text","image","audio"]},"cost":{"input":0.23,"output":0.918,"input_audio":3.584,"output_audio":7.168}},"qwen2-5-vl-7b-instruct":{"id":"qwen2-5-vl-7b-instruct","name":"Qwen2.5-VL 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.287,"output":0.717}},"qwen3.6-flash":{"id":"qwen3.6-flash","name":"Qwen3.6 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":131072}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.1875,"output":1.125,"cache_write":0.234375}},"qwen3.8-flash":{"id":"qwen3.8-flash","name":"Qwen3.8 Flash","tool_call":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","xhigh"]},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video"]},"cost":{"input":0.11875,"output":0.40073,"cache_read":0.01187,"cache_write":0.14844}},"qwen2-5-14b-instruct":{"id":"qwen2-5-14b-instruct","name":"Qwen2.5 14B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.431}},"qwen-math-turbo":{"id":"qwen-math-turbo","name":"Qwen Math Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":4096,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"qwen-plus-character":{"id":"qwen-plus-character","name":"Qwen Plus Character","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":4096},"modalities":{"input":["text"]},"cost":{"input":0.115,"output":0.287}},"qwen3.6-max-preview":{"id":"qwen3.6-max-preview","name":"Qwen3.6 Max Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":131072}],"limit":{"context":245800,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1.32,"output":7.9,"cache_read":0.132}},"glm-5":{"id":"glm-5","name":"GLM-5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":32768}],"limit":{"context":202752,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.573,"output":2.58,"tiers":[{"input":0.86,"output":3.154,"tier":{"type":"context","size":32000}}]}},"qwen3.8-max":{"id":"qwen3.8-max","name":"Qwen3.8 Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","xhigh"]},{"type":"budget_tokens","min":0,"max":262144}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":1.77744,"output":5.33231,"cache_read":0.22218,"cache_write":2.22179}},"kimi-k2.5":{"id":"kimi-k2.5","name":"Moonshot Kimi K2.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image","video"]},"cost":{"input":0.574,"output":2.411}},"qwen3-8b":{"id":"qwen3-8b","name":"Qwen3 8B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.072,"output":0.287,"reasoning":0.717}},"glm-5.1":{"id":"glm-5.1","name":"GLM-5.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":131072}],"limit":{"context":202752,"output":128000},"modalities":{"input":["text"]},"cost":{"input":0.825,"output":3.301,"cache_read":0.17,"tiers":[{"input":1.1,"output":3.851,"tier":{"type":"context","size":32000}}]}},"qwen3-235b-a22b":{"id":"qwen3-235b-a22b","name":"Qwen3 235B-A22B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":1.147,"reasoning":2.868}},"qwen3.7-plus":{"id":"qwen3.7-plus","name":"Qwen3.7 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image","video"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"cache_write":0.625,"tiers":[{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5,"tier":{"type":"context","size":128000}}]}},"qwen-omni-turbo":{"id":"qwen-omni-turbo","name":"Qwen-Omni Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.058,"output":0.23,"input_audio":3.584,"output_audio":7.168}},"deepseek-v4-pro":{"id":"deepseek-v4-pro","name":"DeepSeek V4 Pro","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.435,"output":0.87,"cache_read":0.003625}},"qwen3-coder-480b-a35b-instruct":{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen3-Coder 480B-A35B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.861,"output":3.441,"tiers":[{"input":1.291,"output":5.161,"tier":{"type":"context","size":32000}},{"input":2.151,"output":8.602,"tier":{"type":"context","size":128000}}]}},"qwen2-5-32b-instruct":{"id":"qwen2-5-32b-instruct","name":"Qwen2.5 32B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"qwen2-5-coder-7b-instruct":{"id":"qwen2-5-coder-7b-instruct","name":"Qwen2.5-Coder 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.287}},"qwen-long":{"id":"qwen-long","name":"Qwen Long","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":10000000,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.072,"output":0.287}},"deepseek-r1-distill-llama-8b":{"id":"deepseek-r1-distill-llama-8b","name":"DeepSeek R1 Distill Llama 8B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0,"output":0}},"deepseek-r1-distill-qwen-14b":{"id":"deepseek-r1-distill-qwen-14b","name":"DeepSeek R1 Distill Qwen 14B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.431}},"qwen3-vl-235b-a22b":{"id":"qwen3-vl-235b-a22b","name":"Qwen3-VL 235B-A22B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.286705,"output":1.14682,"reasoning":2.867051}},"qwen3.5-plus":{"id":"qwen3.5-plus","name":"Qwen3.5 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.573,"output":3.44,"reasoning":3.44}},"qwen-math-plus":{"id":"qwen-math-plus","name":"Qwen Math Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":4096,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":1.721}},"qwen-doc-turbo":{"id":"qwen-doc-turbo","name":"Qwen Doc Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.087,"output":0.144}},"qwen-mt-plus":{"id":"qwen-mt-plus","name":"Qwen-MT Plus","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.259,"output":0.775}},"qvq-max":{"id":"qvq-max","name":"QVQ Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":1.147,"output":4.588}},"MiniMax/MiniMax-M2.7":{"id":"MiniMax/MiniMax-M2.7","name":"MiniMax-M2.7","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"cache_write":0.375}},"siliconflow/deepseek-r1-0528":{"id":"siliconflow/deepseek-r1-0528","name":"siliconflow/deepseek-r1-0528","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":163840,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":2.18}},"siliconflow/deepseek-v3.2":{"id":"siliconflow/deepseek-v3.2","name":"siliconflow/deepseek-v3.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":163840,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.27,"output":0.42}},"siliconflow/deepseek-v3.1-terminus":{"id":"siliconflow/deepseek-v3.1-terminus","name":"siliconflow/deepseek-v3.1-terminus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":163840,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.27,"output":1}},"siliconflow/deepseek-v3-0324":{"id":"siliconflow/deepseek-v3-0324","name":"siliconflow/deepseek-v3-0324","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":163840,"output":163840},"modalities":{"input":["text"]},"cost":{"input":0.25,"output":1}},"kimi/kimi-k2.5":{"id":"kimi/kimi-k2.5","name":"kimi/kimi-k2.5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.6,"output":3,"cache_read":0.1}},"qwen3-coder-plus":{"id":"qwen3-coder-plus","name":"Qwen3 Coder Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1,"output":5}}}},"zhipuai":{"models":{"glm-5.2":{"id":"glm-5.2","name":"GLM-5.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":4.4,"cache_read":0.26,"cache_write":0}},"glm-5.3-flash":{"id":"glm-5.3-flash","name":"GLM-5.3-Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":0.075,"output":0.25,"cache_read":0.015,"cache_write":0}},"glm-5":{"id":"glm-5","name":"GLM-5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1,"output":3.2,"cache_read":0.2,"cache_write":0}},"glm-5.1":{"id":"glm-5.1","name":"GLM-5.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":200000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":4.4,"cache_read":0.26,"cache_write":0}},"glm-5.3":{"id":"glm-5.3","name":"GLM-5.3","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":4.4,"cache_read":0.26,"cache_write":0}},"glm-5v-turbo":{"id":"glm-5v-turbo","name":"GLM-5V-Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":200000,"output":131072},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":5,"output":22,"cache_read":1.2,"cache_write":0}},"glm-4.7-flash":{"id":"glm-4.7-flash","name":"GLM-4.7-Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":200000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0,"output":0,"cache_read":0,"cache_write":0}},"glm-4.7-flashx":{"id":"glm-4.7-flashx","name":"GLM-4.7-FlashX","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":200000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.07,"output":0.4,"cache_read":0.01,"cache_write":0}},"glm-4.5v":{"id":"glm-4.5v","name":"GLM-4.5V","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":64000,"output":16384},"modalities":{"input":["text","image","video"]},"cost":{"input":0.6,"output":1.8}},"glm-4.5":{"id":"glm-4.5","name":"GLM-4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":131072,"output":98304},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.2,"cache_read":0.11,"cache_write":0}},"glm-4.5-flash":{"id":"glm-4.5-flash","name":"GLM-4.5-Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":131072,"output":98304},"modalities":{"input":["text"]},"cost":{"input":0,"output":0,"cache_read":0,"cache_write":0}},"glm-4.6v":{"id":"glm-4.6v","name":"GLM-4.6V","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":128000,"output":32768},"modalities":{"input":["text","image","video"]},"cost":{"input":0.3,"output":0.9}},"glm-4.6":{"id":"glm-4.6","name":"GLM-4.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.2,"cache_read":0.11,"cache_write":0}},"glm-4.5-air":{"id":"glm-4.5-air","name":"GLM-4.5-Air","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":131072,"output":98304},"modalities":{"input":["text"]},"cost":{"input":0.2,"output":1.1,"cache_read":0.03,"cache_write":0}},"glm-4.7":{"id":"glm-4.7","name":"GLM-4.7","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.2,"cache_read":0.11,"cache_write":0}}}}} \ No newline at end of file +{"anthropic":{"name":"Anthropic","type":"ANTHROPIC","baseUrl":"https://api.anthropic.com/","models":{"claude-sonnet-4-6":{"id":"claude-sonnet-4-6","name":"Claude Sonnet 4.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","max"]},{"type":"budget_tokens","min":1024}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":3,"output":15,"cache_read":0.3,"cache_write":3.75}},"claude-opus-5":{"id":"claude-opus-5","name":"Claude Opus 5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-opus-4-5":{"id":"claude-opus-4-5","name":"Claude Opus 4.5 (latest)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]},{"type":"budget_tokens","min":1024}],"limit":{"context":200000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-fable-5-1":{"id":"claude-fable-5-1","name":"Claude Fable 5.1","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":10,"output":50,"cache_read":0.25,"cache_write":12.5}},"claude-opus-4-6":{"id":"claude-opus-4-6","name":"Claude Opus 4.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","max"]},{"type":"budget_tokens","min":1024}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-sonnet-4-5-20250929":{"id":"claude-sonnet-4-5-20250929","name":"Claude Sonnet 4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":1024}],"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":3,"output":15,"cache_read":0.3,"cache_write":3.75}},"claude-opus-4-7":{"id":"claude-opus-4-7","name":"Claude Opus 4.7","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-haiku-4-5-20251001":{"id":"claude-haiku-4-5-20251001","name":"Claude Haiku 4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":1024}],"limit":{"context":200000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1,"output":5,"cache_read":0.1,"cache_write":1.25}},"claude-fable-5":{"id":"claude-fable-5","name":"Claude Fable 5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":10,"output":50,"cache_read":1,"cache_write":12.5}},"claude-haiku-4-5":{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5 (latest)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":1024}],"limit":{"context":200000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1,"output":5,"cache_read":0.1,"cache_write":1.25}},"claude-sonnet-4-5":{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5 (latest)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":1024}],"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":3,"output":15,"cache_read":0.3,"cache_write":3.75}},"claude-opus-4-8":{"id":"claude-opus-4-8","name":"Claude Opus 4.8","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-sonnet-5":{"id":"claude-sonnet-5","name":"Claude Sonnet 5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":10,"cache_read":0.2,"cache_write":2.5}},"claude-opus-4-5-20251101":{"id":"claude-opus-4-5-20251101","name":"Claude Opus 4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]},{"type":"budget_tokens","min":1024}],"limit":{"context":200000,"output":64000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}},"claude-opus-4-1":{"id":"claude-opus-4-1","name":"Claude Opus 4.1 (latest)","tool_call":true,"reasoning":true,"limit":{"context":200000,"output":32000},"modalities":{"input":["text","image","pdf"]}},"claude-opus-4-1-20250805":{"id":"claude-opus-4-1-20250805","name":"Claude Opus 4.1","tool_call":true,"reasoning":true,"limit":{"context":200000,"output":32000},"modalities":{"input":["text","image","pdf"]}}}},"deepseek":{"name":"DeepSeek","type":"OPENAI","baseUrl":"https://api.deepseek.com","models":{"deepseek-v4-flash-vision-exp":{"id":"deepseek-v4-flash-vision-exp","name":"DeepSeek V4 Flash Vision Exp","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"]},"cost":{"input":0.15,"output":0.6,"reasoning":0.6,"cache_read":0.003}},"deepseek-v4-flash":{"id":"deepseek-v4-flash","name":"DeepSeek V4 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"]},"cost":{"input":0.15,"output":0.6,"reasoning":0.6,"cache_read":0.003}},"deepseek-v4-pro":{"id":"deepseek-v4-pro","name":"DeepSeek V4 Pro","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.435,"output":0.87,"reasoning":0.87,"cache_read":0.003625}},"deepseek-flash":{"id":"deepseek-flash","name":"DeepSeek V4.1 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"]},"cost":{"input":0.15,"output":0.6,"reasoning":0.6,"cache_read":0.003}},"deepseek-chat":{"id":"deepseek-chat","name":"DeepSeek Chat","tool_call":true,"reasoning":false,"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028}},"deepseek-reasoner":{"id":"deepseek-reasoner","name":"DeepSeek Reasoner","tool_call":true,"reasoning":true,"reasoning_options":[],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.14,"output":0.28,"reasoning":0.28,"cache_read":0.0028}}}},"google":{"name":"Google","type":"GEMINI","baseUrl":"https://generativelanguage.googleapis.com/","models":{"gemma-4-26b-a4b-it":{"id":"gemma-4-26b-a4b-it","name":"Gemma 4 26B A4B IT","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image"]}},"gemini-3.1-pro-preview-customtools":{"id":"gemini-3.1-pro-preview-customtools","name":"Gemini 3.1 Pro Preview Custom Tools","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"tiers":[{"input":4,"output":18,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4}}},"gemini-3.1-flash-lite-image":{"id":"gemini-3.1-flash-lite-image","name":"Nano Banana 2 Lite","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","high"]}],"limit":{"context":65536,"output":65536},"modalities":{"input":["text","image"]},"cost":{"input":0.25,"output":30}},"lyria-3-clip-preview":{"id":"lyria-3-clip-preview","name":"Lyria 3 Clip Preview","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image"]},"cost":{"input":0,"output":0}},"gemini-2.5-flash-image":{"id":"gemini-2.5-flash-image","name":"Nano Banana","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.3,"output":30,"cache_read":0.075}},"deep-research-max-preview-04-2026":{"id":"deep-research-max-preview-04-2026","name":"Deep Research Max Preview (Apr-21-2026)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"tiers":[{"input":4,"output":18,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4}}},"gemini-3-pro-image":{"id":"gemini-3-pro-image","name":"Nano Banana Pro","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high"]}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":2,"output":120}},"gemini-3.1-pro-preview":{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"tiers":[{"input":4,"output":18,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4}}},"deep-research-preview-04-2026":{"id":"deep-research-preview-04-2026","name":"Deep Research Preview (Apr-21-2026)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"tiers":[{"input":4,"output":18,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4}}},"gemini-2.5-flash-lite":{"id":"gemini-2.5-flash-lite","name":"Gemini 2.5 Flash-Lite","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","min":512,"max":24576}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","audio","video","pdf"]},"cost":{"input":0.1,"output":0.4,"cache_read":0.01,"input_audio":0.3}},"gemini-2.5-computer-use-preview-10-2025":{"id":"gemini-2.5-computer-use-preview-10-2025","name":"Gemini 2.5 Computer Use Preview 10-2025","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image"]},"cost":{"input":1.25,"output":10,"tiers":[{"input":2.5,"output":15,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":15}}},"gemini-3.6-flash":{"id":"gemini-3.6-flash","name":"Gemini 3.6 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.75,"output":3.75,"cache_read":0.075,"input_audio":0.75}},"gemini-3.1-flash-lite":{"id":"gemini-3.1-flash-lite","name":"Gemini 3.1 Flash Lite","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.25,"output":1.5,"cache_read":0.025,"input_audio":0.5}},"gemini-3.1-flash-live-preview":{"id":"gemini-3.1-flash-live-preview","name":"Gemini 3.1 Flash Live Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.75,"output":4.5,"input_audio":3,"output_audio":12}},"gemini-2.5-pro-preview-tts":{"id":"gemini-2.5-pro-preview-tts","name":"Gemini 2.5 Pro Preview TTS","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":16384},"modalities":{"input":["text"]},"cost":{"input":1,"output":20}},"gemini-3.5-flash":{"id":"gemini-3.5-flash","name":"Gemini 3.5 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":1.5,"output":9,"cache_read":0.15,"input_audio":1.5}},"veo-3.1-generate-preview":{"id":"veo-3.1-generate-preview","name":"Veo 3.1","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":480,"output":8192},"modalities":{"input":["text","image"]}},"gemini-3.1-flash-lite-preview":{"id":"gemini-3.1-flash-lite-preview","name":"Gemini 3.1 Flash Lite Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.25,"output":1.5,"cache_read":0.025,"input_audio":0.5}},"veo-3.1-fast-generate-preview":{"id":"veo-3.1-fast-generate-preview","name":"Veo 3.1 fast","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":480,"output":8192},"modalities":{"input":["text","image","video"]}},"gemini-2.5-flash-preview-tts":{"id":"gemini-2.5-flash-preview-tts","name":"Gemini 2.5 Flash Preview TTS","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":10}},"gemini-embedding-001":{"id":"gemini-embedding-001","name":"Gemini Embedding 001","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":2048,"output":1},"modalities":{"input":["text"]},"cost":{"input":0.15,"output":0}},"gemini-3.1-flash-image":{"id":"gemini-3.1-flash-image","name":"Nano Banana 2","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","high"]}],"limit":{"context":65536,"output":65536},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":0.5,"output":60}},"gemini-3.5-flash-lite":{"id":"gemini-3.5-flash-lite","name":"Gemini 3.5 Flash Lite","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.3,"output":2.5,"cache_read":0.03}},"gemini-3-pro-image-preview":{"id":"gemini-3-pro-image-preview","name":"Nano Banana Pro","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":2,"output":120}},"gemini-3.1-flash-tts-preview":{"id":"gemini-3.1-flash-tts-preview","name":"Gemini 3.1 Flash TTS Preview","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":8192,"output":16384},"modalities":{"input":["text"]},"cost":{"input":1,"output":20}},"veo-3.1-lite-generate-preview":{"id":"veo-3.1-lite-generate-preview","name":"Veo 3.1 lite","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":480,"output":8192},"modalities":{"input":["text","image"]}},"gemini-flash-lite-latest":{"id":"gemini-flash-lite-latest","name":"Gemini Flash-Lite Latest","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.3,"output":2.5,"cache_read":0.03}},"gemma-4-31b-it":{"id":"gemma-4-31b-it","name":"Gemma 4 31B IT","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image"]}},"gemini-embedding-2":{"id":"gemini-embedding-2","name":"Gemini Embedding 2","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":8192,"output":1},"modalities":{"input":["text","image","audio","video","pdf"]},"cost":{"input":0.2,"output":0,"input_audio":6.5}},"gemini-3-flash-preview":{"id":"gemini-3-flash-preview","name":"Gemini 3 Flash Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"input_audio":1}},"gemini-3.8-flash":{"id":"gemini-3.8-flash","name":"Gemini 3.8 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.75,"output":3.75,"cache_read":0.075,"input_audio":0.75}},"gemini-3.5-live-translate-preview":{"id":"gemini-3.5-live-translate-preview","name":"Gemini 3.5 Live Translate Preview","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":32768},"modalities":{"input":["audio"]},"cost":{"input":3.5,"output":21,"input_audio":3.5,"output_audio":21}},"lyria-3-pro-preview":{"id":"lyria-3-pro-preview","name":"Lyria 3 Pro Preview","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image"]},"cost":{"input":0,"output":0}},"gemini-3.7-flash":{"id":"gemini-3.7-flash","name":"Gemini 3.7 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.75,"output":3.75,"cache_read":0.075,"input_audio":0.75}},"gemini-2.5-pro":{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens","min":128,"max":32768}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","audio","video","pdf"]},"cost":{"input":1.25,"output":10,"cache_read":0.125,"tiers":[{"input":2.5,"output":15,"cache_read":0.25,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":15,"cache_read":0.25}}},"gemini-flash-latest":{"id":"gemini-flash-latest","name":"Gemini Flash Latest","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]},"cost":{"input":0.75,"output":3.75,"cache_read":0.075,"input_audio":0.75}},"gemini-3.1-flash-image-preview":{"id":"gemini-3.1-flash-image-preview","name":"Nano Banana 2","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","high"]}],"limit":{"context":65536,"output":65536},"modalities":{"input":["text","image","pdf"]},"cost":{"input":0.5,"output":60}},"gemini-omni-flash-preview":{"id":"gemini-omni-flash-preview","name":"Gemini Omni Flash Preview","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":1.5,"output":17.5}},"gemini-2.5-flash":{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","min":0,"max":24576}],"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","audio","video","pdf"]},"cost":{"input":0.3,"output":2.5,"cache_read":0.03,"input_audio":1}},"gemini-robotics-er-1.6-preview":{"id":"gemini-robotics-er-1.6-preview","name":"Gemini Robotics-ER 1.6 Preview","tool_call":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","min":0}],"limit":{"context":131072,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":1,"output":5,"input_audio":2}},"gemini-2.0-flash":{"id":"gemini-2.0-flash","name":"Gemini 2.0 Flash","tool_call":true,"reasoning":false,"limit":{"context":1048576,"output":8192},"modalities":{"input":["text","image","audio","video","pdf"]}},"gemini-3-pro-preview":{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro Preview","tool_call":true,"reasoning":true,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text","image","video","audio","pdf"]}},"gemini-2.0-flash-lite":{"id":"gemini-2.0-flash-lite","name":"Gemini 2.0 Flash-Lite","tool_call":true,"reasoning":false,"limit":{"context":1048576,"output":8192},"modalities":{"input":["text","image","audio","video","pdf"]}}}},"xai":{"name":"xAI","type":"OPENAI","baseUrl":"https://api.x.ai/","models":{"grok-4.3":{"id":"grok-4.3","name":"Grok 4.3","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high"]}],"limit":{"context":1000000,"output":30000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.25,"output":2.5,"cache_read":0.2,"tiers":[{"input":2.5,"output":5,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":5,"cache_read":0.4}}},"grok-4.20-0309-reasoning":{"id":"grok-4.20-0309-reasoning","name":"Grok 4.20 (Reasoning)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":1000000,"output":30000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.25,"output":2.5,"cache_read":0.2,"tiers":[{"input":2.5,"output":5,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":5,"cache_read":0.4}}},"grok-4.20-multi-agent-0309":{"id":"grok-4.20-multi-agent-0309","name":"Grok 4.20 Multi-Agent","tool_call":false,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh"]}],"limit":{"context":1000000,"output":30000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.25,"output":2.5,"cache_read":0.2,"tiers":[{"input":2.5,"output":5,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":5,"cache_read":0.4}}},"grok-imagine-image":{"id":"grok-imagine-image","name":"Grok Imagine Image","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":16000,"output":0},"modalities":{"input":["text","image","pdf"]}},"grok-imagine-video":{"id":"grok-imagine-video","name":"Grok Imagine Video","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":1024,"output":0},"modalities":{"input":["text","image","video","pdf"]}},"grok-4.5":{"id":"grok-4.5","name":"Grok 4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":500000,"output":500000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":6,"cache_read":0.3,"tiers":[{"input":4,"output":12,"cache_read":0.6,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":12,"cache_read":0.6}}},"grok-build-0.1":{"id":"grok-build-0.1","name":"Grok Build 0.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":256000,"output":256000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1,"output":2,"cache_read":0.2,"tiers":[{"input":2,"output":4,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2,"output":4,"cache_read":0.4}}},"grok-imagine-video-1.5":{"id":"grok-imagine-video-1.5","name":"Grok Imagine Video 1.5","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":1024,"output":0},"modalities":{"input":["text","image","audio","pdf"]}},"grok-imagine-image-2.0":{"id":"grok-imagine-image-2.0","name":"Grok Imagine Image 2.0","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":64000,"output":0},"modalities":{"input":["text","image","pdf"]}},"grok-4.6":{"id":"grok-4.6","name":"Grok 4.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh"]}],"limit":{"context":500000,"output":500000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":6,"cache_read":0.5,"tiers":[{"input":4,"output":12,"cache_read":1,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":4,"output":12,"cache_read":1}}},"grok-imagine-image-quality":{"id":"grok-imagine-image-quality","name":"Grok Imagine Image Quality","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":16000,"output":0},"modalities":{"input":["text","image","pdf"]}},"grok-4.20-0309-non-reasoning":{"id":"grok-4.20-0309-non-reasoning","name":"Grok 4.20 (Non-Reasoning)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1000000,"output":30000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.25,"output":2.5,"cache_read":0.2,"tiers":[{"input":2.5,"output":5,"cache_read":0.4,"tier":{"type":"context","size":200000}}],"context_over_200k":{"input":2.5,"output":5,"cache_read":0.4}}}}},"minimax":{"name":"MiniMax (minimax.io)","type":"OPENAI","baseUrl":"https://api.minimax.io/","models":{"MiniMax-M2":{"id":"MiniMax-M2","name":"MiniMax-M2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2}},"MiniMax-M2.1":{"id":"MiniMax-M2.1","name":"MiniMax-M2.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.03,"cache_write":0.375}},"MiniMax-M2.5":{"id":"MiniMax-M2.5","name":"MiniMax-M2.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.03,"cache_write":0.375}},"MiniMax-M2.5-highspeed":{"id":"MiniMax-M2.5-highspeed","name":"MiniMax-M2.5-highspeed","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.4,"cache_read":0.06,"cache_write":0.375}},"MiniMax-M3":{"id":"MiniMax-M3","name":"MiniMax-M3","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":1048576,"output":512000},"modalities":{"input":["text","image","video"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"tiers":[{"input":0.6,"output":2.4,"cache_read":0.12,"tier":{"type":"context","size":512000}}],"context_over_200k":{"input":0.6,"output":2.4,"cache_read":0.12}}},"MiniMax-M2.7-highspeed":{"id":"MiniMax-M2.7-highspeed","name":"MiniMax-M2.7-highspeed","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.4,"cache_read":0.06,"cache_write":0.375}},"MiniMax-M2.7":{"id":"MiniMax-M2.7","name":"MiniMax-M2.7","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"cache_write":0.375}}}},"minimax-cn":{"name":"MiniMax (minimaxi.com)","type":"OPENAI","baseUrl":"https://api.minimaxi.com/","models":{"MiniMax-M2":{"id":"MiniMax-M2","name":"MiniMax-M2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2}},"MiniMax-M2.1":{"id":"MiniMax-M2.1","name":"MiniMax-M2.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.03,"cache_write":0.375}},"MiniMax-M2.5":{"id":"MiniMax-M2.5","name":"MiniMax-M2.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.03,"cache_write":0.375}},"MiniMax-M2.5-highspeed":{"id":"MiniMax-M2.5-highspeed","name":"MiniMax-M2.5-highspeed","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.4,"cache_read":0.06,"cache_write":0.375}},"MiniMax-M3":{"id":"MiniMax-M3","name":"MiniMax-M3","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":1048576,"output":512000},"modalities":{"input":["text","image","video"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"tiers":[{"input":0.6,"output":2.4,"cache_read":0.12,"tier":{"type":"context","size":512000}}],"context_over_200k":{"input":0.6,"output":2.4,"cache_read":0.12}}},"MiniMax-M2.7-highspeed":{"id":"MiniMax-M2.7-highspeed","name":"MiniMax-M2.7-highspeed","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.4,"cache_read":0.06,"cache_write":0.375}},"MiniMax-M2.7":{"id":"MiniMax-M2.7","name":"MiniMax-M2.7","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"cache_write":0.375}}}},"moonshotai":{"name":"Moonshot AI","type":"OPENAI","baseUrl":"https://api.moonshot.ai","models":{"kimi-k2.7-code-highspeed":{"id":"kimi-k2.7-code-highspeed","name":"Kimi K2.7 Code HighSpeed","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":1.9,"output":8,"cache_read":0.38}},"kimi-k2.6":{"id":"kimi-k2.6","name":"Kimi K2.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.95,"output":4,"cache_read":0.16}},"kimi-k2.7-code":{"id":"kimi-k2.7-code","name":"Kimi K2.7 Code","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.95,"output":4,"cache_read":0.19}},"kimi-k3":{"id":"kimi-k3","name":"Kimi K3","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1048576,"output":131072},"modalities":{"input":["text","image","video"]},"cost":{"input":3,"output":15,"cache_read":0.3}},"kimi-k2-0711-preview":{"id":"kimi-k2-0711-preview","name":"Kimi K2 0711","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}},"kimi-k2-thinking-turbo":{"id":"kimi-k2-thinking-turbo","name":"Kimi K2 Thinking Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":1.15,"output":8,"cache_read":0.15}},"kimi-k2.5":{"id":"kimi-k2.5","name":"Kimi K2.5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.6,"output":3,"cache_read":0.1}},"kimi-k2-0905-preview":{"id":"kimi-k2-0905-preview","name":"Kimi K2 0905","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}},"kimi-k2-turbo-preview":{"id":"kimi-k2-turbo-preview","name":"Kimi K2 Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":2.4,"output":10,"cache_read":0.6}},"kimi-k2-thinking":{"id":"kimi-k2-thinking","name":"Kimi K2 Thinking","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}}}},"moonshotai-cn":{"name":"Moonshot AI (China)","type":"OPENAI","baseUrl":"https://api.moonshot.cn","models":{"kimi-k3":{"id":"kimi-k3","name":"Kimi K3","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1048576,"output":131072},"modalities":{"input":["text","image","video"]},"cost":{"input":3,"output":15,"cache_read":0.3}},"kimi-k2.7-code":{"id":"kimi-k2.7-code","name":"Kimi K2.7 Code","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.95,"output":4,"cache_read":0.19}},"kimi-k2.6":{"id":"kimi-k2.6","name":"Kimi K2.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.95,"output":4,"cache_read":0.16}},"kimi-k2.7-code-highspeed":{"id":"kimi-k2.7-code-highspeed","name":"Kimi K2.7 Code HighSpeed","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":1.9,"output":8,"cache_read":0.38}},"kimi-k2-thinking":{"id":"kimi-k2-thinking","name":"Kimi K2 Thinking","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}},"kimi-k2-turbo-preview":{"id":"kimi-k2-turbo-preview","name":"Kimi K2 Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":2.4,"output":10,"cache_read":0.6}},"kimi-k2-0905-preview":{"id":"kimi-k2-0905-preview","name":"Kimi K2 0905","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}},"kimi-k2.5":{"id":"kimi-k2.5","name":"Kimi K2.5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.6,"output":3,"cache_read":0.1}},"kimi-k2-thinking-turbo":{"id":"kimi-k2-thinking-turbo","name":"Kimi K2 Thinking Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":262144,"output":262144},"modalities":{"input":["text"]},"cost":{"input":1.15,"output":8,"cache_read":0.15}},"kimi-k2-0711-preview":{"id":"kimi-k2-0711-preview","name":"Kimi K2 0711","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.5,"cache_read":0.15}}}},"openai":{"name":"OpenAI","type":"OPENAI","baseUrl":"https://api.openai.com/","models":{"gpt-5-nano":{"id":"gpt-5-nano","name":"GPT-5 Nano","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":0.05,"output":0.4,"cache_read":0.005}},"gpt-4.1-nano":{"id":"gpt-4.1-nano","name":"GPT-4.1 nano","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1047576,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.1,"output":0.4,"cache_read":0.025}},"gpt-4o-2024-05-13":{"id":"gpt-4o-2024-05-13","name":"GPT-4o (2024-05-13)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":4096},"modalities":{"input":["text","image"]},"cost":{"input":5,"output":15}},"gpt-5-pro":{"id":"gpt-5-pro","name":"GPT-5 Pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["high"]}],"limit":{"context":400000,"input":272000,"output":272000},"modalities":{"input":["text","image"]},"cost":{"input":15,"output":120}},"chatgpt-image-latest":{"id":"chatgpt-image-latest","name":"chatgpt-image-latest","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]}},"gpt-5.6-sol":{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":4,"output":20,"cache_read":0.4,"cache_write":5,"tiers":[{"input":8,"output":30,"cache_read":0.8,"cache_write":10,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":8,"output":30,"cache_read":0.8,"cache_write":10}}},"gpt-4o-2024-08-06":{"id":"gpt-4o-2024-08-06","name":"GPT-4o (2024-08-06)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image"]},"cost":{"input":2.5,"output":10,"cache_read":1.25}},"gpt-6-astra":{"id":"gpt-6-astra","name":"GPT-6 Astra","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":10,"output":50,"cache_read":1,"cache_write":12.5,"tiers":[{"input":20,"output":75,"cache_read":2,"cache_write":25,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":20,"output":75,"cache_read":2,"cache_write":25}}},"gpt-5.2-pro":{"id":"gpt-5.2-pro","name":"GPT-5.2 Pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":21,"output":168}},"gpt-5.3-codex-spark":{"id":"gpt-5.3-codex-spark","name":"GPT-5.3 Codex Spark","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":128000,"input":100000,"output":32000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"gpt-4.1-mini":{"id":"gpt-4.1-mini","name":"GPT-4.1 mini","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1047576,"output":32768},"modalities":{"input":["text","image","pdf"]},"cost":{"input":0.4,"output":1.6,"cache_read":0.1}},"gpt-5.4":{"id":"gpt-5.4","name":"GPT-5.4","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2.5,"output":15,"cache_read":0.25,"tiers":[{"input":5,"output":22.5,"cache_read":0.5,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":5,"output":22.5,"cache_read":0.5}}},"gpt-4-turbo":{"id":"gpt-4-turbo","name":"GPT-4 Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":4096},"modalities":{"input":["text","image"]},"cost":{"input":10,"output":30}},"gpt-5.1":{"id":"gpt-5.1","name":"GPT-5.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":1.25,"output":10,"cache_read":0.125}},"o1":{"id":"o1","name":"o1","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":15,"output":60,"cache_read":7.5}},"gpt-4o":{"id":"gpt-4o","name":"GPT-4o","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2.5,"output":10,"cache_read":1.25}},"gpt-5.6-luna":{"id":"gpt-5.6-luna","name":"GPT-5.6 Luna","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":0.2,"output":1.2,"cache_read":0.02,"cache_write":0.25,"tiers":[{"input":0.4,"output":1.8,"cache_read":0.04,"cache_write":0.5,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":0.4,"output":1.8,"cache_read":0.04,"cache_write":0.5}}},"gpt-5.3-codex":{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"gpt-4o-mini":{"id":"gpt-4o-mini","name":"GPT-4o mini","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image","pdf"]},"cost":{"input":0.15,"output":0.6,"cache_read":0.075}},"gpt-image-1.5":{"id":"gpt-image-1.5","name":"gpt-image-1.5","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]}},"o1-pro":{"id":"o1-pro","name":"o1-pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image"]},"cost":{"input":150,"output":600}},"gpt-4.1":{"id":"gpt-4.1","name":"GPT-4.1","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1047576,"output":32768},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":8,"cache_read":0.5}},"text-embedding-ada-002":{"id":"text-embedding-ada-002","name":"text-embedding-ada-002","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":8192,"output":1536},"modalities":{"input":["text"]},"cost":{"input":0.1,"output":0}},"gpt-image-1":{"id":"gpt-image-1","name":"gpt-image-1","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]}},"gpt-5.4-nano":{"id":"gpt-5.4-nano","name":"GPT-5.4 nano","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":0.2,"output":1.25,"cache_read":0.02}},"gpt-5.5-pro":{"id":"gpt-5.5-pro","name":"GPT-5.5 Pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["medium","high","xhigh"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":30,"output":180,"tiers":[{"input":60,"output":270,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":60,"output":270}}},"gpt-image-1-mini":{"id":"gpt-image-1-mini","name":"gpt-image-1-mini","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]}},"gpt-5.4-mini":{"id":"gpt-5.4-mini","name":"GPT-5.4 mini","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":0.75,"output":4.5,"cache_read":0.075}},"gpt-image-2":{"id":"gpt-image-2","name":"gpt-image-2","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":0,"input":0,"output":0},"modalities":{"input":["text","image"]},"cost":{"input":5,"output":30,"cache_read":1.25}},"gpt-3.5-turbo":{"id":"gpt-3.5-turbo","name":"GPT-3.5-turbo","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16385,"output":4096},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":1.5,"cache_read":0}},"gpt-5.6":{"id":"gpt-5.6","name":"GPT-5.6","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":4,"output":20,"cache_read":0.4,"cache_write":5,"tiers":[{"input":8,"output":30,"cache_read":0.8,"cache_write":10,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":8,"output":30,"cache_read":0.8,"cache_write":10}}},"text-embedding-3-small":{"id":"text-embedding-3-small","name":"text-embedding-3-small","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":8191,"output":1536},"modalities":{"input":["text"]},"cost":{"input":0.02,"output":0}},"gpt-5-mini":{"id":"gpt-5-mini","name":"GPT-5 Mini","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":0.25,"output":2,"cache_read":0.025}},"gpt-5.4-pro":{"id":"gpt-5.4-pro","name":"GPT-5.4 Pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["medium","high","xhigh"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":30,"output":180,"tiers":[{"input":60,"output":270,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":60,"output":270}}},"text-embedding-3-large":{"id":"text-embedding-3-large","name":"text-embedding-3-large","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":8191,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.13,"output":0}},"gpt-5.6-terra":{"id":"gpt-5.6-terra","name":"GPT-5.6 Terra","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh","max"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":12,"cache_read":0.2,"cache_write":2.5,"tiers":[{"input":4,"output":18,"cache_read":0.4,"cache_write":5,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":4,"output":18,"cache_read":0.4,"cache_write":5}}},"gpt-4":{"id":"gpt-4","name":"GPT-4","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":8192},"modalities":{"input":["text"]},"cost":{"input":30,"output":60}},"gpt-5.2":{"id":"gpt-5.2","name":"GPT-5.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"gpt-5":{"id":"gpt-5","name":"GPT-5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high"]}],"limit":{"context":400000,"input":272000,"output":128000},"modalities":{"input":["text","image"]},"cost":{"input":1.25,"output":10,"cache_read":0.125}},"gpt-5.2-chat-latest":{"id":"gpt-5.2-chat-latest","name":"GPT-5.2 Chat","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["medium"]}],"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"o4-mini":{"id":"o4-mini","name":"o4-mini","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image"]},"cost":{"input":1.1,"output":4.4,"cache_read":0.275}},"gpt-realtime-2.1":{"id":"gpt-realtime-2.1","name":"GPT-Realtime-2.1","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["minimal","low","medium","high","xhigh"]}],"limit":{"context":128000,"input":96000,"output":32000},"modalities":{"input":["text","audio","image"]},"cost":{"input":4,"output":24,"cache_read":0.4,"input_audio":32,"output_audio":64}},"o3-mini":{"id":"o3-mini","name":"o3-mini","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text"]},"cost":{"input":1.1,"output":4.4,"cache_read":0.55}},"o3":{"id":"o3","name":"o3","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":2,"output":8,"cache_read":0.5}},"o3-pro":{"id":"o3-pro","name":"o3-pro","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high"]}],"limit":{"context":200000,"output":100000},"modalities":{"input":["text","image"]},"cost":{"input":20,"output":80}},"gpt-5.3-chat-latest":{"id":"gpt-5.3-chat-latest","name":"GPT-5.3 Chat (latest)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image"]},"cost":{"input":1.75,"output":14,"cache_read":0.175}},"gpt-5.5":{"id":"gpt-5.5","name":"GPT-5.5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","low","medium","high","xhigh"]}],"limit":{"context":1050000,"input":922000,"output":128000},"modalities":{"input":["text","image","pdf"]},"cost":{"input":5,"output":30,"cache_read":0.5,"tiers":[{"input":10,"output":45,"cache_read":1,"tier":{"type":"context","size":272000}}],"context_over_200k":{"input":10,"output":45,"cache_read":1}}},"gpt-4o-2024-11-20":{"id":"gpt-4o-2024-11-20","name":"GPT-4o (2024-11-20)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":128000,"output":16384},"modalities":{"input":["text","image"]},"cost":{"input":2.5,"output":10,"cache_read":1.25}}}},"alibaba":{"name":"Alibaba","type":"OPENAI","baseUrl":"https://dashscope-intl.aliyuncs.com/compatible-mode","models":{"qwen3.7-max":{"id":"qwen3.7-max","name":"Qwen3.7 Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"]},"cost":{"input":2.5,"output":7.5,"cache_read":0.5,"cache_write":3.125}},"qwen2-5-72b-instruct":{"id":"qwen2-5-72b-instruct","name":"Qwen2.5 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":5.6}},"deepseek-v4-flash-0731":{"id":"deepseek-v4-flash-0731","name":"DeepSeek V4 Flash 0731","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.2,"output":0.4,"cache_read":0.04}},"qwen3-coder-plus":{"id":"qwen3-coder-plus","name":"Qwen3 Coder Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1,"output":5}},"qwen3-next-80b-a3b-thinking":{"id":"qwen3-next-80b-a3b-thinking","name":"Qwen3-Next 80B-A3B (Thinking)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":6}},"qwen2-5-omni-7b":{"id":"qwen2-5-omni-7b","name":"Qwen2.5-Omni 7B","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.1,"output":0.4,"input_audio":6.76}},"qwen-mt-turbo":{"id":"qwen-mt-turbo","name":"Qwen-MT Turbo","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.16,"output":0.49}},"qwen-vl-max":{"id":"qwen-vl-max","name":"Qwen-VL Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.8,"output":3.2}},"qwen3-next-80b-a3b-instruct":{"id":"qwen3-next-80b-a3b-instruct","name":"Qwen3-Next 80B-A3B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":2}},"qwen3-coder-flash":{"id":"qwen3-coder-flash","name":"Qwen3 Coder Flash","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.5}},"qwen3-14b":{"id":"qwen3-14b","name":"Qwen3 14B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.35,"output":1.4,"reasoning":4.2}},"qwen-max":{"id":"qwen-max","name":"Qwen Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":8192},"modalities":{"input":["text"]},"cost":{"input":1.6,"output":6.4}},"qwen3.6-plus":{"id":"qwen3.6-plus","name":"Qwen3.6 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"cache_write":0.625,"tiers":[{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5,"tier":{"type":"context","size":256000}}],"context_over_200k":{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5}}},"qwen-vl-plus":{"id":"qwen-vl-plus","name":"Qwen-VL Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.21,"output":0.63}},"qwen-omni-turbo-realtime":{"id":"qwen-omni-turbo-realtime","name":"Qwen-Omni Turbo Realtime","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio"]},"cost":{"input":0.27,"output":1.07,"input_audio":4.44,"output_audio":8.89}},"qwen3.5-27b":{"id":"qwen3.5-27b","name":"Qwen3.5 27B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.3,"output":2.4}},"qwen3.5-35b-a3b":{"id":"qwen3.5-35b-a3b","name":"Qwen3.5 35B-A3B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.25,"output":2}},"qwen-flash":{"id":"qwen-flash","name":"Qwen Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.05,"output":0.4}},"glm-5.2":{"id":"glm-5.2","name":"GLM-5.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","minimal","low","medium","high","xhigh","max"]}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":4.4,"cache_read":0.28,"cache_write":0}},"qwen-turbo":{"id":"qwen-turbo","name":"Qwen Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.05,"output":0.2,"reasoning":0.5}},"qwen3-livetranslate-flash-realtime":{"id":"qwen3-livetranslate-flash-realtime","name":"Qwen3-LiveTranslate Flash Realtime","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":53248,"output":4096},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":10,"output":10,"input_audio":10,"output_audio":38}},"qwen3-vl-30b-a3b":{"id":"qwen3-vl-30b-a3b","name":"Qwen3-VL 30B-A3B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.2,"output":0.8,"reasoning":2.4}},"qwen-vl-ocr":{"id":"qwen-vl-ocr","name":"Qwen-VL OCR","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":34096,"output":4096},"modalities":{"input":["text","image"]},"cost":{"input":0.72,"output":0.72}},"qwen3-32b":{"id":"qwen3-32b","name":"Qwen3 32B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.7,"output":2.8,"reasoning":8.4}},"qwq-plus":{"id":"qwq-plus","name":"QwQ Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.8,"output":2.4}},"qwen3-vl-plus":{"id":"qwen3-vl-plus","name":"Qwen3-VL Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.2,"output":1.6,"reasoning":4.8}},"qwen2-5-7b-instruct":{"id":"qwen2-5-7b-instruct","name":"Qwen2.5 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.175,"output":0.7}},"qwen3-coder-30b-a3b-instruct":{"id":"qwen3-coder-30b-a3b-instruct","name":"Qwen3-Coder 30B-A3B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.45,"output":2.25,"tiers":[{"input":0.75,"output":3.75,"tier":{"type":"context","size":32000}},{"input":1.2,"output":6,"tier":{"type":"context","size":128000}}]}},"qwen3.5-397b-a17b":{"id":"qwen3.5-397b-a17b","name":"Qwen3.5 397B-A17B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.6,"output":3.6}},"qwen3.6-27b":{"id":"qwen3.6-27b","name":"Qwen3.6 27B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.6,"output":3.6}},"qwen3-asr-flash":{"id":"qwen3-asr-flash","name":"Qwen3-ASR Flash","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":53248,"output":4096},"modalities":{"input":["audio"]},"cost":{"input":0.035,"output":0.035}},"qwen3-omni-flash":{"id":"qwen3-omni-flash","name":"Qwen3-Omni Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":65536,"output":16384},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.43,"output":1.66,"input_audio":3.81,"output_audio":15.11}},"qwen2-5-vl-72b-instruct":{"id":"qwen2-5-vl-72b-instruct","name":"Qwen2.5-VL 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":2.8,"output":8.4}},"qwen3.6-35b-a3b":{"id":"qwen3.6-35b-a3b","name":"Qwen3.6 35B-A3B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.248,"output":1.485}},"qwen3-max":{"id":"qwen3-max","name":"Qwen3 Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1.2,"output":6}},"qwen-plus":{"id":"qwen-plus","name":"Qwen Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.4,"output":1.2,"reasoning":4}},"qwen3.5-122b-a10b":{"id":"qwen3.5-122b-a10b","name":"Qwen3.5 122B-A10B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video","audio"]},"cost":{"input":0.4,"output":3.2}},"qwen3-omni-flash-realtime":{"id":"qwen3-omni-flash-realtime","name":"Qwen3-Omni Flash Realtime","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":65536,"output":16384},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.52,"output":1.99,"input_audio":4.57,"output_audio":18.13}},"qwen2-5-vl-7b-instruct":{"id":"qwen2-5-vl-7b-instruct","name":"Qwen2.5-VL 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.35,"output":1.05}},"qwen3.6-flash":{"id":"qwen3.6-flash","name":"Qwen3.6 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.1875,"output":1.125,"cache_write":0.234375}},"qwen3.8-flash":{"id":"qwen3.8-flash","name":"Qwen3.8 Flash","tool_call":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","xhigh"]},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video"]},"cost":{"input":0.15,"output":0.47,"cache_read":0.016,"cache_write":0.2}},"qwen2-5-14b-instruct":{"id":"qwen2-5-14b-instruct","name":"Qwen2.5 14B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.35,"output":1.4}},"qwen3.6-max-preview":{"id":"qwen3.6-max-preview","name":"Qwen3.6 Max Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1.3,"output":7.8,"cache_read":0.13,"cache_write":1.625}},"qwen-plus-character-ja":{"id":"qwen-plus-character-ja","name":"Qwen Plus Character (Japanese)","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":512},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":1.4}},"qwen3.8-max":{"id":"qwen3.8-max","name":"Qwen3.8 Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","xhigh"]},{"type":"budget_tokens","min":0,"max":262144}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":2,"output":6,"cache_read":0.25,"cache_write":2.5}},"qwen3-8b":{"id":"qwen3-8b","name":"Qwen3 8B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.18,"output":0.7,"reasoning":2.1}},"qwen3-235b-a22b":{"id":"qwen3-235b-a22b","name":"Qwen3 235B-A22B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.7,"output":2.8,"reasoning":8.4}},"qwen3.7-plus":{"id":"qwen3.7-plus","name":"Qwen3.7 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"cache_write":0.625,"tiers":[{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5,"tier":{"type":"context","size":256000}}],"context_over_200k":{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5}}},"qwen-omni-turbo":{"id":"qwen-omni-turbo","name":"Qwen-Omni Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.07,"output":0.27,"input_audio":4.44,"output_audio":8.89}},"qwen3-coder-480b-a35b-instruct":{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen3-Coder 480B-A35B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1.5,"output":7.5,"tiers":[{"input":2.7,"output":13.5,"tier":{"type":"context","size":32000}},{"input":4.5,"output":22.5,"tier":{"type":"context","size":128000}}]}},"qwen2-5-32b-instruct":{"id":"qwen2-5-32b-instruct","name":"Qwen2.5 32B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.7,"output":2.8}},"qwen3-vl-235b-a22b":{"id":"qwen3-vl-235b-a22b","name":"Qwen3-VL 235B-A22B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.7,"output":2.8,"reasoning":8.4}},"qwen3.5-plus":{"id":"qwen3.5-plus","name":"Qwen3.5 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.4,"output":2.4,"reasoning":2.4}},"qwen-mt-plus":{"id":"qwen-mt-plus","name":"Qwen-MT Plus","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":8192},"modalities":{"input":["text"]},"cost":{"input":2.46,"output":7.37}},"qvq-max":{"id":"qvq-max","name":"QVQ Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":1.2,"output":4.8}}}},"alibaba-cn":{"name":"Alibaba (China)","type":"OPENAI","baseUrl":"https://dashscope.aliyuncs.com/compatible-mode","models":{"qwen3.7-max":{"id":"qwen3.7-max","name":"Qwen3.7 Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"]},"cost":{"input":2.5,"output":7.5,"cache_read":0.5,"cache_write":3.125}},"qwen2-5-72b-instruct":{"id":"qwen2-5-72b-instruct","name":"Qwen2.5 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":1.721}},"deepseek-r1-distill-qwen-7b":{"id":"deepseek-r1-distill-qwen-7b","name":"DeepSeek R1 Distill Qwen 7B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.072,"output":0.144}},"qwen3-next-80b-a3b-thinking":{"id":"qwen3-next-80b-a3b-thinking","name":"Qwen3-Next 80B-A3B (Thinking)","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":131072,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":1.434}},"deepseek-v3":{"id":"deepseek-v3","name":"DeepSeek V3","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":65536,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":1.147}},"qwen2-5-omni-7b":{"id":"qwen2-5-omni-7b","name":"Qwen2.5-Omni 7B","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.087,"output":0.345,"input_audio":5.448}},"qwen-mt-turbo":{"id":"qwen-mt-turbo","name":"Qwen-MT Turbo","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.101,"output":0.28}},"deepseek-v3-1":{"id":"deepseek-v3-1","name":"DeepSeek V3.1","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":1.721}},"qwen-deep-research":{"id":"qwen-deep-research","name":"Qwen Deep Research","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":7.742,"output":23.367}},"qwen-vl-max":{"id":"qwen-vl-max","name":"Qwen-VL Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.23,"output":0.574}},"qwen3-next-80b-a3b-instruct":{"id":"qwen3-next-80b-a3b-instruct","name":"Qwen3-Next 80B-A3B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.574}},"qwen3-coder-flash":{"id":"qwen3-coder-flash","name":"Qwen3 Coder Flash","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.574}},"qwen3-14b":{"id":"qwen3-14b","name":"Qwen3 14B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.574,"reasoning":1.434}},"qwen-max":{"id":"qwen-max","name":"Qwen Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.345,"output":1.377}},"qwen3.6-plus":{"id":"qwen3.6-plus","name":"Qwen3.6 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"cache_write":0.625,"tiers":[{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5,"tier":{"type":"context","size":256000}}],"context_over_200k":{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5}}},"moonshot-kimi-k2-instruct":{"id":"moonshot-kimi-k2-instruct","name":"Moonshot Kimi K2 Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":2.294}},"qwen-vl-plus":{"id":"qwen-vl-plus","name":"Qwen-VL Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.115,"output":0.287}},"qwen-omni-turbo-realtime":{"id":"qwen-omni-turbo-realtime","name":"Qwen-Omni Turbo Realtime","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio"]},"cost":{"input":0.23,"output":0.918,"input_audio":3.584,"output_audio":7.168}},"kimi-k2.6":{"id":"kimi-k2.6","name":"Moonshot Kimi K2.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":262144,"output":16384},"modalities":{"input":["text","image","video"]},"cost":{"input":0.929,"output":3.858}},"qwen-flash":{"id":"qwen-flash","name":"Qwen Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.022,"output":0.216}},"glm-5.2":{"id":"glm-5.2","name":"GLM-5.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":1000000,"output":128000},"modalities":{"input":["text"]},"cost":{"input":1.1,"output":3.851,"cache_read":0.275,"cache_write":0}},"qwen-turbo":{"id":"qwen-turbo","name":"Qwen Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":1000000,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.044,"output":0.087,"reasoning":0.431}},"qwen3-vl-30b-a3b":{"id":"qwen3-vl-30b-a3b","name":"Qwen3-VL 30B-A3B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.108,"output":0.431,"reasoning":1.076}},"qwen-vl-ocr":{"id":"qwen-vl-ocr","name":"Qwen-VL OCR","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":34096,"output":4096},"modalities":{"input":["text","image"]},"cost":{"input":0.717,"output":0.717}},"tongyi-intent-detect-v3":{"id":"tongyi-intent-detect-v3","name":"Tongyi Intent Detect V3","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":8192,"output":1024},"modalities":{"input":["text"]},"cost":{"input":0.058,"output":0.144}},"deepseek-v4-flash":{"id":"deepseek-v4-flash","name":"DeepSeek V4 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028}},"kimi-k2-thinking":{"id":"kimi-k2-thinking","name":"Moonshot Kimi K2 Thinking","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"budget_tokens"}],"limit":{"context":262144,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":2.294}},"qwen3-32b":{"id":"qwen3-32b","name":"Qwen3 32B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":1.147,"reasoning":2.868}},"qwq-plus":{"id":"qwq-plus","name":"QwQ Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.23,"output":0.574}},"qwen3.5-flash":{"id":"qwen3.5-flash","name":"Qwen3.5 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.172,"output":1.72,"reasoning":1.72}},"qwen3-vl-plus":{"id":"qwen3-vl-plus","name":"Qwen3-VL Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.143353,"output":1.433525,"reasoning":4.300576}},"deepseek-v3-2-exp":{"id":"deepseek-v3-2-exp","name":"DeepSeek V3.2 Exp","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.431}},"qwen2-5-7b-instruct":{"id":"qwen2-5-7b-instruct","name":"Qwen2.5 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.072,"output":0.144}},"qwen3-coder-30b-a3b-instruct":{"id":"qwen3-coder-30b-a3b-instruct","name":"Qwen3-Coder 30B-A3B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.216,"output":0.861,"tiers":[{"input":0.323,"output":1.291,"tier":{"type":"context","size":32000}},{"input":0.538,"output":2.151,"tier":{"type":"context","size":128000}}]}},"qwen2-5-math-7b-instruct":{"id":"qwen2-5-math-7b-instruct","name":"Qwen2.5-Math 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":4096,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.287}},"qwen3.5-397b-a17b":{"id":"qwen3.5-397b-a17b","name":"Qwen3.5 397B-A17B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":262144,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.172,"output":1.032,"reasoning":1.032,"tiers":[{"input":0.43,"output":2.58,"reasoning":2.58,"tier":{"type":"context","size":128000}}]}},"qwq-32b":{"id":"qwq-32b","name":"QwQ 32B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"deepseek-r1":{"id":"deepseek-r1","name":"DeepSeek R1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":2.294}},"qwen3-asr-flash":{"id":"qwen3-asr-flash","name":"Qwen3-ASR Flash","tool_call":false,"temperature":false,"reasoning":false,"limit":{"context":53248,"output":4096},"modalities":{"input":["audio"]},"cost":{"input":0.032,"output":0.032}},"qwen3-omni-flash":{"id":"qwen3-omni-flash","name":"Qwen3-Omni Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":65536,"output":16384},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.058,"output":0.23,"input_audio":3.584,"output_audio":7.168}},"deepseek-r1-distill-qwen-1-5b":{"id":"deepseek-r1-distill-qwen-1-5b","name":"DeepSeek R1 Distill Qwen 1.5B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0,"output":0}},"qwen3.7-flash":{"id":"qwen3.7-flash","name":"Qwen3.7 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"input":991000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.02962,"output":0.1185,"cache_read":0.002962,"cache_write":0.03703,"tiers":[{"input":0.08887,"output":0.35549,"cache_read":0.008887,"cache_write":0.11109,"tier":{"type":"context","size":32000}},{"input":0.17774,"output":0.71098,"cache_read":0.017774,"cache_write":0.22218,"tier":{"type":"context","size":256000}}]}},"qwen2-5-vl-72b-instruct":{"id":"qwen2-5-vl-72b-instruct","name":"Qwen2.5-VL 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":2.294,"output":6.881}},"deepseek-r1-0528":{"id":"deepseek-r1-0528","name":"DeepSeek R1 0528","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":2.294}},"deepseek-r1-distill-qwen-32b":{"id":"deepseek-r1-distill-qwen-32b","name":"DeepSeek R1 Distill Qwen 32B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"qwen3-max":{"id":"qwen3-max","name":"Qwen3 Max","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.861,"output":3.441}},"qwen2-5-coder-32b-instruct":{"id":"qwen2-5-coder-32b-instruct","name":"Qwen2.5-Coder 32B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"deepseek-r1-distill-llama-70b":{"id":"deepseek-r1-distill-llama-70b","name":"DeepSeek R1 Distill Llama 70B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"qwen2-5-math-72b-instruct":{"id":"qwen2-5-math-72b-instruct","name":"Qwen2.5-Math 72B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":4096,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":1.721}},"qwen-plus":{"id":"qwen-plus","name":"Qwen Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.115,"output":0.287,"reasoning":1.147,"cache_read":0.012,"cache_write":0.144}},"MiniMax-M2.5":{"id":"MiniMax-M2.5","name":"MiniMax-M2.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2}},"qwen3-omni-flash-realtime":{"id":"qwen3-omni-flash-realtime","name":"Qwen3-Omni Flash Realtime","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":65536,"output":16384},"modalities":{"input":["text","image","audio"]},"cost":{"input":0.23,"output":0.918,"input_audio":3.584,"output_audio":7.168}},"qwen2-5-vl-7b-instruct":{"id":"qwen2-5-vl-7b-instruct","name":"Qwen2.5-VL 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":0.287,"output":0.717}},"qwen3.6-flash":{"id":"qwen3.6-flash","name":"Qwen3.6 Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":131072}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.1875,"output":1.125,"cache_write":0.234375}},"qwen3.8-flash":{"id":"qwen3.8-flash","name":"Qwen3.8 Flash","tool_call":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","xhigh"]},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video"]},"cost":{"input":0.11875,"output":0.40073,"cache_read":0.01187,"cache_write":0.14844}},"qwen2-5-14b-instruct":{"id":"qwen2-5-14b-instruct","name":"Qwen2.5 14B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.431}},"qwen-math-turbo":{"id":"qwen-math-turbo","name":"Qwen Math Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":4096,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"qwen-plus-character":{"id":"qwen-plus-character","name":"Qwen Plus Character","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":4096},"modalities":{"input":["text"]},"cost":{"input":0.115,"output":0.287}},"qwen3.6-max-preview":{"id":"qwen3.6-max-preview","name":"Qwen3.6 Max Preview","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":131072}],"limit":{"context":245800,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1.32,"output":7.9,"cache_read":0.132}},"glm-5":{"id":"glm-5","name":"GLM-5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":32768}],"limit":{"context":202752,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.573,"output":2.58,"tiers":[{"input":0.86,"output":3.154,"tier":{"type":"context","size":32000}}]}},"qwen3.8-max":{"id":"qwen3.8-max","name":"Qwen3.8 Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","xhigh"]},{"type":"budget_tokens","min":0,"max":262144}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":1.77744,"output":5.33231,"cache_read":0.22218,"cache_write":2.22179}},"kimi-k2.5":{"id":"kimi-k2.5","name":"Moonshot Kimi K2.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image","video"]},"cost":{"input":0.574,"output":2.411}},"qwen3-8b":{"id":"qwen3-8b","name":"Qwen3 8B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.072,"output":0.287,"reasoning":0.717}},"glm-5.1":{"id":"glm-5.1","name":"GLM-5.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":131072}],"limit":{"context":202752,"output":128000},"modalities":{"input":["text"]},"cost":{"input":0.825,"output":3.301,"cache_read":0.17,"tiers":[{"input":1.1,"output":3.851,"tier":{"type":"context","size":32000}}]}},"qwen3-235b-a22b":{"id":"qwen3-235b-a22b","name":"Qwen3 235B-A22B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":38912}],"limit":{"context":131072,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":1.147,"reasoning":2.868}},"qwen3.7-plus":{"id":"qwen3.7-plus","name":"Qwen3.7 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":262144}],"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image","video"]},"cost":{"input":0.5,"output":3,"cache_read":0.05,"cache_write":0.625,"tiers":[{"input":2,"output":6,"cache_read":0.2,"cache_write":2.5,"tier":{"type":"context","size":128000}}]}},"qwen-omni-turbo":{"id":"qwen-omni-turbo","name":"Qwen-Omni Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":32768,"output":2048},"modalities":{"input":["text","image","audio","video"]},"cost":{"input":0.058,"output":0.23,"input_audio":3.584,"output_audio":7.168}},"deepseek-v4-pro":{"id":"deepseek-v4-pro","name":"DeepSeek V4 Pro","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"]},"cost":{"input":0.435,"output":0.87,"cache_read":0.003625}},"qwen3-coder-480b-a35b-instruct":{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen3-Coder 480B-A35B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":262144,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.861,"output":3.441,"tiers":[{"input":1.291,"output":5.161,"tier":{"type":"context","size":32000}},{"input":2.151,"output":8.602,"tier":{"type":"context","size":128000}}]}},"qwen2-5-32b-instruct":{"id":"qwen2-5-32b-instruct","name":"Qwen2.5 32B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.287,"output":0.861}},"qwen2-5-coder-7b-instruct":{"id":"qwen2-5-coder-7b-instruct","name":"Qwen2.5-Coder 7B Instruct","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.287}},"glm-5.3":{"id":"glm-5.3","name":"GLM-5.3","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1.1,"output":3.851,"cache_read":0.275,"cache_write":0}},"qwen-long":{"id":"qwen-long","name":"Qwen Long","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":10000000,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.072,"output":0.287}},"deepseek-r1-distill-llama-8b":{"id":"deepseek-r1-distill-llama-8b","name":"DeepSeek R1 Distill Llama 8B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0,"output":0}},"deepseek-r1-distill-qwen-14b":{"id":"deepseek-r1-distill-qwen-14b","name":"DeepSeek R1 Distill Qwen 14B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":32768,"output":16384},"modalities":{"input":["text"]},"cost":{"input":0.144,"output":0.431}},"qwen3-vl-235b-a22b":{"id":"qwen3-vl-235b-a22b","name":"Qwen3-VL 235B-A22B","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":32768},"modalities":{"input":["text","image"]},"cost":{"input":0.286705,"output":1.14682,"reasoning":2.867051}},"qwen3.5-plus":{"id":"qwen3.5-plus","name":"Qwen3.5 Plus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens","max":81920}],"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"]},"cost":{"input":0.573,"output":3.44,"reasoning":3.44}},"qwen-math-plus":{"id":"qwen-math-plus","name":"Qwen Math Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":4096,"output":3072},"modalities":{"input":["text"]},"cost":{"input":0.574,"output":1.721}},"qwen-doc-turbo":{"id":"qwen-doc-turbo","name":"Qwen Doc Turbo","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":131072,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.087,"output":0.144}},"qwen-mt-plus":{"id":"qwen-mt-plus","name":"Qwen-MT Plus","tool_call":false,"temperature":true,"reasoning":false,"limit":{"context":16384,"output":8192},"modalities":{"input":["text"]},"cost":{"input":0.259,"output":0.775}},"qvq-max":{"id":"qvq-max","name":"QVQ Max","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":131072,"output":8192},"modalities":{"input":["text","image"]},"cost":{"input":1.147,"output":4.588}},"MiniMax/MiniMax-M2.7":{"id":"MiniMax/MiniMax-M2.7","name":"MiniMax-M2.7","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.3,"output":1.2,"cache_read":0.06,"cache_write":0.375}},"siliconflow/deepseek-r1-0528":{"id":"siliconflow/deepseek-r1-0528","name":"siliconflow/deepseek-r1-0528","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[],"limit":{"context":163840,"output":32768},"modalities":{"input":["text"]},"cost":{"input":0.5,"output":2.18}},"siliconflow/deepseek-v3.2":{"id":"siliconflow/deepseek-v3.2","name":"siliconflow/deepseek-v3.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":163840,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.27,"output":0.42}},"siliconflow/deepseek-v3.1-terminus":{"id":"siliconflow/deepseek-v3.1-terminus","name":"siliconflow/deepseek-v3.1-terminus","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":163840,"output":65536},"modalities":{"input":["text"]},"cost":{"input":0.27,"output":1}},"siliconflow/deepseek-v3-0324":{"id":"siliconflow/deepseek-v3-0324","name":"siliconflow/deepseek-v3-0324","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":163840,"output":163840},"modalities":{"input":["text"]},"cost":{"input":0.25,"output":1}},"kimi/kimi-k2.5":{"id":"kimi/kimi-k2.5","name":"kimi/kimi-k2.5","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"]},"cost":{"input":0.6,"output":3,"cache_read":0.1}},"qwen3-coder-plus":{"id":"qwen3-coder-plus","name":"Qwen3 Coder Plus","tool_call":true,"temperature":true,"reasoning":false,"limit":{"context":1048576,"output":65536},"modalities":{"input":["text"]},"cost":{"input":1,"output":5}}}},"zhipuai":{"name":"Zhipu AI","type":"OPENAI","baseUrl":"https://open.bigmodel.cn/api/paas/v4","models":{"glm-5.2":{"id":"glm-5.2","name":"GLM-5.2","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":4.4,"cache_read":0.26,"cache_write":0}},"glm-5.3-flash":{"id":"glm-5.3-flash","name":"GLM-5.3-Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":0.075,"output":0.25,"cache_read":0.015,"cache_write":0}},"glm-5":{"id":"glm-5","name":"GLM-5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1,"output":3.2,"cache_read":0.2,"cache_write":0}},"glm-5.1":{"id":"glm-5.1","name":"GLM-5.1","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":200000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":4.4,"cache_read":0.26,"cache_write":0}},"glm-5.3":{"id":"glm-5.3","name":"GLM-5.3","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":1.4,"output":4.4,"cache_read":0.26,"cache_write":0}},"glm-5v-turbo":{"id":"glm-5v-turbo","name":"GLM-5V-Turbo","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":200000,"output":131072},"modalities":{"input":["text","image","video","pdf"]},"cost":{"input":5,"output":22,"cache_read":1.2,"cache_write":0}},"glm-4.7-flash":{"id":"glm-4.7-flash","name":"GLM-4.7-Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":200000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0,"output":0,"cache_read":0,"cache_write":0}},"glm-4.7-flashx":{"id":"glm-4.7-flashx","name":"GLM-4.7-FlashX","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":200000,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.07,"output":0.4,"cache_read":0.01,"cache_write":0}},"glm-4.5v":{"id":"glm-4.5v","name":"GLM-4.5V","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":64000,"output":16384},"modalities":{"input":["text","image","video"]},"cost":{"input":0.6,"output":1.8}},"glm-4.5":{"id":"glm-4.5","name":"GLM-4.5","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":131072,"output":98304},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.2,"cache_read":0.11,"cache_write":0}},"glm-4.5-flash":{"id":"glm-4.5-flash","name":"GLM-4.5-Flash","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":131072,"output":98304},"modalities":{"input":["text"]},"cost":{"input":0,"output":0,"cache_read":0,"cache_write":0}},"glm-4.6v":{"id":"glm-4.6v","name":"GLM-4.6V","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":128000,"output":32768},"modalities":{"input":["text","image","video"]},"cost":{"input":0.3,"output":0.9}},"glm-4.6":{"id":"glm-4.6","name":"GLM-4.6","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.2,"cache_read":0.11,"cache_write":0}},"glm-4.5-air":{"id":"glm-4.5-air","name":"GLM-4.5-Air","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":131072,"output":98304},"modalities":{"input":["text"]},"cost":{"input":0.2,"output":1.1,"cache_read":0.03,"cache_write":0}},"glm-4.7":{"id":"glm-4.7","name":"GLM-4.7","tool_call":true,"temperature":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"limit":{"context":204800,"output":131072},"modalities":{"input":["text"]},"cost":{"input":0.6,"output":2.2,"cache_read":0.11,"cache_write":0}}}}} \ No newline at end of file diff --git a/app/src/main/assets/migrations/53_add_target_mode_fields.sql b/app/src/main/assets/migrations/53_add_target_mode_fields.sql new file mode 100644 index 00000000..7a298517 --- /dev/null +++ b/app/src/main/assets/migrations/53_add_target_mode_fields.sql @@ -0,0 +1,8 @@ +-- 53: 新增 TARGET 目标驱动模式的会话级字段。 +-- goalStatement:目标声明文本;goalTerminationReason:终止原因(ACHIEVED/FAILED/STEP_LIMIT/INTERRUPTED)。 +-- goalStepCount / goalFailCount:TARGET 执行期间的步数与连续失败计数。现有会话默认 NULL / 0,向后兼容。 + +ALTER TABLE chat_sessions ADD COLUMN goalStatement TEXT; +ALTER TABLE chat_sessions ADD COLUMN goalTerminationReason TEXT; +ALTER TABLE chat_sessions ADD COLUMN goalStepCount INTEGER NOT NULL DEFAULT 0; +ALTER TABLE chat_sessions ADD COLUMN goalFailCount INTEGER NOT NULL DEFAULT 0; diff --git a/app/src/main/assets/prompts/70-skills-and-mcp.md b/app/src/main/assets/prompts/70-skills-and-mcp.md index f8f935ea..d741583d 100644 --- a/app/src/main/assets/prompts/70-skills-and-mcp.md +++ b/app/src/main/assets/prompts/70-skills-and-mcp.md @@ -18,6 +18,7 @@ 2. 调用 `loadSkill`,传入与清单一致的技能名,拿到 `SKILL.md` 完整正文。 3. 严格按正文行动。正文常会要求运行同目录下的脚本,用 `Bash` 执行(如 `python ~/.aicode/skills//run.py`)。 4. 若脚本所需解释器/依赖不存在,按安全规则先向用户说明缺什么、准备如何安装、装到哪里,得到确认后再处理。 +- **宽泛请求先查技能**:用户提出较为宽泛、普遍的请求(如界面风格、应用类型、常见功能、架构选型)时,主动对照「可用技能」清单——有对口技能(如 `github-reference`:开源项目参考)就主动 `loadSkill` 加载并按其行事,优先参考现成方案再动手,而不是仅凭默认流程硬做。 - 注意:只能加载和使用「可用技能」清单里实际存在的技能,不要凭记忆臆造技能名。某个技能本轮已加载过,就直接依其正文行事,不必重复 `loadSkill`。 ## 安装技能(用户明确要求时) diff --git a/app/src/main/assets/prompts/82-target-mode.md b/app/src/main/assets/prompts/82-target-mode.md new file mode 100644 index 00000000..35907326 --- /dev/null +++ b/app/src/main/assets/prompts/82-target-mode.md @@ -0,0 +1,33 @@ + +## 目标驱动模式(TARGET MODE) + +你当前处于 TARGET(目标驱动)模式。此模式由用户或你主动进入,含义是用户已预先授权你在此会话内自主执行工具调用以达成设定的目标,**无需就单次工具调用请求权限弹窗**。你已不再处于受约束的只读状态,可以正常执行文件修改、命令运行与网络请求。 + +## 目标声明 + +当前会话的目标声明为: +{goalStatement} + +你必须围绕该目标自主规划并执行,直至目标达成或明确无法继续。 + +## 行为规则 + +- 所有工具调用(写文件、执行命令、终端操作、网络请求等)将自动放行,不会弹出权限确认窗。请像平时一样正常调用工具推进目标,不要因「无需授权」就改变工作方式或省略自验步骤。 +- **唯一仍被拦截的是灾难性删除**:`rm` 删除根目录、系统关键目录(`/etc`、`/usr`、`/bin` 等)、工作区根目录 `~/workspace` 整体、用户主目录整体等高危操作会被安全防护拒绝。这是防误删的底线,不属于「权限」范畴。如需清理大量文件,请精确指定目标路径,不要用通配符删除整个目录树。 +- 仍需遵守通用安全规则:不引入命令注入、XSS、SQL 注入等漏洞;不执行破坏性且难以撤销的操作(`git push --force`、`rm -rf` 未核实路径、覆盖未提交改动等)前先确认。 + +## 终止条件 + +TARGET 模式在以下任一情况终止并自动切回 BUILD: + +- **目标达成**:当目标完成时,调用 `completeGoal(summary="...")` 声明达成并简要总结完成的工作与最终状态。用户确认后即退出 TARGET 回到 BUILD。 +- **连续失败**:若连续多次工具调用失败达阈值,系统会自动终止并切回 BUILD。遇到失败应分析原因、调整策略,不要盲目重试。 +- **步数超限**:单次目标执行的工具调用次数有上限,超出即终止。请在每一步推进中保持高效,避免无意义的重复调用。 +- **用户中断**:用户可随时手动切换模式中断目标执行,已产生的检查点会保留供回滚。 + +## 谨慎使用 + +- TARGET 模式权限极大,适合用户已明确知晓风险、希望 AI 全自主完成多步骤复杂任务的场景。 +- 即使全放行,仍应遵循「先看再说」「做完先自验」「如实汇报」的工作方式。放行的是执行权限,不是省略验证的理由。 +- 如发现任务实际涉及高风险操作(删除、强推、改远端、发消息等),仍应先向用户说明再执行——TARGET 放行的是常规工具调用,不代替对不可逆操作的人工确认。 +- 每一步文件改动都会被检查点记录,目标执行后可整体回滚。 diff --git a/app/src/main/assets/prompts/agent/action-suggestions.md b/app/src/main/assets/prompts/agent/action-suggestions.md new file mode 100644 index 00000000..4d5061a4 --- /dev/null +++ b/app/src/main/assets/prompts/agent/action-suggestions.md @@ -0,0 +1,19 @@ + +You generate follow-up action suggestions. You output ONLY a JSON array of 3 strings. Nothing else. + + +Based on the conversation context (recent messages and the latest assistant response), suggest 3 short, actionable next steps the user would most likely want to take. + +Your output must be: +- A JSON array of exactly 3 strings, e.g. ["给登录加上错误提示","补一个单元测试","提交代码并推送"] +- Each item: one short imperative sentence, ≤30 characters +- No explanations, no markdown code fences + + + +- you MUST use the same language as the conversation context +- Suggestions must be concrete and directly actionable (e.g. 提交代码 / 加错误处理 / 写测试 / 部署验证), not vague topics (e.g. "继续优化") +- Base suggestions on what was just done: natural next steps, obvious gaps, verification or delivery actions +- Do not repeat what was already completed; suggest what comes NEXT +- Do not suggest destructive or risky actions (force push, delete data, etc.) + diff --git a/app/src/main/assets/prompts/agent/goal-optimizer.md b/app/src/main/assets/prompts/agent/goal-optimizer.md new file mode 100644 index 00000000..c15a6e52 --- /dev/null +++ b/app/src/main/assets/prompts/agent/goal-optimizer.md @@ -0,0 +1,21 @@ + +你是目标声明优化器。你输出 ONLY 优化后的目标声明文本。Nothing else. + + +把用户的原始输入改写为一条清晰、可验证、面向结果的目标声明,供目标驱动(TARGET)模式的 AI 自主执行。 + +Follow all rules in +Your output must be: +- A single paragraph, no bullet lists, no line breaks +- ≤300 characters +- No explanations, no prefixes like "目标:" or "Goal:" + + + +- you MUST use the same language as the user message you are optimizing +- 保留用户原意与范围,禁止自行扩大或缩小任务范围,禁止添加用户未提出的要求 +- 把口语化、含糊的表述改写为明确、可判定完成与否的陈述(做什么、产出什么、达成的判据) +- 补全显而易见的上下文指代(如"它""这个"在对话里指代的内容),但不要虚构事实 +- 去掉与目标无关的寒暄、语气词和重复内容 +- 若用户输入本身已经足够清晰,仅做最小润色,原样保留关键信息 + diff --git a/app/src/main/assets/prompts/agent/request-optimizer.md b/app/src/main/assets/prompts/agent/request-optimizer.md new file mode 100644 index 00000000..c384c773 --- /dev/null +++ b/app/src/main/assets/prompts/agent/request-optimizer.md @@ -0,0 +1,21 @@ + +你是需求表达优化器。你输出 ONLY 优化后的需求文本。Nothing else. + + +结合对话上下文,把用户当前输入改写为一条清晰、完整、可直接执行的需求描述,供 AI 理解并执行。 + +Follow all rules in +Your output must be: +- A single paragraph, no bullet lists, no line breaks +- ≤300 characters +- No explanations, no prefixes like "优化后:" or "Optimized:" + + + +- you MUST use the same language as the user's input you are optimizing +- 结合上下文补全显而易见的指代(如"它""这个""上面说的"在对话里指代的内容),但不要虚构事实 +- 保留用户原意与范围,禁止自行扩大或缩小需求范围,禁止添加用户未提出的要求 +- 把口语化、含糊的表述改写为明确、可执行的陈述(做什么、改哪里、达成的判据) +- 去掉寒暄、语气词和重复内容;若输入已足够清晰,仅做最小润色 +- 上下文不足以补全指代时,保留原表述,只做语言层面的润色 + diff --git a/app/src/main/assets/skills/github-reference/SKILL.md b/app/src/main/assets/skills/github-reference/SKILL.md new file mode 100644 index 00000000..a00273ac --- /dev/null +++ b/app/src/main/assets/skills/github-reference/SKILL.md @@ -0,0 +1,39 @@ +--- +name: "github-reference" +description: "用户提出宽泛、普遍的请求时主动使用:界面风格(苹果风 UI、iOS 风格、Material You、玻璃拟态)、应用类型(做一个笔记/电商/聊天/待办 App)、常见功能(下拉刷新、暗色模式、多主题、登录注册)、架构选型(MVVM、模块化)等开放式需求。先用 websearch 搜 GitHub 上有没有可参考或可直接使用的开源项目,对比评估后借鉴思路或复用,再动手实现。Use when the user makes a broad or generic request (e.g. Apple-style UI, build an app, common features) - search GitHub for reference projects first." +--- +## 开源参考(GitHub Reference) + +把「从零硬做」变成「先找现成方案」:宽泛的需求大概率已有成熟开源实现,先搜索、对比、评估,再决定借鉴还是复用。 + +## 何时触发 + +用户的请求属于以下任一类,即应使用本技能: + +- **界面风格**:苹果风 UI、iOS 风格、Material You、玻璃拟态、拟物风等风格类诉求 +- **应用类型**:「做一个笔记/电商/聊天/待办/记账 App」这类整应用需求 +- **常见功能**:下拉刷新、暗色模式、多主题、登录注册、文件预览、富文本编辑等通用功能 +- **架构选型**:MVVM、MVI、模块化、插件化等模式类问题 +- 用户说「类似 xx 的」「参照 xx」「帮我看看有没有现成的」时,同样适用 + +## 工作流 + +1. **提炼检索词**:从请求中提取 2-3 组关键词(中文诉求转英文检索词命中率更高,如「苹果风 UI」→ `iOS style UI library`)。 +2. **搜索 GitHub**:用 `websearch` 检索,查询词加 `site:github.com` 或加 `github awesome` 前缀;一轮不理想就换关键词重搜。 +3. **核对候选**:用 `webfetch` 打开候选仓库主页或 README,核对技术栈、功能覆盖、README 完整度。 +4. **评估对比**(挑 2-3 个对比): + - Stars 数与活跃度(最近提交时间、issue 响应) + - License 是否允许商用与闭源(MIT / Apache-2.0 宽松;GPL/LGPL 注意传染性,需提示用户) + - 与当前项目技术栈是否匹配(语言、框架、minSdk 等约束) + - 是否还在维护(弃坑仓库谨慎推荐) +5. **给结论并动手**: + - **可直接复用**:给出依赖坐标或仓库地址,说明引入方式,经用户确认后引入。 + - **参考借鉴**:给出该仓库的实现思路、关键文件/设计模式,结合当前项目现状适配实现。 + - 结论里必须附上参考的仓库 URL 与许可证,让用户可追溯。 + +## 注意 + +- **MCP 优先**:若已配置 GitHub 相关的 MCP server(工具名形如 `mcp____`),优先用其搜索/仓库工具;未配置时用 `websearch`/`webfetch`。 +- **不盲目照抄**:开源代码要结合项目现有结构适配,直接整段复制前确认许可证与代码质量;引入依赖前确认与项目约束(如 minSdk、targetSdk)兼容。 +- **引用来源**:无论借鉴还是复用,结论中都要写明仓库地址,方便用户核查。 +- **搜索失败兜底**:无网络或搜索不到合适项目时,告知用户搜索情况,再按默认流程实现,不要空转重试。 diff --git a/app/src/main/assets/skills/setup-opencode/SKILL.md b/app/src/main/assets/skills/setup-opencode/SKILL.md new file mode 100644 index 00000000..5a569be3 --- /dev/null +++ b/app/src/main/assets/skills/setup-opencode/SKILL.md @@ -0,0 +1,110 @@ +--- +name: "setup-opencode" +description: "用户要求安装配置 opencode CLI、运行 /setup-opencode、或提到「检测安装 OpenCode CI」「把当前模型配置给 opencode」时使用。检测容器内 opencode 是否可用,未安装则安装,然后读取 App 导出的 provider 配置文件生成 opencode 配置并验证。Use when the user asks to set up opencode CLI in Bentley (the local container), install OpenCode CI, or migrate the current app model provider into opencode config." +--- + +## OpenCode CLI 安装配置(Setup OpenCode) + +在 Bentley(本 app 的 PRoot 容器)内检测并安装 opencode CLI,然后把 App 当前使用的模型 provider 迁移为 opencode 配置。 + +## 工作流 + +### 第一步:检测 opencode 是否可用 + +```bash +opencode --version +``` + +- 命令存在且输出版本号:跳到第三步(已安装,无需重复安装)。 +- 提示 command not found:继续第二步安装。 + +### 第二步:安装 opencode + +按顺序尝试,任一成功即止: + +1. 官方安装脚本(首选): + +```bash +curl -fsSL https://opencode.ai/install | bash +``` + +安装脚本把二进制放到 `$HOME/bin` 或 `$HOME/.opencode/bin`,装完后确认 `ls $HOME/bin/opencode $HOME/.opencode/bin/opencode 2>/dev/null` 找到实际路径;若不在 PATH 里,后续命令用绝对路径调用,并提示用户把它加进 `~/.bashrc` 的 PATH。 + +2. 官方脚本失败(网络不通等)再试 npm: + +```bash +npm i -g opencode-ai@latest +``` + +3. 两者都失败:报告失败原因(网络代理、磁盘空间等),停止并给出排查建议,不强行继续。 + +安装完成后再次运行 `opencode --version` 确认可用。 + +### 第三步:读取 App 导出的 provider 配置 + +App 已把当前生效的模型 provider 导出到 `~/.aicode/opencode-provider.json`(执行本技能前刚生成)。用 `readFile` 读取,字段: + +```json +{ + "providerName": "提供商显示名", + "providerType": "OPENAI | ANTHROPIC | GEMINI", + "baseUrl": "接口地址", + "apiKey": "密钥", + "model": "模型 id" +} +``` + +按 providerType 选择 npm 适配包: + +- `OPENAI` → `@ai-sdk/openai-compatible`(任意兼容接口通用,baseUrl 通常是 `https://xxx/v1`) +- `ANTHROPIC` → `@ai-sdk/anthropic` +- `GEMINI` → `@ai-sdk/google` + +文件不存在或字段缺失时:提示用户先在 App 设置里配置好 provider 并执行 `/setup-opencode`,停止。 + +### 第四步:生成 opencode 配置 + +先读现有的 `~/.config/opencode/opencode.json`(存在则解析保留其它字段,仅更新 provider 部分;不存在或解析失败则新建)。 + +写入/合并配置,示例(占位值用第三步读到的实际值替换,provider id 用 `aicode`): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "aicode": { + "npm": "@ai-sdk/openai-compatible", + "name": "(providerName)", + "options": { + "baseURL": "(baseUrl)", + "apiKey": "(apiKey)" + }, + "models": { + "(model)": { "name": "(model)" } + } + } + }, + "model": "aicode/(model)" +} +``` + +注意: +- 保持 JSON 合法:无注释、无尾逗号。 +- 若原配置已有 `provider.aicode`,整体替换该子对象;其它 provider 与顶层字段原样保留。 +- 自定义请求头需求不存在时可忽略 headers。 +- **不要把 apiKey 的值打印、复述或写进对话**,只写进配置文件;总结时用「已配置」代替密钥本身。 + +### 第五步:验证 + +```bash +opencode run "回复 OK" 2>&1 | tail -20 +``` + +- 输出包含模型回复:报告安装配置成功,附简短总结(安装方式、provider 名、模型、配置文件路径)。 +- 报错:读错误信息定位常见问题——baseUrl 缺 `/v1`、key 无效、模型名不存在——修正配置后重试一次;仍失败则报告错误原文与已尝试的修复,停止。 + +## 边界 + +- 全程在容器内执行,不涉及宿主 Android 系统。 +- opencode 与本 app 是独立工具:此配置只让 opencode CLI 能用同一个 provider,不影响 app 自身对话。 +- 不要卸载或升级容器内已有 node/npm 等基础件;安装失败时优先换安装渠道而非改系统。 diff --git a/app/src/main/java/com/aicode/AIEditorApp.kt b/app/src/main/java/com/aicode/AIEditorApp.kt index c0351b64..cc0ee318 100644 --- a/app/src/main/java/com/aicode/AIEditorApp.kt +++ b/app/src/main/java/com/aicode/AIEditorApp.kt @@ -14,6 +14,7 @@ import com.aicode.core.util.FileLogger import net.schmizz.sshj.common.SecurityUtils import com.aicode.feature.agent.domain.container.ContainerInstaller import com.aicode.feature.agent.domain.mcp.McpManager +import com.aicode.feature.agent.domain.notification.AgentInteractionNotificationManager import com.aicode.feature.settings.data.repository.KeepaliveSettingsRepository import com.aicode.feature.settings.data.repository.LanguageSettingsRepository import com.aicode.feature.settings.data.repository.LogSettingsRepository @@ -92,6 +93,10 @@ class AIEditorApp : Application(), Configuration.Provider { @Inject lateinit var mcpManager: McpManager + /** 交互通知管理:启动即清理上次进程残留的权限/计划快捷操作通知(按钮已无 resolver 可路由)。 */ + @Inject + lateinit var interactionNotificationManager: AgentInteractionNotificationManager + /** MCP 配置仓库:启动即监听 mcp.json 外部直接编辑,改动数秒内刷新列表并触发重连。 */ @Inject lateinit var mcpConfigRepository: com.aicode.feature.agent.domain.mcp.McpConfigRepository @@ -160,6 +165,9 @@ class AIEditorApp : Application(), Configuration.Provider { registerBouncyCastle() } createNotificationChannels() + // 上次进程被杀后可能残留带快捷按钮的交互通知,点击已无 resolver 可路由:启动即清理。 + interactionNotificationManager.cancelPermission() + interactionNotificationManager.cancelPlan() // 主线程启动凭据请求监听(FileObserver 必须主线程创建与 startWatching), // 监听容器内 credential helper 写来的 cred-req-* → 全局弹窗回填 → 回喂 git 续跑。 credentialRequestBridge.start() @@ -180,6 +188,10 @@ class AIEditorApp : Application(), Configuration.Provider { appScope.launch { ContainerInstaller.extractAgents(this@AIEditorApp) } + // 启动即释放内置技能(github-reference 开源参考)到 ~/.aicode/skills/;已存在不覆盖,同子代理惯例。 + appScope.launch { + ContainerInstaller.extractSkills(this@AIEditorApp) + } // 启动即把旧 Room git 凭据一次性迁移到 git-credentials 文件(真源已迁到文件,删表由迁移器完成)。 appScope.launch { legacyCredentialMigrator.migrateIfNeeded() diff --git a/app/src/main/java/com/aicode/MainActivity.kt b/app/src/main/java/com/aicode/MainActivity.kt index 326a205b..4da07f92 100644 --- a/app/src/main/java/com/aicode/MainActivity.kt +++ b/app/src/main/java/com/aicode/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DrawerValue import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet @@ -42,7 +43,9 @@ import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale @@ -65,8 +68,15 @@ import com.aicode.core.ui.PAGE_MOTION_MS import com.aicode.core.ui.VerticalSplitHandle import com.aicode.core.ui.drawerWidth import com.aicode.core.ui.isExpandedWidth +import com.aicode.core.ui.glass.GlassPanelArea +import com.aicode.core.ui.glass.LocalGlassSettings +import com.aicode.core.ui.glass.glassPanel +import com.aicode.core.ui.glass.isEnabled import com.aicode.core.ui.pageEnter import com.aicode.core.ui.pageExit +import com.aicode.core.ui.glass.GlassSettings +import com.aicode.core.ui.glass.LocalBackdrop +import com.aicode.core.ui.glass.LocalGlassSettings import com.aicode.feature.agent.presentation.AIAgentViewModel import com.aicode.feature.agent.presentation.component.AIChatPanel import com.aicode.feature.agent.presentation.component.ChatDrawerContent @@ -96,6 +106,8 @@ import com.aicode.feature.settings.presentation.component.decodeBackgroundBitmap import com.aicode.feature.settings.presentation.component.openUrl import com.aicode.feature.settings.presentation.component.settingsPageBackground import com.aicode.feature.terminal.domain.TerminalKeepaliveService +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.aicode.feature.terminal.presentation.TerminalViewModel import com.aicode.feature.terminal.presentation.component.TerminalScreen import com.aicode.feature.workspace.presentation.WorkspaceViewModel @@ -227,37 +239,56 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - // 全局自定义背景图:叠加在所有页面内容之上(水印效果),透明度可调。 - // 不拦截触摸事件;弹窗(Dialog 独立窗口)不受层级影响。 + // 全局自定义背景图:绘制在页面内容之下,透明度与磨砂强度可调。 val bgPath by backgroundSettings.imagePathFlow.collectAsStateWithLifecycle(initialValue = null) val bgAlpha by backgroundSettings.alphaFlow.collectAsStateWithLifecycle(initialValue = BackgroundSettingsRepository.DEFAULT_ALPHA) - Box(modifier = Modifier.fillMaxSize()) { - AppNavigation(onboardingRepository = onboardingRepository) - // 全局凭据弹窗:覆盖所有页面,命令行 git 缺凭据在任意页面都能弹。 - com.aicode.feature.credentials.presentation.component.GlobalCredentialDialogHost( - bridge = credentialRequestBridge - ) - if (bgPath != null && bgAlpha > 0f) { - val screen = LocalView.current - val bitmap by produceState(initialValue = null, bgPath) { - value = kotlinx.coroutines.withContext(Dispatchers.IO) { - decodeBackgroundBitmap( - bgPath!!, - screen.width.coerceAtLeast(1), - screen.height.coerceAtLeast(1) + val frostIntensity by backgroundSettings.frostIntensityFlow.collectAsStateWithLifecycle( + initialValue = BackgroundSettingsRepository.DEFAULT_FROST_INTENSITY + ) + // 玻璃材质:壁纸层标记为 backdrop 源(垫窗口底色防透明像素),配置经 CompositionLocal 下发。 + val glassSettings by backgroundSettings.glassStateFlow.collectAsStateWithLifecycle( + initialValue = GlassSettings.DISABLED + ) + val glassBaseColor = MaterialTheme.colorScheme.background + val glassBaseColorState = androidx.compose.runtime.rememberUpdatedState(glassBaseColor) + val glassBackdrop = rememberLayerBackdrop { + drawRect(glassBaseColorState.value) + drawContent() + } + androidx.compose.runtime.CompositionLocalProvider( + LocalGlassSettings provides glassSettings, + LocalBackdrop provides glassBackdrop + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (bgPath != null && bgAlpha > 0f) { + val screen = LocalView.current + val bitmap by produceState(initialValue = null, bgPath) { + value = kotlinx.coroutines.withContext(Dispatchers.IO) { + decodeBackgroundBitmap( + bgPath!!, + screen.width.coerceAtLeast(1), + screen.height.coerceAtLeast(1) + ) + } + } + bitmap?.let { + Image( + bitmap = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur((frostIntensity * 32f).dp) + .alpha(bgAlpha) + .layerBackdrop(glassBackdrop) ) } } - bitmap?.let { - Image( - bitmap = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .alpha(bgAlpha) - ) - } + AppNavigation(onboardingRepository = onboardingRepository) + // 全局凭据弹窗:覆盖所有页面,命令行 git 缺凭据在任意页面都能弹。 + com.aicode.feature.credentials.presentation.component.GlobalCredentialDialogHost( + bridge = credentialRequestBridge + ) } } } @@ -325,6 +356,12 @@ fun AppNavigation( val settingsViewModel: SettingsViewModel = hiltViewModel() val workspaceViewModel: WorkspaceViewModel = hiltViewModel() + // 玻璃材质:侧栏/输入框/内容面板按区域开关短路,glassPanel 内部再兜底总开关与 API33 门槛。 + val glass = LocalGlassSettings.current + val sidebarGlassOn = glass.enabled && + glass.isEnabled(GlassPanelArea.SIDEBAR) && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + // ── 首次启动引导 ── val onboardingStateHolder = remember { OnboardingStateHolder() } val onboardingUiState by onboardingStateHolder.state.collectAsStateWithLifecycle() @@ -367,6 +404,7 @@ fun AppNavigation( val currentSessionId by agentViewModel.currentSessionId.collectAsStateWithLifecycle() val agentStates by agentViewModel.agentStates.collectAsStateWithLifecycle() val awaitingPermissionSessionIds by agentViewModel.awaitingPermissionSessionIds.collectAsStateWithLifecycle() + val sessionTitleFilter by agentViewModel.sessionTitleFilter.collectAsStateWithLifecycle() val subSessionsByParent by agentViewModel.subSessionsByParent.collectAsStateWithLifecycle() val expandedPaths by agentViewModel.expandedPaths.collectAsStateWithLifecycle() val browseState by agentViewModel.browseState.collectAsStateWithLifecycle() @@ -462,6 +500,8 @@ fun AppNavigation( currentSessionId = currentSessionId, agentStates = agentStates, awaitingPermissionSessionIds = awaitingPermissionSessionIds, + sessionTitleFilter = sessionTitleFilter, + onSessionTitleFilterChange = { agentViewModel.setSessionTitleFilter(it) }, subSessionsByParent = subSessionsByParent, browseState = browseState, expandedPaths = expandedPaths, @@ -699,6 +739,11 @@ fun AppNavigation( modifier = Modifier .width(drawerWidth()) .fillMaxHeight() + .then( + if (sidebarGlassOn) Modifier.glassPanel( + RoundedCornerShape(0.dp), GlassPanelArea.SIDEBAR + ) else Modifier + ) ) { sidebarStateHolder.SaveableStateProvider("chat-sidebar") { drawerBody() @@ -719,9 +764,15 @@ fun AppNavigation( drawerContent = { ModalDrawerSheet( drawerShape = RectangleShape, - drawerContainerColor = settingsPageBackground(), + drawerContainerColor = if (sidebarGlassOn) Color.Transparent else settingsPageBackground(), drawerTonalElevation = 0.dp, - modifier = Modifier.width(drawerWidth()) + modifier = Modifier + .width(drawerWidth()) + .then( + if (sidebarGlassOn) Modifier.glassPanel( + RoundedCornerShape(0.dp), GlassPanelArea.SIDEBAR + ) else Modifier + ) ) { drawerBody() } diff --git a/app/src/main/java/com/aicode/core/ui/glass/GlassMode.kt b/app/src/main/java/com/aicode/core/ui/glass/GlassMode.kt new file mode 100644 index 00000000..9b31ebb0 --- /dev/null +++ b/app/src/main/java/com/aicode/core/ui/glass/GlassMode.kt @@ -0,0 +1,58 @@ +package com.aicode.core.ui.glass + +import androidx.compose.runtime.staticCompositionLocalOf +import com.kyant.backdrop.Backdrop + +/** 玻璃材质三档:磨砂 / 水玻璃(AGSL 水波折射)/ 液体玻璃(透镜折射 + 色散)。 */ +enum class GlassMode { + FROSTED, + WATER, + LIQUID, +} + +/** + * 玻璃材质全局配置。radiusDp 由 frost_intensity × 32 换算,三档共用。 + */ +data class GlassSettings( + val enabled: Boolean, + val mode: GlassMode, + val sidebarEnabled: Boolean, + val inputEnabled: Boolean, + val contentEnabled: Boolean, + val radiusDp: Float, + val waterWaveAnimated: Boolean, +) { + companion object { + const val MAX_RADIUS_DP = 32f + + /** 总开关关闭时的默认态:区域开关保持用户选择记忆。 */ + val DISABLED = GlassSettings( + enabled = false, + mode = GlassMode.FROSTED, + sidebarEnabled = true, + inputEnabled = true, + contentEnabled = true, + radiusDp = 16f, + waterWaveAnimated = false, + ) + } +} + +/** 玻璃面板三大应用区域。 */ +enum class GlassPanelArea { SIDEBAR, INPUT, CONTENT } + +/** 区域开关读取。 */ +fun GlassSettings.isEnabled(area: GlassPanelArea): Boolean = when (area) { + GlassPanelArea.SIDEBAR -> sidebarEnabled + GlassPanelArea.INPUT -> inputEnabled + GlassPanelArea.CONTENT -> contentEnabled +} + +/** 全局玻璃配置注入点;未提供时视为关闭态,玻璃面板全部透传。 */ +val LocalGlassSettings = staticCompositionLocalOf { GlassSettings.DISABLED } + +/** + * Backdrop 源注入点:MainActivity 用 [com.kyant.backdrop.backdrops.rememberLayerBackdrop] + * 标记壁纸层后在此提供;null(未接线)时玻璃面板透传。 + */ +val LocalBackdrop = staticCompositionLocalOf { null } diff --git a/app/src/main/java/com/aicode/core/ui/glass/GlassPanel.kt b/app/src/main/java/com/aicode/core/ui/glass/GlassPanel.kt new file mode 100644 index 00000000..fe201f21 --- /dev/null +++ b/app/src/main/java/com/aicode/core/ui/glass/GlassPanel.kt @@ -0,0 +1,121 @@ +package com.aicode.core.ui.glass + +import android.graphics.RenderEffect +import android.graphics.RuntimeShader +import android.os.Build +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.effect +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import kotlin.math.min + +/** 染色表面基准 alpha,保证玻璃面板上文字可读(需求下限 0.3)。 */ +private const val TINT_ALPHA = 0.4f + +/** 水波折射的坐标偏移幅度。 */ +private val WAVE_AMPLITUDE = 6.dp + +/** + * 玻璃面板 Modifier:按 [LocalGlassSettings] 档位渲染磨砂 / 水玻璃 / 液体玻璃材质, + * backdrop 源从 [LocalBackdrop] 读取(MainActivity 标记壁纸层)。 + * + * 短路规则:backdrop 未接线、总开关关闭、[area] 区域开关关闭、API < 33 时原样透传。 + * 液体玻璃的效果链顺序遵循库约束 color filter => blur => lens; + * 水玻璃在 blur 后叠加 [WATER_WAVE_SHADER] 折射,静态水纹为默认,动画仅在水波开关开启时驱动。 + */ +@Composable +fun Modifier.glassPanel( + shape: CornerBasedShape, + area: GlassPanelArea, +): Modifier { + val backdrop = LocalBackdrop.current ?: return this + val settings = LocalGlassSettings.current + if (!settings.enabled || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return this + if (!settings.isEnabled(area)) return this + + val density = LocalDensity.current + val radiusPx = with(density) { + settings.radiusDp.coerceIn(0f, GlassSettings.MAX_RADIUS_DP).dp.toPx() + } + val amplitudePx = with(density) { WAVE_AMPLITUDE.toPx() } + val surfaceColor = MaterialTheme.colorScheme.surface + val isLight = surfaceColor.luminance() > 0.5f + + val timeState = remember { mutableFloatStateOf(WATER_WAVE_STATIC_TIME) } + val waveShader = remember { RuntimeShader(WATER_WAVE_SHADER) } + val animateWave = settings.mode == GlassMode.WATER && settings.waterWaveAnimated + LaunchedEffect(animateWave) { + if (animateWave) { + while (true) { + withFrameNanos { frameTimeNanos -> + timeState.floatValue = (frameTimeNanos % 10_000_000_000L) / 1_000_000_000f + } + } + } else { + timeState.floatValue = WATER_WAVE_STATIC_TIME + } + } + + return drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + when (settings.mode) { + GlassMode.FROSTED -> blur(radiusPx, TileMode.Clamp) + + GlassMode.WATER -> { + blur(radiusPx, TileMode.Clamp) + waveShader.setFloatUniform("uTime", timeState.floatValue) + waveShader.setFloatUniform("uAmplitude", amplitudePx) + effect( + RenderEffect.createRuntimeShaderEffect( + waveShader, + WATER_WAVE_UNIFORM_CONTENT, + ) + ) + } + + GlassMode.LIQUID -> { + vibrancy() + blur(radiusPx, TileMode.Clamp) + val cornerPx = shape.topStart.toPx(size, this) + val height = min(cornerPx, radiusPx * 0.5f) + lens( + refractionHeight = height, + refractionAmount = height * 2f, + depthEffect = false, + chromaticAberration = true, + ) + } + } + }, + onDrawSurface = { + drawRect(surfaceColor.copy(alpha = TINT_ALPHA)) + if (settings.mode == GlassMode.LIQUID) { + val cornerPx = shape.topStart.toPx(size, this) + drawRoundRect( + color = Color.White.copy(alpha = if (isLight) 0.5f else 0.18f), + style = Stroke(width = 1.dp.toPx()), + cornerRadius = CornerRadius(cornerPx, cornerPx), + ) + } + }, + ) +} diff --git a/app/src/main/java/com/aicode/core/ui/glass/WaterWaveShader.kt b/app/src/main/java/com/aicode/core/ui/glass/WaterWaveShader.kt new file mode 100644 index 00000000..1626188a --- /dev/null +++ b/app/src/main/java/com/aicode/core/ui/glass/WaterWaveShader.kt @@ -0,0 +1,26 @@ +package com.aicode.core.ui.glass + +/** + * 水玻璃档的 AGSL 波浪折射 shader:对上游效果链(blur 之后)的采样坐标做 + * 双向正弦叠加偏移,产生静态水纹;[WATER_WAVE_STATIC_TIME] 相位固定时零逐帧开销。 + * uniform 契约:uContent 上游 shader、uTime 相位(秒)、uAmplitude 偏移幅度(px)。 + */ +const val WATER_WAVE_SHADER_KEY = "aicode_water_wave" + +const val WATER_WAVE_UNIFORM_CONTENT = "uContent" + +const val WATER_WAVE_STATIC_TIME = 7.3f + +const val WATER_WAVE_SHADER: String = """ + uniform shader uContent; + uniform float uTime; + uniform float uAmplitude; + + half4 main(float2 coord) { + float t = uTime; + float dx = sin(coord.y * 0.02 + t * 1.3) + 0.5 * sin(coord.y * 0.045 - t * 0.7); + float dy = cos(coord.x * 0.02 + t * 1.1) + 0.5 * cos(coord.x * 0.045 - t * 0.9); + float2 offset = float2(dx, dy) * uAmplitude; + return sample(uContent, coord + offset); + } +""" diff --git a/app/src/main/java/com/aicode/di/AgentModule.kt b/app/src/main/java/com/aicode/di/AgentModule.kt index eaf87f3b..9f3cd0b6 100644 --- a/app/src/main/java/com/aicode/di/AgentModule.kt +++ b/app/src/main/java/com/aicode/di/AgentModule.kt @@ -77,6 +77,7 @@ import com.aicode.feature.agent.domain.tool.mcp.ManageMcpTool import com.aicode.feature.agent.domain.tool.memory.MemoryTool import com.aicode.feature.agent.domain.tool.mode.PlanApprovalManager import com.aicode.feature.agent.domain.tool.mode.SwitchModeTool +import com.aicode.feature.agent.domain.tool.mode.CompleteGoalTool import com.aicode.feature.agent.domain.tool.search.WebFetchTool import com.aicode.feature.agent.domain.tool.search.WebSearchTool import com.aicode.feature.agent.domain.workflow.ContextCompactor @@ -278,6 +279,7 @@ object AgentModule { webSearchTool: WebSearchTool, webFetchTool: WebFetchTool, switchModeTool: SwitchModeTool, + completeGoalTool: CompleteGoalTool, todoTool: TodoTool, memoryTool: MemoryTool, taskTool: TaskTool @@ -299,6 +301,7 @@ object AgentModule { register("websearch", webSearchTool) register("webfetch", webFetchTool) register("switchMode", switchModeTool) + register("completeGoal", completeGoalTool) register("todo", todoTool) register("memory", memoryTool) register("task", taskTool) @@ -329,7 +332,9 @@ object AgentModule { llmCallRecordDao: LlmCallRecordDao, keyRotator: ProviderKeyRotator, agentNotificationCenter: AgentNotificationCenter, - fileAccess: FileAccessProvider + fileAccess: FileAccessProvider, + targetModeSettingsRepository: com.aicode.feature.settings.data.repository.TargetModeSettingsRepository, + chatSessionDao: com.aicode.feature.agent.data.local.dao.ChatSessionDao ): AgentWorkflow { return StatefulAgentWorkflow( toolRegistry, @@ -353,7 +358,9 @@ object AgentModule { llmCallRecordDao, keyRotator, agentNotificationCenter, - fileAccess + fileAccess, + targetModeSettingsRepository, + chatSessionDao ) } } diff --git a/app/src/main/java/com/aicode/feature/agent/data/local/dao/AgentMessageDao.kt b/app/src/main/java/com/aicode/feature/agent/data/local/dao/AgentMessageDao.kt index a250f3cc..b34df6b1 100644 --- a/app/src/main/java/com/aicode/feature/agent/data/local/dao/AgentMessageDao.kt +++ b/app/src/main/java/com/aicode/feature/agent/data/local/dao/AgentMessageDao.kt @@ -72,6 +72,19 @@ interface AgentMessageDao { @Query("SELECT * FROM agent_messages WHERE content LIKE '%' || :query || '%' ORDER BY timestamp ASC") suspend fun searchMessages(query: String): List + /** + * 会话内消息全文搜索(未压缩、已落库),按时间倒序取前 [limit] 条。 + * 只投影 4 列,避免 thinkingBlocksJson 等大字段进内存;查询词由调用方做 LIKE 转义。 + */ + @Query( + """ + SELECT id, role, content, timestamp FROM agent_messages + WHERE sessionId = :sessionId AND isCompacted = 0 AND content LIKE '%' || :query || '%' ESCAPE '\' + ORDER BY timestamp DESC LIMIT :limit + """ + ) + suspend fun searchMessagesInSession(sessionId: String, query: String, limit: Int): List + @Query("SELECT * FROM agent_messages ORDER BY timestamp ASC") suspend fun getAllOnce(): List @@ -119,3 +132,11 @@ data class SessionStorageUsage( val messageCount: Int, val bytes: Long ) + +/** 会话内消息搜索结果行([AgentMessageDao.searchMessagesInSession] 的投影)。 */ +data class AgentMessageSearchRow( + val id: String, + val role: String, + val content: String, + val timestamp: Long +) diff --git a/app/src/main/java/com/aicode/feature/agent/data/local/database/AgentDatabase.kt b/app/src/main/java/com/aicode/feature/agent/data/local/database/AgentDatabase.kt index c96b662a..21507d43 100644 --- a/app/src/main/java/com/aicode/feature/agent/data/local/database/AgentDatabase.kt +++ b/app/src/main/java/com/aicode/feature/agent/data/local/database/AgentDatabase.kt @@ -34,7 +34,7 @@ abstract class AgentDatabase : RoomDatabase() { abstract fun llmCallRecordDao(): LlmCallRecordDao companion object { - const val SCHEMA_VERSION = 52 + const val SCHEMA_VERSION = 53 /** 数据库文件名(落在 `databases/` 下,另有 Room 默认 WAL 模式产生的 `-wal`/`-shm`)。 */ const val DATABASE_NAME = "aicode_agent_db" diff --git a/app/src/main/java/com/aicode/feature/agent/data/local/entity/ChatSessionEntity.kt b/app/src/main/java/com/aicode/feature/agent/data/local/entity/ChatSessionEntity.kt index 635eed18..7d2c81ad 100644 --- a/app/src/main/java/com/aicode/feature/agent/data/local/entity/ChatSessionEntity.kt +++ b/app/src/main/java/com/aicode/feature/agent/data/local/entity/ChatSessionEntity.kt @@ -28,7 +28,15 @@ data class ChatSessionEntity( /** 子代理会话:父会话 id;null 表示普通根会话。 */ val parentId: String? = null, /** 子代理会话:派生子代理的类型(如 coder / researcher);null 表示普通根会话。 */ - val subagentType: String? = null + val subagentType: String? = null, + /** TARGET 模式的目标声明;null 表示非 TARGET 模式或未设定。 */ + val goalStatement: String? = null, + /** TARGET 模式终止原因(GoalTerminationReason.name);null 表示未终止或非 TARGET 模式。 */ + val goalTerminationReason: String? = null, + /** 当前 TARGET 执行已产生的工具调用步数。 */ + val goalStepCount: Int = 0, + /** 当前 TARGET 执行的连续工具失败计数。 */ + val goalFailCount: Int = 0 ) { fun toDomain(): ChatSession = ChatSession( id = id, @@ -45,7 +53,11 @@ data class ChatSessionEntity( lastInputTokens = lastInputTokens, isPinned = isPinned, parentId = parentId, - subagentType = subagentType + subagentType = subagentType, + goalStatement = goalStatement, + goalTerminationReason = goalTerminationReason, + goalStepCount = goalStepCount, + goalFailCount = goalFailCount ) companion object { @@ -64,7 +76,11 @@ data class ChatSessionEntity( lastInputTokens = session.lastInputTokens, isPinned = session.isPinned, parentId = session.parentId, - subagentType = session.subagentType + subagentType = session.subagentType, + goalStatement = session.goalStatement, + goalTerminationReason = session.goalTerminationReason, + goalStepCount = session.goalStepCount, + goalFailCount = session.goalFailCount ) } } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/command/McpCommandHandler.kt b/app/src/main/java/com/aicode/feature/agent/domain/command/McpCommandHandler.kt new file mode 100644 index 00000000..d29a9e8b --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/command/McpCommandHandler.kt @@ -0,0 +1,16 @@ +package com.aicode.feature.agent.domain.command + +import javax.inject.Inject + +/** + * /mcp —— 以 Markdown 表格气泡列出 MCP 服务器连接状态、工具数与错误信息。 + */ +class McpCommandHandler @Inject constructor() : SlashCommandHandler { + override val trigger = "/mcp" + override val label = "查看 MCP" + override val description = "列出 MCP 服务器连接状态与已注册的工具数" + + override fun execute(context: SlashCommandContext) { + context.showMcpOverview() + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/command/MemoryCommandHandler.kt b/app/src/main/java/com/aicode/feature/agent/domain/command/MemoryCommandHandler.kt new file mode 100644 index 00000000..67f783b1 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/command/MemoryCommandHandler.kt @@ -0,0 +1,17 @@ +package com.aicode.feature.agent.domain.command + +import javax.inject.Inject + +/** + * /memory —— 以 Markdown 表格气泡列出当前生效的项目规则(提示词分层注入内容), + * 让用户知道 AI 正在遵守哪些规则文件。 + */ +class MemoryCommandHandler @Inject constructor() : SlashCommandHandler { + override val trigger = "/memory" + override val label = "查看规则" + override val description = "列出当前会话生效的项目规则与提示词文件" + + override fun execute(context: SlashCommandContext) { + context.showMemoryOverview() + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/command/ModelCommandHandler.kt b/app/src/main/java/com/aicode/feature/agent/domain/command/ModelCommandHandler.kt new file mode 100644 index 00000000..6b31ba7a --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/command/ModelCommandHandler.kt @@ -0,0 +1,16 @@ +package com.aicode.feature.agent.domain.command + +import javax.inject.Inject + +/** + * /model —— 打开模型选择弹窗(等同点输入栏的模型按钮)。 + */ +class ModelCommandHandler @Inject constructor() : SlashCommandHandler { + override val trigger = "/model" + override val label = "切换模型" + override val description = "打开模型选择弹窗,切换当前会话使用的模型" + + override fun execute(context: SlashCommandContext) { + context.requestModelSheet() + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/command/RewindCommandHandler.kt b/app/src/main/java/com/aicode/feature/agent/domain/command/RewindCommandHandler.kt new file mode 100644 index 00000000..f9fe5291 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/command/RewindCommandHandler.kt @@ -0,0 +1,17 @@ +package com.aicode.feature.agent.domain.command + +import javax.inject.Inject + +/** + * /rewind —— 打开当前会话的回退(检查点)选择菜单, + * 把对话与代码回滚到之前某个检查点。 + */ +class RewindCommandHandler @Inject constructor() : SlashCommandHandler { + override val trigger = "/rewind" + override val label = "回退会话" + override val description = "打开检查点选择菜单,回退对话与代码" + + override fun execute(context: SlashCommandContext) { + context.requestRewindMenu() + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/command/SetupOpencodeCommandHandler.kt b/app/src/main/java/com/aicode/feature/agent/domain/command/SetupOpencodeCommandHandler.kt new file mode 100644 index 00000000..fa00b4d1 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/command/SetupOpencodeCommandHandler.kt @@ -0,0 +1,18 @@ +package com.aicode.feature.agent.domain.command + +import javax.inject.Inject + +/** + * /setup-opencode —— 让 AI 自动完成 opencode CLI 的检测安装与模型配置迁移: + * App 先把当前生效 provider 导出到容器内 ~/.aicode/opencode-provider.json, + * 再把 setup-opencode 技能正文作为请求发给 AI,由其在容器内执行安装与配置。 + */ +class SetupOpencodeCommandHandler @Inject constructor() : SlashCommandHandler { + override val trigger = "/setup-opencode" + override val label = "配置 OpenCode" + override val description = "检测/安装 opencode CLI 并把当前模型配置迁移为 opencode 配置" + + override fun execute(context: SlashCommandContext) { + context.runSetupOpencode() + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/command/SkillsCommandHandler.kt b/app/src/main/java/com/aicode/feature/agent/domain/command/SkillsCommandHandler.kt new file mode 100644 index 00000000..9fc1b7a0 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/command/SkillsCommandHandler.kt @@ -0,0 +1,17 @@ +package com.aicode.feature.agent.domain.command + +import javax.inject.Inject + +/** + * /skills —— 以 Markdown 表格气泡列出已安装技能与内置子代理, + * 含启用状态,便于用户了解可用的技能面。 + */ +class SkillsCommandHandler @Inject constructor() : SlashCommandHandler { + override val trigger = "/skills" + override val label = "查看技能" + override val description = "列出已安装的技能与内置子代理及其启用状态" + + override fun execute(context: SlashCommandContext) { + context.showSkillsOverview() + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/command/SlashCommand.kt b/app/src/main/java/com/aicode/feature/agent/domain/command/SlashCommand.kt index e47f423a..981d0a8f 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/command/SlashCommand.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/command/SlashCommand.kt @@ -34,4 +34,28 @@ interface SlashCommandHandler { interface SlashCommandContext { fun showSessionStatus() fun compactCurrentSession() + + /** 打开模型选择弹窗(等同点输入栏的模型按钮)。 */ + fun requestModelSheet() + + /** 以 Markdown 气泡列出当前会话生效的项目规则(prompts 分层注入内容)。 */ + fun showMemoryOverview() + + /** 以 Markdown 气泡列出已安装技能(名称/描述/启用状态)与内置子代理。 */ + fun showSkillsOverview() + + /** 以 Markdown 气泡列出 MCP 服务器连接状态与工具数。 */ + fun showMcpOverview() + + /** 以 Markdown 气泡列出当前注册可用的工具。 */ + fun showToolsOverview() + + /** 打开当前会话的回退(检查点)选择菜单。 */ + fun requestRewindMenu() + + /** + * 导出当前生效 provider 配置到容器内 ~/.aicode/opencode-provider.json, + * 并把 setup-opencode 技能正文作为请求发给 AI 执行(检测/安装 opencode CLI + 生成配置)。 + */ + fun runSetupOpencode() } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/command/SlashCommandModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/command/SlashCommandModule.kt index fe413881..4f50602f 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/command/SlashCommandModule.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/command/SlashCommandModule.kt @@ -23,4 +23,32 @@ abstract class SlashCommandModule { @Binds @IntoSet abstract fun bindCompressCommandHandler(handler: CompressCommandHandler): SlashCommandHandler + + @Binds + @IntoSet + abstract fun bindModelCommandHandler(handler: ModelCommandHandler): SlashCommandHandler + + @Binds + @IntoSet + abstract fun bindMemoryCommandHandler(handler: MemoryCommandHandler): SlashCommandHandler + + @Binds + @IntoSet + abstract fun bindSkillsCommandHandler(handler: SkillsCommandHandler): SlashCommandHandler + + @Binds + @IntoSet + abstract fun bindMcpCommandHandler(handler: McpCommandHandler): SlashCommandHandler + + @Binds + @IntoSet + abstract fun bindToolsCommandHandler(handler: ToolsCommandHandler): SlashCommandHandler + + @Binds + @IntoSet + abstract fun bindRewindCommandHandler(handler: RewindCommandHandler): SlashCommandHandler + + @Binds + @IntoSet + abstract fun bindSetupOpencodeCommandHandler(handler: SetupOpencodeCommandHandler): SlashCommandHandler } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/command/ToolsCommandHandler.kt b/app/src/main/java/com/aicode/feature/agent/domain/command/ToolsCommandHandler.kt new file mode 100644 index 00000000..0dde8dcf --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/command/ToolsCommandHandler.kt @@ -0,0 +1,17 @@ +package com.aicode.feature.agent.domain.command + +import javax.inject.Inject + +/** + * /tools —— 以 Markdown 表格气泡列出当前注册可用的工具清单, + * 含各工具的权限策略(ALWAYS/ASK/DENY)。 + */ +class ToolsCommandHandler @Inject constructor() : SlashCommandHandler { + override val trigger = "/tools" + override val label = "查看工具" + override val description = "列出当前注册可用的工具及权限策略" + + override fun execute(context: SlashCommandContext) { + context.showToolsOverview() + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/container/ContainerInstaller.kt b/app/src/main/java/com/aicode/feature/agent/domain/container/ContainerInstaller.kt index dd44cce3..f46035aa 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/container/ContainerInstaller.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/container/ContainerInstaller.kt @@ -110,6 +110,28 @@ class ContainerInstaller @Inject constructor( } } + /** + * 从 assets 提取内置技能(如 github-reference)到 ~/.aicode/skills//SKILL.md。 + * 逐目录处理:技能目录已存在则不覆盖(保留用户在设置页的修改与删除后的重建选择), + * 用户自建的其它技能目录完全不受影响。禁用状态由 SkillConfigRepository 持久化, + * 用户禁用内置技能后文件保留、清单中不再出现。 + */ + fun extractSkills(context: Context) { + val destDir = File(File(context.filesDir, "aicode"), "skills") + destDir.mkdirs() + runCatching { + val entries = context.assets.list("skills") ?: return@runCatching + for (entry in entries) { + val destSkillDir = File(destDir, entry) + if (!destSkillDir.isDirectory) { + extractAssetsRecursive(context, "skills/$entry", destSkillDir) + } + } + }.onFailure { + FileLogger.w(TAG, "提取内置技能失败: ${it.message}", it) + } + } + /** * 从 assets 提取内置脚本(如套餐余量 demo_balance.py)到 ~/.aicode/scripts/。 * 若文件已存在则不覆盖,以保留用户的修改。 diff --git a/app/src/main/java/com/aicode/feature/agent/domain/model/AgentMessage.kt b/app/src/main/java/com/aicode/feature/agent/domain/model/AgentMessage.kt index 2ca1ba8f..ebeb0f61 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/model/AgentMessage.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/model/AgentMessage.kt @@ -75,5 +75,11 @@ data class AgentContext( * 本会话绑定的自定义子代理定义;非空表示这是一个按定义运行的子代理会话, * 系统提示词与工具集都按其配置组装(见 [com.aicode.feature.agent.domain.prompt.SystemPromptProvider])。 */ - val agentDefinition: AgentDefinition? = null + val agentDefinition: AgentDefinition? = null, + /** TARGET 模式:当前执行的工具调用步数(跨消息从 DB 恢复)。 */ + val goalStepCount: Int = 0, + /** TARGET 模式:当前连续失败计数。 */ + val goalFailCount: Int = 0, + /** TARGET 模式:终止原因(GoalTerminationReason.name)。 */ + val goalTerminationReason: String? = null ) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/model/ChatSession.kt b/app/src/main/java/com/aicode/feature/agent/domain/model/ChatSession.kt index e35d106d..96db418e 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/model/ChatSession.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/model/ChatSession.kt @@ -6,7 +6,18 @@ package com.aicode.feature.agent.domain.model enum class AgentMode { BUILD, // 默认模式,允许所有授权操作 PLAN, // 计划模式,拦截修改类操作,只读/探索为主 - AUTO // 自动模式,放行所有权限(不弹窗),仅用户手动可切换;AI 无法通过 switchMode 进入 + AUTO, // 自动模式,放行所有权限(不弹窗),仅用户手动可切换;AI 无法通过 switchMode 进入 + TARGET // 目标驱动模式,AI 依据 goalStatement 自主执行,达成或失败即终止;免逐步弹窗授权(等同 AUTO + 灾难防护) +} + +/** + * TARGET 模式的目标终止原因。 + */ +enum class GoalTerminationReason { + ACHIEVED, // 目标达成(completeGoal 工具触发) + FAILED, // 连续失败达阈值 + STEP_LIMIT, // 工具调用步数达上限 + INTERRUPTED // 用户手动切换模式中断 } /** @@ -29,5 +40,13 @@ data class ChatSession( /** 子代理会话:父会话 id;null 表示普通根会话。 */ val parentId: String? = null, /** 子代理会话:派生子代理的类型(如 coder / researcher);null 表示普通根会话。 */ - val subagentType: String? = null + val subagentType: String? = null, + /** TARGET 模式的目标声明;null 表示非 TARGET 模式或未设定。 */ + val goalStatement: String? = null, + /** TARGET 模式终止原因(GoalTerminationReason.name);null 表示未终止或非 TARGET 模式。 */ + val goalTerminationReason: String? = null, + /** 当前 TARGET 执行已产生的工具调用步数。 */ + val goalStepCount: Int = 0, + /** 当前 TARGET 执行的连续工具失败计数。 */ + val goalFailCount: Int = 0 ) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/notification/AgentInteractionNotificationManager.kt b/app/src/main/java/com/aicode/feature/agent/domain/notification/AgentInteractionNotificationManager.kt new file mode 100644 index 00000000..d30770a3 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/notification/AgentInteractionNotificationManager.kt @@ -0,0 +1,158 @@ +package com.aicode.feature.agent.domain.notification + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.aicode.R +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.permission.PermissionChoice +import com.aicode.feature.agent.domain.tool.PendingToolPermission +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 交互请求的系统通知:App 在后台时 AI 等待工具权限/计划批准,发通知并带快捷操作按钮, + * 点击经 [NotificationActionReceiver] 路由回注册的 resolver 直接解决请求。 + * 通知 id:权限 101、计划 102;复用 `agent_complete` 通道(IMPORTANCE_HIGH)。 + */ +@Singleton +class AgentInteractionNotificationManager @Inject constructor( + @ApplicationContext private val context: Context +) { + + /** 当前待决请求的 resolver 回调,由 ViewModel 注册;App 内已解决或进程重启后为 null。 */ + private var permissionResolver: ((PermissionChoice) -> Unit)? = null + private var planApprover: (() -> Unit)? = null + + fun notifyPermission(request: PendingToolPermission, onChoice: (PermissionChoice) -> Unit) { + permissionResolver = onChoice + val notification = NotificationCompat.Builder(context, CHANNEL_AGENT_INTERACTION) + .setSmallIcon(android.R.drawable.ic_menu_info_details) + .setContentTitle(context.getString(R.string.notification_permission_title)) + .setContentText(request.title.ifBlank { request.toolName }) + .setStyle(NotificationCompat.BigTextStyle().bigText(request.title.ifBlank { request.toolName })) + .setContentIntent(openAppIntent()) + .addAction(0, context.getString(R.string.notification_permission_approve), permissionActionIntent(ACTION_PERMISSION_APPROVE, 1)) + .addAction(0, context.getString(R.string.notification_permission_deny), permissionActionIntent(ACTION_PERMISSION_DENY, 2)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + runCatching { + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID_PERMISSION, notification) + }.onFailure { FileLogger.e(TAG, "发送权限请求通知失败", it) } + } + + /** @param contentText 批准理由(AI 的切换说明),作为通知正文展示。 */ + fun notifyPlan(contentText: String, onApprove: () -> Unit) { + planApprover = onApprove + val notification = NotificationCompat.Builder(context, CHANNEL_AGENT_INTERACTION) + .setSmallIcon(android.R.drawable.ic_menu_info_details) + .setContentTitle(context.getString(R.string.notification_plan_title)) + .setContentText(contentText) + .setStyle(NotificationCompat.BigTextStyle().bigText(contentText)) + .setContentIntent(openAppIntent()) + .addAction(0, context.getString(R.string.notification_plan_approve), planActionIntent()) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + runCatching { + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID_PLAN, notification) + }.onFailure { FileLogger.e(TAG, "发送计划批准通知失败", it) } + } + + fun cancelPermission() { + permissionResolver = null + runCatching { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID_PERMISSION) + }.onFailure { FileLogger.e(TAG, "取消权限请求通知失败", it) } + } + + fun cancelPlan() { + planApprover = null + runCatching { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID_PLAN) + }.onFailure { FileLogger.e(TAG, "取消计划批准通知失败", it) } + } + + /** 清空两类 resolver 回调(App 退前台或通知随面板处理时调用)。 */ + fun clearResolvers() { + permissionResolver = null + planApprover = null + } + + /** + * 通知快捷操作路由:resolver 回调有效则直接解决请求并取消通知,返回 true; + * 回调已失效(App 内已解决/进程重启)时仅取消通知并返回 false。 + */ + fun handlePermissionAction(approve: Boolean): Boolean { + val resolver = permissionResolver + val choice = if (approve) PermissionChoice.ONCE else PermissionChoice.REJECT + return if (resolver != null) { + resolver(choice) + cancelPermission() + true + } else { + FileLogger.w(TAG, "权限通知操作被忽略:resolver 已失效") + cancelPermission() + false + } + } + + /** 计划批准通知操作路由,语义同 [handlePermissionAction]。 */ + fun handlePlanApprove(): Boolean { + val approver = planApprover + return if (approver != null) { + approver() + cancelPlan() + true + } else { + FileLogger.w(TAG, "计划通知操作被忽略:approver 已失效") + cancelPlan() + false + } + } + + /** 点击通知打开 App:launcher intent 按包名解析,不依赖具体 Activity 类引用。 */ + private fun openAppIntent(): PendingIntent { + val intent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + setPackage(context.packageName) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + } + + private fun permissionActionIntent(action: String, requestCode: Int): PendingIntent = + PendingIntent.getBroadcast( + context, + requestCode, + Intent(context, NotificationActionReceiver::class.java).apply { + this.action = action + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + private fun planActionIntent(): PendingIntent = PendingIntent.getBroadcast( + context, + 3, + Intent(context, NotificationActionReceiver::class.java).apply { + action = ACTION_PLAN_APPROVE + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + companion object { + private const val TAG = "AgentInteractionNotification" + + /** 与 AIEditorApp 创建的 `agent_complete` 通道一致,避免为交互通知单独建渠道。 */ + private const val CHANNEL_AGENT_INTERACTION = "agent_complete" + + const val NOTIFICATION_ID_PERMISSION = 101 + const val NOTIFICATION_ID_PLAN = 102 + + const val ACTION_PERMISSION_APPROVE = "com.aicode.notification.PERMISSION_APPROVE" + const val ACTION_PERMISSION_DENY = "com.aicode.notification.PERMISSION_DENY" + const val ACTION_PLAN_APPROVE = "com.aicode.notification.PLAN_APPROVE" + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/notification/NotificationActionReceiver.kt b/app/src/main/java/com/aicode/feature/agent/domain/notification/NotificationActionReceiver.kt new file mode 100644 index 00000000..29c3434e --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/notification/NotificationActionReceiver.kt @@ -0,0 +1,35 @@ +package com.aicode.feature.agent.domain.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.aicode.core.util.FileLogger +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * 通知快捷操作入口:把通知按钮点击路由回 [AgentInteractionNotificationManager], + * 由其分发到注册的 resolver(ViewModel 解决权限请求/批准计划);resolver 失效时仅取消通知。 + */ +@AndroidEntryPoint +class NotificationActionReceiver : BroadcastReceiver() { + + @Inject + lateinit var interactionNotificationManager: AgentInteractionNotificationManager + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + AgentInteractionNotificationManager.ACTION_PERMISSION_APPROVE -> + interactionNotificationManager.handlePermissionAction(approve = true) + AgentInteractionNotificationManager.ACTION_PERMISSION_DENY -> + interactionNotificationManager.handlePermissionAction(approve = false) + AgentInteractionNotificationManager.ACTION_PLAN_APPROVE -> + interactionNotificationManager.handlePlanApprove() + else -> FileLogger.w(TAG, "未知通知操作: ${intent.action}") + } + } + + companion object { + private const val TAG = "NotificationActionReceiver" + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt b/app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt index 39d00a9c..6cb329ff 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt @@ -67,8 +67,8 @@ class ToolPermissionPolicyEngine @Inject constructor( return EvalResult(Verdict.DENY, emptyList(), denyReason = "当前处于 PLAN(计划)模式,系统物理沙盒已禁止修改系统状态或执行写操作。请在计划模式下仅调用只读工具探索代码,不要尝试修改文件或执行命令。") } - if (mode == com.aicode.feature.agent.domain.model.AgentMode.AUTO) { - // AUTO 模式放行所有权限,但仍保留灾难性 rm 防护(根目录/系统目录删除) + if (mode == com.aicode.feature.agent.domain.model.AgentMode.AUTO || mode == com.aicode.feature.agent.domain.model.AgentMode.TARGET) { + // AUTO / TARGET 模式放行所有权限,但仍保留灾难性 rm 防护(根目录/系统目录删除) if (isShellTool(toolName, args)) { val command = ((args["command"] ?: args["input"]) as? JsonPrimitive)?.content if (command != null) { diff --git a/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt b/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt index 9a03256c..3eec890d 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt @@ -157,6 +157,35 @@ class SessionUseCase @Inject constructor( chatSessionDao.upsert(s.copy(mode = mode)) } + /** 进入 TARGET 模式:写入目标声明并重置步数/失败计数;从 TARGET 切出时记录中断原因。 */ + /** 进入 TARGET 模式(目标待定):清空旧目标与计数,目标由启用后的第一条用户消息自动设定。 */ + suspend fun enterTargetMode(sessionId: String) { + val s = chatSessionDao.getById(sessionId) ?: return + chatSessionDao.upsert( + s.copy( + mode = "TARGET", + goalStatement = null, + goalStepCount = 0, + goalFailCount = 0, + goalTerminationReason = null + ) + ) + } + + /** 设定/更新 TARGET 模式的目标声明,并重置步数与失败计数(新一轮目标执行开始)。 */ + suspend fun updateGoalStatement(sessionId: String, goal: String) { + val s = chatSessionDao.getById(sessionId) ?: return + chatSessionDao.upsert( + s.copy( + mode = "TARGET", + goalStatement = goal, + goalStepCount = 0, + goalFailCount = 0, + goalTerminationReason = null + ) + ) + } + suspend fun updateProviderModel(sessionId: String, providerId: String?, model: String?) { chatSessionDao.updateProviderModel(sessionId, providerId, model) } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/mode/CompleteGoalTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/mode/CompleteGoalTool.kt new file mode 100644 index 00000000..a5141aad --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/mode/CompleteGoalTool.kt @@ -0,0 +1,90 @@ +package com.aicode.feature.agent.domain.tool.mode + +import com.aicode.feature.agent.data.local.dao.ChatSessionDao +import com.aicode.feature.agent.domain.model.AgentContext +import com.aicode.feature.agent.domain.model.AgentMode +import com.aicode.feature.agent.domain.model.GoalTerminationReason +import com.aicode.feature.agent.domain.tool.AbstractContextualTool +import com.aicode.feature.agent.domain.tool.ParameterType +import com.aicode.feature.agent.domain.tool.PendingToolPermission +import com.aicode.feature.agent.domain.tool.ToolCapability +import com.aicode.feature.agent.domain.tool.ToolParameter +import com.aicode.feature.agent.domain.tool.ToolPermissionPolicy +import com.aicode.feature.agent.domain.tool.ToolResult +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import javax.inject.Inject + +/** + * 让 AI 在 TARGET 模式下声明目标已达成。调用后切回 BUILD 并记录终止原因 ACHIEVED, + * summary 展示给用户确认。仅在 TARGET 模式可用。 + */ +class CompleteGoalTool @Inject constructor( + private val chatSessionDao: ChatSessionDao +) : AbstractContextualTool() { + + override val name = "completeGoal" + override val description = "声明当前 TARGET 模式的目标已达成。调用后 AiCode 会提示用户确认,确认即退出 TARGET 回到 BUILD。仅在 TARGET 模式可用,调用时必须提供 summary 描述完成的工作与最终状态。" + override val permissionPolicy = ToolPermissionPolicy.ASK + override val capabilities = setOf(ToolCapability.MODIFY_SESSION_STATE) + + override val parameters: Map = mapOf( + "summary" to ToolParameter( + name = "summary", + type = ParameterType.STRING, + description = "目标达成总结,说明完成的工作与最终状态,展示给用户确认", + required = true + ) + ) + + override suspend fun executeWithContext( + args: Map, + context: AgentContext + ): ToolResult { + if (context.mode != AgentMode.TARGET) { + return ToolResult.Error("当前不在 TARGET 模式,无法声明目标达成", "NOT_IN_TARGET_MODE") + } + + val summary = args["summary"]?.jsonPrimitive?.contentOrNull?.trim() + ?: return ToolResult.Error("缺少必需参数: summary", "MISSING_SUMMARY") + if (summary.isEmpty()) { + return ToolResult.Error("summary 不能为空", "EMPTY_SUMMARY") + } + + val sessionId = context.sessionId + ?: return ToolResult.Error("未关联会话 ID,无法完成目标", "NO_SESSION") + + val sessionEntity = chatSessionDao.getById(sessionId) + ?: return ToolResult.Error("找不到会话记录", "SESSION_NOT_FOUND") + + // 标记目标达成并切回 BUILD,终止原因记 ACHIEVED + chatSessionDao.upsert( + sessionEntity.copy( + mode = AgentMode.BUILD.name, + goalTerminationReason = GoalTerminationReason.ACHIEVED.name + ) + ) + + return ToolResult.Success(JsonPrimitive("目标已达成,会话已切回 BUILD 模式。达成总结:$summary")) + } + + override fun buildPermissionRequest( + callId: String, + args: Map, + argsPreview: String + ): PendingToolPermission { + val summary = args["summary"]?.jsonPrimitive?.contentOrNull ?: "无总结" + + return PendingToolPermission( + id = callId, + toolName = name, + title = "目标达成确认", + summary = "AI 声明目标已达成,申请退出 TARGET 模式", + details = "达成总结:$summary", + argsPreview = argsPreview, + rememberablePatterns = emptyList() + ) + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeTool.kt index e5adf0a6..a0503064 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeTool.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeTool.kt @@ -24,7 +24,7 @@ class SwitchModeTool @Inject constructor( ) : AbstractContextualTool() { override val name = "switchMode" - override val description = "切换当前会话的模式。如果你当前处于 BUILD(构建)模式并认为你需要进入 PLAN(计划)模式来构思复杂逻辑,或者当前在 PLAN 模式下计划已经完成需要进入 BUILD 模式修改代码时,调用此工具主动申请切换。切换前需要用户授权。注意:AUTO(自动)模式只能由用户在界面上手动切换进入,本工具无法切换到 AUTO;但处于 AUTO 模式时,可通过本工具切换到 PLAN 模式退出自动模式(这是 AI 退出 AUTO 的唯一路径)。" + override val description = "切换当前会话的模式。支持 PLAN / BUILD / TARGET 三种切换。BUILD 切换到 PLAN 可构思复杂逻辑,PLAN 计划完成后切 BUILD 修改代码。切换到 TARGET 模式需提供 goal 参数设定目标声明,进入后 AI 依据目标自主执行直到达成或失败。切换前需要用户授权。注意:AUTO(自动)模式只能由用户在界面上手动切换进入,本工具无法切换到 AUTO;但处于 AUTO 模式时,可通过本工具切换到 PLAN 模式退出自动模式(这是 AI 退出 AUTO 的唯一路径)。" override val permissionPolicy = ToolPermissionPolicy.ASK override val capabilities = setOf(ToolCapability.MODIFY_SESSION_STATE) @@ -32,15 +32,21 @@ class SwitchModeTool @Inject constructor( "mode" to ToolParameter( name = "mode", type = ParameterType.STRING, - description = "目标模式,必须是 'PLAN' 或 'BUILD'", + description = "目标模式,必须是 'PLAN'、'BUILD' 或 'TARGET'", required = true, - enum = listOf("PLAN", "BUILD") + enum = listOf("PLAN", "BUILD", "TARGET") ), "reason" to ToolParameter( name = "reason", type = ParameterType.STRING, description = "切换模式的理由,将展示给用户", required = true + ), + "goal" to ToolParameter( + name = "goal", + type = ParameterType.STRING, + description = "切换到 TARGET 模式时的目标声明,描述 AI 需自主达成的目标。仅 mode=TARGET 时必需", + required = false ) ) @@ -50,14 +56,14 @@ class SwitchModeTool @Inject constructor( ): ToolResult { val targetModeStr = args["mode"]?.jsonPrimitive?.contentOrNull?.trim()?.uppercase() ?: return ToolResult.Error("缺少必需参数: mode", "MISSING_MODE") - + val reason = args["reason"]?.jsonPrimitive?.contentOrNull?.trim() ?: return ToolResult.Error("缺少必需参数: reason", "MISSING_REASON") val targetMode = try { AgentMode.valueOf(targetModeStr) } catch (e: Exception) { - return ToolResult.Error("无效的模式: $targetModeStr,只能是 PLAN 或 BUILD", "INVALID_MODE") + return ToolResult.Error("无效的模式: $targetModeStr,只能是 PLAN、BUILD 或 TARGET", "INVALID_MODE") } if (targetMode == AgentMode.AUTO) { @@ -69,6 +75,14 @@ class SwitchModeTool @Inject constructor( return ToolResult.Error("当前处于 AUTO 模式,只能切换到 PLAN 模式退出自动模式", "AUTO_EXIT_PLAN_ONLY") } + // TARGET 模式必须提供 goal 参数 + if (targetMode == AgentMode.TARGET) { + val goal = args["goal"]?.jsonPrimitive?.contentOrNull?.trim() + if (goal.isNullOrEmpty()) { + return ToolResult.Error("切换到 TARGET 模式需要提供 goal 参数描述目标声明", "MISSING_GOAL") + } + } + if (context.mode == targetMode) { return ToolResult.Success(JsonPrimitive("当前已处于 ${targetMode.name} 模式,无需切换。")) } @@ -81,10 +95,27 @@ class SwitchModeTool @Inject constructor( val sessionEntity = chatSessionDao.getById(sessionId) ?: return ToolResult.Error("找不到会话记录", "SESSION_NOT_FOUND") - // 切换模式并保存到数据库。UI 层通过 flow 监听,会自动更新外观与后续流程的上下文 - chatSessionDao.upsert(sessionEntity.copy(mode = targetMode.name)) + // 从 TARGET 切出视为中断目标执行,记录终止原因(保留检查点供回滚) + val terminationReason = if (context.mode == AgentMode.TARGET) { + com.aicode.feature.agent.domain.model.GoalTerminationReason.INTERRUPTED.name + } else null - return ToolResult.Success(JsonPrimitive("成功切换至 ${targetMode.name} 模式。")) + val goalStatement = if (targetMode == AgentMode.TARGET) { + args["goal"]?.jsonPrimitive?.contentOrNull?.trim() + } else null + + // 切换模式并保存到数据库。进入 TARGET 重置步数与失败计数并落 goal;从 TARGET 切出记录中断。 + val updated = sessionEntity.copy( + mode = targetMode.name, + goalStatement = goalStatement ?: if (targetMode == AgentMode.TARGET) sessionEntity.goalStatement else sessionEntity.goalStatement, + goalStepCount = if (targetMode == AgentMode.TARGET) 0 else sessionEntity.goalStepCount, + goalFailCount = if (targetMode == AgentMode.TARGET) 0 else sessionEntity.goalFailCount, + goalTerminationReason = terminationReason ?: sessionEntity.goalTerminationReason + ) + chatSessionDao.upsert(updated) + + val extra = if (targetMode == AgentMode.TARGET) ",目标:${goalStatement}" else "" + return ToolResult.Success(JsonPrimitive("成功切换至 ${targetMode.name} 模式$extra。")) } override fun buildPermissionRequest( @@ -94,13 +125,19 @@ class SwitchModeTool @Inject constructor( ): PendingToolPermission { val mode = args["mode"]?.jsonPrimitive?.contentOrNull ?: "UNKNOWN" val reason = args["reason"]?.jsonPrimitive?.contentOrNull ?: "无理由" - + val goal = args["goal"]?.jsonPrimitive?.contentOrNull + val details = if (goal != null) { + "目标模式:$mode\n\n目标声明:$goal\n\n申请理由:$reason" + } else { + "目标模式:$mode\n\n申请理由:$reason" + } + return PendingToolPermission( id = callId, toolName = name, title = "模式切换申请", summary = "AI 申请切换为 $mode 模式", - details = "目标模式:$mode\n\n申请理由:$reason", + details = details, argsPreview = argsPreview, rememberablePatterns = emptyList() ) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt index 56096f00..fd9382ec 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt @@ -1,6 +1,7 @@ package com.aicode.feature.agent.domain.workflow import com.aicode.feature.agent.domain.model.AgentContext +import com.aicode.feature.agent.domain.model.AgentMessage import com.aicode.feature.agent.domain.model.AgentMode import com.aicode.feature.agent.domain.provider.RetryErrorInfo import com.aicode.feature.agent.domain.tool.AgentTool @@ -102,4 +103,22 @@ interface AgentWorkflow { * 生成失败或取不到标题时返回 null(调用方保留临时标题)。 */ suspend fun generateTitle(sessionId: String, request: String): String? + + /** + * TARGET 模式:把用户第一条消息优化为可执行的目标声明(跟随当前聊天模型)。 + * 优化失败或取不到结果时返回 null(调用方回退使用用户原文)。 + */ + suspend fun generateGoalStatement(sessionId: String, request: String): String? + + /** + * 结合对话上下文优化用户当前输入的需求表达(跟随当前聊天模型,输入框「优化表达」按钮)。 + * 优化失败或取不到结果时返回 null(调用方保留原文)。 + */ + suspend fun optimizeRequest(sessionId: String, request: String, context: List): String? + + /** + * 每轮对话结束后基于上下文生成至多 3 个后续行动建议(跟随当前聊天模型)。 + * 生成失败或取不到结果时返回空列表。 + */ + suspend fun generateActionSuggestions(sessionId: String, history: List): List } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt index 9950c510..cf86d238 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -99,7 +100,9 @@ class StatefulAgentWorkflow @Inject constructor( private val llmCallRecordDao: LlmCallRecordDao, private val keyRotator: ProviderKeyRotator, private val agentNotificationCenter: AgentNotificationCenter, - private val fileAccess: FileAccessProvider + private val fileAccess: FileAccessProvider, + private val targetModeSettingsRepository: com.aicode.feature.settings.data.repository.TargetModeSettingsRepository, + private val chatSessionDao: com.aicode.feature.agent.data.local.dao.ChatSessionDao ) : AgentWorkflow { private companion object { @@ -108,10 +111,17 @@ class StatefulAgentWorkflow @Inject constructor( const val PROGRESS_INTERVAL_MS = 250L const val USER_REJECTED_CODE = "USER_REJECTED" const val TITLE_GENERATOR_FILE = "agent/title-generator.md" + const val GOAL_OPTIMIZER_FILE = "agent/goal-optimizer.md" + const val REQUEST_OPTIMIZER_FILE = "agent/request-optimizer.md" + const val ACTION_SUGGESTIONS_FILE = "agent/action-suggestions.md" const val TITLE_MAX_CHARS = 50 + const val GOAL_STATEMENT_MAX_CHARS = 600 + const val REQUEST_MAX_CHARS = 2000 + private const val GOAL_STATEMENT_PLACEHOLDER = "(目标声明待生成:将从用户下一条消息自动提取)" /** 模式提醒提示词:复用 prompts 目录文件(用户可自定义覆盖),切换时随消息注入而非进 system。 */ const val MODE_REMINDER_PLAN_FILE = "80-plan-mode.md" const val MODE_REMINDER_AUTO_FILE = "81-auto-mode.md" + const val MODE_REMINDER_TARGET_FILE = "82-target-mode.md" val LEADING_COMMENT = Regex("(?s)^\\s*\\s*") /** 模型直出图片落盘目录(与 GenerateImageTool 保持一致)。 */ const val GENERATED_IMAGE_DIR = "~/.aicode/generated-images" @@ -434,9 +444,22 @@ class StatefulAgentWorkflow @Inject constructor( var currentContext = context var state = AgentSessionState() var currentTools = tools + + // TARGET 模式:从 DB 恢复当前会话的步数与失败计数(跨用户消息持久),并取目标声明注入模式提醒。 + var goalStatement: String? = null + if (currentContext.mode == AgentMode.TARGET && currentContext.sessionId != null) { + currentContext = chatSessionDao.getById(currentContext.sessionId)?.let { e -> + goalStatement = e.goalStatement + currentContext.copy( + goalStepCount = e.goalStepCount, + goalFailCount = e.goalFailCount, + goalTerminationReason = e.goalTerminationReason + ) + } ?: currentContext + } val actionQueue = ArrayDeque() // 模式提醒随最新用户消息注入(不进 system,避免切换时 system 前缀变化打断缓存)。 - val modeReminder = buildModeReminder(currentContext.mode) + val modeReminder = buildModeReminder(currentContext.mode, goalStatement) actionQueue.addLast( AgentAction.InitRequest( currentContext.history + AgentMessage.UserMessage( @@ -736,7 +759,10 @@ class StatefulAgentWorkflow @Inject constructor( } } else { currentContext = newCtx - rawResult += buildModeSwitchNotice(newCtx.mode) + val switchedGoal = if (newCtx.mode == AgentMode.TARGET) { + currentContext.sessionId?.let { chatSessionDao.getById(it)?.goalStatement } + } else null + rawResult += buildModeSwitchNotice(newCtx.mode, switchedGoal) } } batchResults.add(ToolBatchResult(toolCall.id, toolCall.name, rawResult, isError, runResult.attachments, runResult.images)) @@ -764,6 +790,55 @@ class StatefulAgentWorkflow @Inject constructor( if (notifySessionId != null && notifications.isNotEmpty()) { agentNotificationCenter.ack(notifySessionId, notifications.map { it.seq }) } + + // TARGET 模式:步数与失败计数 + 终止判定。 + // 仅对实际执行的工具结果计数(被拒/拦截不计入 failCount,因属预防性阻断)。 + if (currentContext.mode == AgentMode.TARGET) { + val thresholds = targetModeSettingsRepository.thresholds.first() + var stepCount = currentContext.goalStepCount + var failCount = currentContext.goalFailCount + batchResults.forEach { br -> + stepCount += 1 + if (br.isError) failCount += 1 else failCount = 0 + } + val sessionId = currentContext.sessionId + val termination: com.aicode.feature.agent.domain.model.GoalTerminationReason? = when { + stepCount >= thresholds.maxStepBudget -> + com.aicode.feature.agent.domain.model.GoalTerminationReason.STEP_LIMIT + failCount >= thresholds.maxConsecutiveFailures -> + com.aicode.feature.agent.domain.model.GoalTerminationReason.FAILED + else -> null + } + if (sessionId != null) { + chatSessionDao.getById(sessionId)?.let { entity -> + chatSessionDao.upsert( + entity.copy( + goalStepCount = stepCount, + goalFailCount = failCount, + goalTerminationReason = termination?.name ?: entity.goalTerminationReason, + mode = if (termination != null) AgentMode.BUILD.name else entity.mode + ) + ) + } + } + currentContext = currentContext.copy( + goalStepCount = stepCount, + goalFailCount = failCount, + goalTerminationReason = termination?.name, + mode = if (termination != null) AgentMode.BUILD else currentContext.mode + ) + if (termination != null) { + val reasonText = when (termination) { + com.aicode.feature.agent.domain.model.GoalTerminationReason.STEP_LIMIT -> + "TARGET 模式因步数达上限($stepCount/${thresholds.maxStepBudget})终止,已切回 BUILD 模式。请用户检查进展并决定后续。" + com.aicode.feature.agent.domain.model.GoalTerminationReason.FAILED -> + "TARGET 模式因连续失败达阈值($failCount/${thresholds.maxConsecutiveFailures})终止,已切回 BUILD 模式。请用户检查失败原因。" + else -> "TARGET 模式终止,已切回 BUILD 模式。" + } + send(AgentEvent.ModeChanged(AgentMode.BUILD, reasonText)) + } + } + actionQueue.addLast(AgentAction.ToolBatchFinished(batchResults)) } } @@ -907,6 +982,144 @@ class StatefulAgentWorkflow @Inject constructor( FileLogger.w(TAG, "生成会话标题失败", e) }.getOrNull() + override suspend fun generateGoalStatement(sessionId: String, request: String): String? = runCatching { + val provider = getEffectiveProvider(sessionId) + val prompt = promptProvider.resolvePrompt(GOAL_OPTIMIZER_FILE) + .replace(LEADING_COMMENT, "") + val callStartWall = System.currentTimeMillis() + val callStartElapsed = SystemClock.elapsedRealtime() + var callCompleted = false + var callError: String? = null + var usage: AIResponse? = null + val response = try { + val resp = provider.complete( + systemPrompt = prompt, + messages = listOf(AgentMessage.UserMessage(content = request)), + tools = emptyList() + ) + usage = resp + callCompleted = true + resp + } catch (e: CancellationException) { + callError = "cancelled" + throw e + } catch (e: Exception) { + callError = e.message ?: e.javaClass.simpleName + throw e + } finally { + val durationMillis = (SystemClock.elapsedRealtime() - callStartElapsed).toInt() + runCatching { + llmCallRecordDao.insert( + LlmCallRecordEntity( + sessionId = sessionId, + providerId = provider.providerId.ifBlank { null }, + model = provider.model, + kind = "goal", + inputTokens = usage?.inputTokens ?: 0, + outputTokens = usage?.outputTokens ?: 0, + cachedInputTokens = usage?.cachedInputTokens ?: 0, + cacheCreationTokens = usage?.cacheCreationTokens ?: 0, + ttfbMillis = null, + durationMillis = durationMillis, + status = if (callCompleted) "success" else "error", + errorMessage = callError, + stopReason = usage?.stopReason, + createdAt = callStartWall + ) + ) + } + } + response.content.trim().replace("\n", " ").take(GOAL_STATEMENT_MAX_CHARS).ifBlank { null } + }.onFailure { e -> + FileLogger.w(TAG, "优化目标声明失败", e) + }.getOrNull() + + /** + * 旁路单轮 LLM 调用(不进主循环、不产生对话气泡):解析 provider、加载提示词、调用并记录调用日志, + * 返回模型原始正文;失败返回 null。generateTitle/generateGoalStatement 与本方法同构, + * 仅提示词与后处理不同,故新方法统一走本入口。 + */ + private suspend fun sideLlmCall( + sessionId: String, + kind: String, + promptFile: String, + messages: List + ): String? = runCatching { + val provider = getEffectiveProvider(sessionId) + val prompt = promptProvider.resolvePrompt(promptFile) + .replace(LEADING_COMMENT, "") + val callStartWall = System.currentTimeMillis() + val callStartElapsed = SystemClock.elapsedRealtime() + var callCompleted = false + var callError: String? = null + var usage: AIResponse? = null + val response = try { + val resp = provider.complete( + systemPrompt = prompt, + messages = messages, + tools = emptyList() + ) + usage = resp + callCompleted = true + resp + } catch (e: CancellationException) { + callError = "cancelled" + throw e + } catch (e: Exception) { + callError = e.message ?: e.javaClass.simpleName + throw e + } finally { + val durationMillis = (SystemClock.elapsedRealtime() - callStartElapsed).toInt() + runCatching { + llmCallRecordDao.insert( + LlmCallRecordEntity( + sessionId = sessionId, + providerId = provider.providerId.ifBlank { null }, + model = provider.model, + kind = kind, + inputTokens = usage?.inputTokens ?: 0, + outputTokens = usage?.outputTokens ?: 0, + cachedInputTokens = usage?.cachedInputTokens ?: 0, + cacheCreationTokens = usage?.cacheCreationTokens ?: 0, + ttfbMillis = null, + durationMillis = durationMillis, + status = if (callCompleted) "success" else "error", + errorMessage = callError, + stopReason = usage?.stopReason, + createdAt = callStartWall + ) + ) + } + } + response.content + }.onFailure { e -> + FileLogger.w(TAG, "旁路 LLM 调用失败: kind=$kind", e) + }.getOrNull() + + override suspend fun optimizeRequest(sessionId: String, request: String, context: List): String? { + val messages = context + AgentMessage.UserMessage(content = request) + return sideLlmCall(sessionId, "optimize", REQUEST_OPTIMIZER_FILE, messages) + ?.trim() + ?.replace("\n", " ") + ?.take(REQUEST_MAX_CHARS) + ?.ifBlank { null } + } + + override suspend fun generateActionSuggestions(sessionId: String, history: List): List { + val raw = sideLlmCall(sessionId, "suggestions", ACTION_SUGGESTIONS_FILE, history) ?: return emptyList() + // 优先按 JSON 数组解析;格式不合规时退化为按行拆分 + val fromJson = runCatching { Json.parseToJsonElement(raw).jsonArray } + .getOrNull() + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() } + ?.filter { it.isNotEmpty() } + ?.take(3) + if (!fromJson.isNullOrEmpty()) return fromJson + return raw.lines() + .map { it.trim().removePrefix("-").removePrefix("*").trim().trim('"') } + .filter { it.isNotEmpty() } + .take(3) + } + private suspend fun runToolStream( tool: StreamingAgentTool, toolCall: ToolCall, @@ -1005,7 +1218,7 @@ class StatefulAgentWorkflow @Inject constructor( * 模式提示词不进 system——一旦切换就要重建 system、打断前缀缓存; * 改为消息级提醒:每次用户请求拼在最新用户消息末尾,位置在消息流尾部,前缀保持稳定。 */ - private fun buildModeReminder(mode: AgentMode): String? = when (mode) { + private fun buildModeReminder(mode: AgentMode, goalStatement: String? = null): String? = when (mode) { AgentMode.PLAN -> promptProvider.resolvePrompt(MODE_REMINDER_PLAN_FILE) .replace(LEADING_COMMENT, "") .trim() @@ -1014,6 +1227,11 @@ class StatefulAgentWorkflow @Inject constructor( .replace(LEADING_COMMENT, "") .trim() .let { "【模式提醒】$it" } + AgentMode.TARGET -> promptProvider.resolvePrompt(MODE_REMINDER_TARGET_FILE) + .replace(LEADING_COMMENT, "") + .replace("{goalStatement}", goalStatement?.takeIf { it.isNotBlank() } ?: GOAL_STATEMENT_PLACEHOLDER) + .trim() + .let { "【模式提醒】$it" } AgentMode.BUILD -> null } @@ -1029,12 +1247,16 @@ class StatefulAgentWorkflow @Inject constructor( return JsonObject(obj + ("notifications" to AgentNotificationFormatter.buildJsonArray(items))).toString() } - private fun buildModeSwitchNotice(mode: AgentMode): String = when (mode) { + private fun buildModeSwitchNotice(mode: AgentMode, goalStatement: String? = null): String = when (mode) { AgentMode.PLAN -> "\n\n" + promptProvider.resolvePrompt(MODE_REMINDER_PLAN_FILE) .replace(LEADING_COMMENT, "") .trim() AgentMode.BUILD -> "\n\n【模式切换】计划已获用户批准,你已切换到 BUILD(构建)模式,可以开始执行计划。" AgentMode.AUTO -> "\n\n【模式切换】你已切换到 AUTO(自动)模式。" + AgentMode.TARGET -> "\n\n" + promptProvider.resolvePrompt(MODE_REMINDER_TARGET_FILE) + .replace(LEADING_COMMENT, "") + .replace("{goalStatement}", goalStatement?.takeIf { it.isNotBlank() } ?: GOAL_STATEMENT_PLACEHOLDER) + .trim() } /** diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt index 41092917..ec97053e 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt @@ -34,12 +34,14 @@ import com.aicode.feature.agent.domain.model.AgentMode import com.aicode.feature.agent.domain.model.ChatSession import com.aicode.feature.agent.domain.model.ReasoningEffort import com.aicode.feature.agent.domain.notification.AgentNotificationCenter +import com.aicode.feature.agent.domain.notification.AgentInteractionNotificationManager import com.aicode.feature.agent.domain.notification.AgentNotificationFormatter import com.aicode.feature.agent.domain.notification.AgentNotificationKind import com.aicode.feature.agent.domain.notification.NotificationOutcome import com.aicode.feature.agent.domain.notification.PendingNotification import com.aicode.feature.agent.domain.permission.PermissionChoice import com.aicode.feature.agent.domain.mcp.McpManager +import com.aicode.feature.agent.domain.mcp.McpServerStatus import com.aicode.feature.agent.domain.subagent.AgentDefinition import com.aicode.feature.agent.domain.subagent.AgentDefinitionRepository import com.aicode.feature.agent.domain.subagent.SubAgentEvent @@ -57,6 +59,7 @@ import com.aicode.feature.workspace.domain.WorkspacePathMapper import com.aicode.feature.workspace.domain.isValidFileEntryName import com.aicode.feature.agent.domain.workflow.AgentEvent import com.aicode.feature.agent.domain.tool.ToolPermissionManager +import com.aicode.feature.agent.domain.tool.ToolPermissionPolicy import com.aicode.feature.agent.domain.tool.ToolRegistry import com.aicode.feature.agent.domain.tool.mode.PlanApprovalChoice import com.aicode.feature.agent.domain.tool.mode.PlanApprovalManager @@ -82,6 +85,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.MutableStateFlow @@ -104,6 +108,14 @@ import java.io.OutputStream import java.util.UUID import javax.inject.Inject +/** 消息全文搜索单条结果(角色 + 命中片段 + 时间),供搜索面板渲染。 */ +data class MessageSearchResult( + val id: String, + val role: String, + val snippet: String, + val timestamp: Long +) + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @HiltViewModel class AIAgentViewModel @Inject constructor( @@ -130,7 +142,10 @@ class AIAgentViewModel @Inject constructor( private val keepaliveSettings: KeepaliveSettingsRepository, private val subAgentEventBus: SubAgentEventBus, private val agentNotificationCenter: AgentNotificationCenter, + private val interactionNotificationManager: AgentInteractionNotificationManager, private val agentDefinitionRepository: AgentDefinitionRepository, + private val skillRepository: com.aicode.feature.agent.domain.skill.SkillRepository, + private val memoryRepository: com.aicode.feature.agent.domain.memory.MemoryRepository, val fileAccess: FileAccessProvider, private val dirWatcher: WorkspaceDirWatcher, @param:ApplicationContext private val context: Context @@ -222,6 +237,126 @@ class AIAgentViewModel @Inject constructor( draftPrefs.edit().remove(id).apply() } + /** + * 输入框「优化表达」:把当前输入文本经当前对话模型结合对话上下文优化后回填输入框。 + * 优化失败时保留原文,不打断输入。 + */ + fun optimizeInputExpression() { + val sid = _currentSessionId.value ?: return + val draft = _inputDrafts.value[sid]?.trim().orEmpty() + if (draft.isBlank() || _optimizingInput.value) return + viewModelScope.launch { + _optimizingInput.value = true + try { + val context = recentContextForSideCall(sid) + val optimized = agentWorkflow.optimizeRequest(sid, draft, context) ?: return@launch + updateInputDraft(optimized) + } catch (e: Exception) { + FileLogger.w(TAG, "优化输入表达失败", e) + } finally { + _optimizingInput.value = false + } + } + } + + /** 会话内消息全文搜索:LIKE 转义后查已落库未压缩消息,倒序至多 50 条。 */ + fun searchMessages() { + val sid = _currentSessionId.value ?: return + val query = _messageSearchQuery.value.trim() + if (query.isEmpty()) { + _messageSearchResults.value = emptyList() + return + } + viewModelScope.launch { + _messageSearching.value = true + try { + val rows = agentMessageDao.searchMessagesInSession( + sid, MessageSearchUtils.escapeLike(query), MESSAGE_SEARCH_LIMIT + ) + _messageSearchResults.value = rows.map { row -> + MessageSearchResult( + id = row.id, + role = row.role, + snippet = MessageSearchUtils.buildSearchSnippet(row.content, query), + timestamp = row.timestamp + ) + } + } catch (e: Exception) { + FileLogger.w(TAG, "搜索消息失败", e) + _messageSearchResults.value = emptyList() + } finally { + _messageSearching.value = false + } + } + } + + fun setMessageSearchQuery(query: String) { + _messageSearchQuery.value = query + if (query.isBlank()) _messageSearchResults.value = emptyList() + } + + fun clearMessageSearch() { + _messageSearchQuery.value = "" + _messageSearchResults.value = emptyList() + _pendingJumpMessageId.value = null + } + + /** 请求跳转到指定消息:UI 在消息列表定位;未在已加载分页时按需扩页。 */ + fun requestJumpToMessage(id: String) { + _pendingJumpMessageId.value = id + } + + fun clearPendingJump() { + _pendingJumpMessageId.value = null + } + + /** 跳转扩页:单次 +100,至上限 1000(消息列表加载范围上限)。 */ + fun expandMessageLimitForJump() { + val sid = _currentSessionId.value ?: return + val current = _messageLimit.value[sid] ?: defaultLimit + if (current >= MAX_MESSAGE_LIMIT_FOR_JUMP) { + _pendingJumpMessageId.value = null + return + } + _messageLimit.value = _messageLimit.value + (sid to (current + 100).coerceAtMost(MAX_MESSAGE_LIMIT_FOR_JUMP)) + } + + fun highlightMessage(id: String) { + _highlightMessageId.value = id + viewModelScope.launch { + delay(HIGHLIGHT_CLEAR_DELAY_MS) + if (_highlightMessageId.value == id) _highlightMessageId.value = null + } + } + + /** + * 旁路 LLM 调用用的近期对话上下文:取最近几条消息,按类型截断正文并剥离 + * thinking/签名/工具调用等主循环专用字段,控制 token 成本。 + */ + private suspend fun recentContextForSideCall( + sid: String, + maxMessages: Int = 6, + maxCharsPerMessage: Int = 300 + ): List = messagePersistenceUseCase.buildHistory(sid, SessionUseCase.PENDING_TOOL_MARKER) + .takeLast(maxMessages) + .map { message -> + when (message) { + is AgentMessage.UserMessage -> message.copy(content = message.content.take(maxCharsPerMessage)) + is AgentMessage.AssistantMessage -> message.copy( + content = message.content.take(maxCharsPerMessage), + toolCalls = emptyList(), + reasoning = "", + signature = "", + thinkingBlocksJson = "", + images = emptyList() + ) + is AgentMessage.ToolResultMessage -> message.copy( + result = message.result.take(maxCharsPerMessage), + images = emptyList() + ) + } + } + fun loadMoreMessages() { val sid = _currentSessionId.value ?: return val currentLimit = _messageLimit.value[sid] ?: defaultLimit @@ -230,7 +365,6 @@ class AIAgentViewModel @Inject constructor( /** 容器初始化实时进度(解压/部署/装包),AI 页底部气泡展示。 */ val containerInit: StateFlow = containerEngine.initProgress - private val _currentWorkspace = MutableStateFlow("") fun setWorkspace(path: String) { if (path.isBlank() || _currentWorkspace.value == path) return @@ -518,6 +652,62 @@ class AIAgentViewModel @Inject constructor( val currentSessionMode: StateFlow = currentSessionState.map { it?.mode ?: AgentMode.BUILD } .stateIn(viewModelScope, SharingStarted.Eagerly, AgentMode.BUILD) + /** TARGET 模式:目标描述 */ + val currentSessionGoal: StateFlow = currentSessionState.map { it?.goalStatement } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + /** TARGET 模式:已执行步数 */ + val currentGoalStepCount: StateFlow = currentSessionState.map { it?.goalStepCount ?: 0 } + .stateIn(viewModelScope, SharingStarted.Eagerly, 0) + + /** TARGET 模式:连续失败次数 */ + val currentGoalFailCount: StateFlow = currentSessionState.map { it?.goalFailCount ?: 0 } + .stateIn(viewModelScope, SharingStarted.Eagerly, 0) + + /** TARGET 模式:终止原因(GoalTerminationReason.name) */ + val currentGoalTerminationReason: StateFlow = + currentSessionState.map { it?.goalTerminationReason } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + /** 当前会话的行动建议(每轮对话结束后生成,至多 3 条,点击填入输入框)。 */ + private val _actionSuggestions = MutableStateFlow>>(emptyMap()) + val actionSuggestions: StateFlow> = _currentSessionId + .flatMapLatest { id -> + if (id == null) flowOf(emptyList()) else _actionSuggestions.map { it[id].orEmpty() } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + /** 输入框「优化表达」是否执行中(按钮显示进度、防重复点击)。 */ + private val _optimizingInput = MutableStateFlow(false) + val optimizingInput: StateFlow = _optimizingInput.asStateFlow() + + /** 会话列表标题过滤词(空串显示全部)。 */ + private val _sessionTitleFilter = MutableStateFlow("") + val sessionTitleFilter: StateFlow = _sessionTitleFilter.asStateFlow() + + fun setSessionTitleFilter(filter: String) { + _sessionTitleFilter.value = filter + } + + /** 消息搜索面板输入词(空串=未搜索)。 */ + private val _messageSearchQuery = MutableStateFlow("") + val messageSearchQuery: StateFlow = _messageSearchQuery.asStateFlow() + + /** 消息全文搜索结果(倒序,至多 50 条)。 */ + private val _messageSearchResults = MutableStateFlow>(emptyList()) + val messageSearchResults: StateFlow> = _messageSearchResults.asStateFlow() + + private val _messageSearching = MutableStateFlow(false) + val messageSearching: StateFlow = _messageSearching.asStateFlow() + + /** 跳转请求:消息搜索结果点击后设置,UI 在消息列表中定位滚动;完成或超限后清空。 */ + private val _pendingJumpMessageId = MutableStateFlow(null) + val pendingJumpMessageId: StateFlow = _pendingJumpMessageId.asStateFlow() + + /** 跳转定位成功后高亮该消息,5 秒自动清除。 */ + private val _highlightMessageId = MutableStateFlow(null) + val highlightMessageId: StateFlow = _highlightMessageId.asStateFlow() + /** 当前会话的思考强度(默认 MEDIUM)。 */ val currentSessionReasoningEffort: StateFlow = currentSessionState.map { it?.reasoningEffort ?: ReasoningEffort.MEDIUM } @@ -763,6 +953,25 @@ class AIAgentViewModel @Inject constructor( const val TAG = "AIAgentViewModel" const val AGENT_COMPLETE_CHANNEL = "agent_complete" const val AGENT_COMPLETE_NOTIFICATION_ID = 100 + + /** /setup-opencode:App 导出到容器 ~/.aicode/ 的 provider 配置文件名。 */ + const val SETUP_OPENCODE_PROVIDER_EXPORT = "opencode-provider.json" + + /** /setup-opencode:发给 AI 的任务提示词(正文与内置技能 setup-opencode 一致)。 */ + val SETUP_OPENCODE_PROMPT = """ + 请加载技能 setup-opencode 并按其正文完成 OpenCode CLI 的安装配置: + 1. 检测容器(Bentley)内 opencode 是否可用(opencode --version),无则用官方脚本或 npm 安装。 + 2. 读取 App 已导出的 ~/.aicode/opencode-provider.json(当前生效 provider 的 baseUrl/apiKey/model)。 + 3. 按其 providerType 选 npm 适配包,生成或合并 ~/.config/opencode/opencode.json 配置(provider id 用 aicode,model 用 aicode/<模型id>)。 + 4. 用 opencode run 验证可用,报告结果。全程不要把 apiKey 的值打印到对话里。 + """.trimIndent() + /** 消息全文搜索单次返回上限。 */ + const val MESSAGE_SEARCH_LIMIT = 50 + /** 跳转定位允许扩到的消息加载上限。 */ + const val MAX_MESSAGE_LIMIT_FOR_JUMP = 1000 + /** 跳转高亮自动清除延迟。 */ + const val HIGHLIGHT_CLEAR_DELAY_MS = 5000L + /** wakeLock 超时保险:构建、装依赖类工具动辄十几分钟,给足 60 分钟;任务正常结束会主动释放。 */ const val KEEPALIVE_TIMEOUT_MS = 60 * 60 * 1000L /** 从后台任务通知文本中提取 内容,供系统通知正文展示。 */ @@ -831,8 +1040,39 @@ class AIAgentViewModel @Inject constructor( } } } + + // 后台交互通知:App 在后台时 AI 等待工具权限/计划批准 → 发系统通知带快捷操作按钮, + // 点击经 NotificationActionReceiver 路由回 resolveToolPermission / approvePlanAndBuild; + // 请求被任何路径解决后 pending 置空,这里统一取消通知。 + viewModelScope.launch { + toolPermissionManager.pendingRequest.collect { request -> + if (request == null) { + interactionNotificationManager.cancelPermission() + } else if (!isAppInForeground()) { + interactionNotificationManager.notifyPermission(request) { choice -> + resolveToolPermission(request.id, choice) + } + } + } + } + viewModelScope.launch { + planApprovalManager.pendingApproval.collect { approval -> + if (approval == null) { + interactionNotificationManager.cancelPlan() + } else if (!isAppInForeground()) { + // 通知正文用批准理由(AI 的切换说明),比当前会话标题更贴近请求内容 + interactionNotificationManager.notifyPlan(approval.reason) { + approvePlanAndBuild() + } + } + } + } } + /** App 是否在前台:与 agent 完成通知的判断口径一致。 */ + private fun isAppInForeground(): Boolean = + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + /** * 子代理已创建(task 工具已创建子会话):在子会话上启动 AI 工作流。 * 消息由 executeAgentRequestStream 统一落库(与用户手动发消息一致), @@ -1099,6 +1339,8 @@ class AIAgentViewModel @Inject constructor( } coroutineContext[Job]?.let { sessionJobs[sessionId] = it } + // 新一轮请求开始:清掉上一轮的行动建议(已过时) + _actionSuggestions.value = _actionSuggestions.value - sessionId FileLogger.d(TAG, "stream start: sid=$sessionId prevState=${_agentStates.value[sessionId]} isAutoTrigger=$isAutoTrigger") setAgentState(sessionId, AgentUIState.Streaming) acquireKeepalive() @@ -1120,6 +1362,13 @@ class AIAgentViewModel @Inject constructor( agentWorkflow.generateTitle(sessionId, request)?.let { sessionUseCase.updateTitle(sessionId, it) } } } + // TARGET 模式且尚未设定目标:本条消息自动设为目标,并用当前对话模型优化表达。 + // 优化失败或写入失败都回退用户原文,保证目标设定不阻塞发送。 + if (currentSession?.mode == "TARGET" && currentSession?.goalStatement.isNullOrBlank()) { + val goal = agentWorkflow.generateGoalStatement(sessionId, request) ?: request + runCatching { sessionUseCase.updateGoalStatement(sessionId, goal) } + .onFailure { FileLogger.w(TAG, "写入目标声明失败", it) } + } } sessionUseCase.touch(sessionId, messagePersistenceUseCase.nextTimestamp()) @@ -1323,6 +1572,23 @@ class AIAgentViewModel @Inject constructor( } setStreamingText(sessionId, null) + // 对话结束(无排队后续、非失败、非子会话)时异步生成 3 个行动建议,供下一步快速发起 + if (!failed && !isSub && _queuedRequests.value[sessionId].isNullOrEmpty()) { + viewModelScope.launch { + try { + val history = recentContextForSideCall(sessionId) + val suggestions = agentWorkflow.generateActionSuggestions(sessionId, history) + if (suggestions.isNotEmpty()) { + _actionSuggestions.value = _actionSuggestions.value + (sessionId to suggestions) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + FileLogger.w(TAG, "生成行动建议失败", e) + } + } + } + } catch (e: CancellationException) { val cancelledState = _agentStates.value[sessionId] val isOwnJob = sessionJobs[sessionId] == coroutineContext[Job] @@ -1540,7 +1806,12 @@ class AIAgentViewModel @Inject constructor( fun setSessionMode(mode: AgentMode) { val sid = _currentSessionId.value ?: return viewModelScope.launch { - sessionUseCase.updateMode(sid, mode.name) + // 进入 TARGET 清空旧目标与计数,目标由启用后的第一条用户消息自动设定 + if (mode == AgentMode.TARGET) { + sessionUseCase.enterTargetMode(sid) + } else { + sessionUseCase.updateMode(sid, mode.name) + } } } @@ -1659,6 +1930,204 @@ class AIAgentViewModel @Inject constructor( private fun escapeMd(text: String): String = text.replace("|", "\\|").replace("\n", " ") + // ── 斜杠指令 /model ───────────────────────────────────────────── + + /** 请求打开模型选择弹窗(/model),UI 消费后调 [onModelSheetDismissed] 复位。 */ + private val _modelSheetRequested = MutableStateFlow(false) + val modelSheetRequested: StateFlow<Boolean> = _modelSheetRequested.asStateFlow() + + override fun requestModelSheet() { + _modelSheetRequested.value = true + } + + fun onModelSheetDismissed() { + _modelSheetRequested.value = false + } + + // ── 斜杠指令 /memory ──────────────────────────────────────────── + + /** /memory —— 以 Markdown 表格气泡列出当前会话注入的规则与记忆构成。 */ + override fun showMemoryOverview() { + val sid = _currentSessionId.value ?: return + val projectRoot = _currentWorkspace.value + viewModelScope.launch(Dispatchers.IO) { + val table = buildString { + appendLine("| 组成部分 | 内容 |") + appendLine("|---|---|") + appendLine("| 基础规则 | 内置提示词 00-90(身份/沟通/编码/安全/工具/技能/模式),随 App 升级更新 |") + // 项目规则文件:AGENTS.md 优先于 CLAUDE.md(与 SystemPromptProvider 一致) + val agentsFile = java.io.File(projectRoot, "AGENTS.md") + val claudeFile = java.io.File(projectRoot, "CLAUDE.md") + val ruleFile = when { + agentsFile.isFile -> "AGENTS.md" + claudeFile.isFile -> "CLAUDE.md" + else -> null + } + appendLine("| 项目规则 | ${escapeMd(ruleFile ?: "(未找到 AGENTS.md / CLAUDE.md)")} |") + // 记忆 + val memories = runCatching { memoryRepository.listMemories(projectRoot.ifBlank { null }) } + .getOrDefault(emptyList()) + val globalCount = memories.count { it.scope == com.aicode.feature.agent.domain.memory.MemoryScope.GLOBAL } + val projectCount = memories.count { it.scope == com.aicode.feature.agent.domain.memory.MemoryScope.PROJECT } + appendLine("| 全局记忆 | ${if (globalCount == 0) "无" else "$globalCount 条(memory 工具可读全文)"} |") + appendLine("| 项目记忆 | ${if (projectCount == 0) "无" else "$projectCount 条(memory 工具可读全文)"} |") + } + persistInfoBubble(sid, table.trimEnd()) + } + } + + // ── 斜杠指令 /skills ──────────────────────────────────────────── + + /** /skills —— 以 Markdown 表格气泡列出技能与内置子代理及启用状态。 */ + override fun showSkillsOverview() { + val sid = _currentSessionId.value ?: return + viewModelScope.launch(Dispatchers.IO) { + val table = buildString { + appendLine("| 名称 | 作用域 | 状态 | 描述 |") + appendLine("|---|---|---|---|") + val entries = runCatching { skillRepository.listAllSkills() }.getOrDefault(emptyList()) + val disabledNames = entries + .filter { skillRepository.isSkillDisabled(it.skill.name) } + .map { it.skill.name } + .toSet() + entries.forEach { entry -> + val status = if (entry.skill.name in disabledNames) "已禁用" else "已启用" + appendLine("| ${escapeMd(entry.skill.name)} | ${entry.scope.name} | $status | ${escapeMd(entry.skill.description)} |") + } + if (entries.isEmpty()) appendLine("| (无已安装技能,可在设置页新建) | - | - | - |") + // 内置子代理 + val agents = runCatching { agentDefinitionRepository.listEnabled() }.getOrDefault(emptyList()) + agents.forEach { entry -> + appendLine("| ${escapeMd(entry.definition.name)}(子代理) | - | 已启用 | ${escapeMd(entry.definition.description)} |") + } + appendLine("| Explore(子代理) | 内置 | 已启用 | 快速探索代码库定位文件与内容 |") + } + persistInfoBubble(sid, table.trimEnd()) + } + } + + // ── 斜杠指令 /mcp ─────────────────────────────────────────────── + + /** /mcp —— 以 Markdown 表格气泡列出 MCP 服务器连接状态。 */ + override fun showMcpOverview() { + val sid = _currentSessionId.value ?: return + val table = buildString { + appendLine("| MCP 服务器 | 状态 | 工具数 | 说明 |") + appendLine("|---|---|---|---|") + val statuses = mcpManager.statuses.value + if (statuses.isEmpty()) { + appendLine("| (未配置 MCP 服务器,可在设置页添加) | - | - | - |") + } else { + statuses.forEach { s -> + val stateText = when (s.state) { + McpServerStatus.State.CONNECTED -> "已连接" + McpServerStatus.State.CONNECTING -> "连接中" + McpServerStatus.State.FAILED -> "失败" + McpServerStatus.State.DISABLED -> "已禁用" + } + appendLine("| ${escapeMd(s.name)} | $stateText | ${s.toolCount} | ${escapeMd(s.error ?: "")} |") + } + } + } + viewModelScope.launch { + persistInfoBubble(sid, table.trimEnd()) + } + } + + // ── 斜杠指令 /tools ──────────────────────────────────────────── + + /** /tools —— 以 Markdown 表格气泡列出当前注册可用的工具及权限策略。 */ + override fun showToolsOverview() { + val sid = _currentSessionId.value ?: return + val table = buildString { + appendLine("| 工具 | 权限策略 | 说明 |") + appendLine("|---|---|---|") + val tools = toolRegistry.getAvailableTools() + tools.sortedBy { it.name.lowercase() }.forEach { tool -> + val policy = when (tool.permissionPolicy) { + ToolPermissionPolicy.AUTO_APPROVE -> "自动放行" + ToolPermissionPolicy.ASK -> "每次询问" + } + appendLine("| ${escapeMd(tool.name)} | $policy | ${escapeMd(tool.description.take(60))} |") + } + } + viewModelScope.launch { + persistInfoBubble(sid, table.trimEnd()) + } + } + + // ── 斜杠指令 /rewind ─────────────────────────────────────────── + + /** /rewind —— 打开回退菜单,定位到最近一条用户消息。 */ + override fun requestRewindMenu() { + val sid = _currentSessionId.value ?: return + val lastUserMessage = messagesState.value.messages.lastOrNull { it.role == MessageRole.USER } + if (lastUserMessage == null) { + viewModelScope.launch { + persistInfoBubble(sid, context.getString(R.string.command_rewind_no_target)) + } + return + } + openRewindMenu(lastUserMessage.id) + } + + /** 落库一条以 AI 气泡展示的 Markdown 信息(各 overview 指令共用),并刷新会话时间戳。 */ + private suspend fun persistInfoBubble(sessionId: String, content: String) { + sessionUseCase.touch(sessionId, messagePersistenceUseCase.nextTimestamp()) + messagePersistenceUseCase.persist(sessionId, MessageRole.ASSISTANT, content, isCompacted = true) + } + + // ── 斜杠指令 /setup-opencode ──────────────────────────────────── + + /** /setup-opencode —— 导出当前 provider 配置并把安装配置任务交给 AI 执行。 */ + override fun runSetupOpencode() { + val sessionId = _currentSessionId.value ?: return + viewModelScope.launch { + // 1. 定位当前生效 provider:会话绑定优先,回退全局默认 + val session = sessionUseCase.getSessionById(sessionId) + val providerId = session?.providerId?.takeIf { it.isNotBlank() } + ?: defaultModelSettingsRepository.getDefaultProviderId().takeIf { it.isNotBlank() } + val provider = providerId?.let { aiProviderRepository.getProviderById(it) } + if (provider == null) { + persistInfoBubble(sessionId, context.getString(R.string.command_setup_opencode_no_provider)) + return@launch + } + // 2. 导出到容器内 ~/.aicode/opencode-provider.json(filesDir/aicode/) + val exportFile = java.io.File( + java.io.File(context.filesDir, "aicode"), + SETUP_OPENCODE_PROVIDER_EXPORT + ) + exportFile.parentFile?.mkdirs() + val exported = """ + { + "providerName": ${jsonQuote(provider.name)}, + "providerType": "${provider.type.name}", + "baseUrl": ${jsonQuote(provider.baseUrl)}, + "apiKey": ${jsonQuote(provider.apiKey)}, + "model": ${jsonQuote(provider.effectiveModel)} + } + """.trimIndent() + runCatching { exportFile.writeText(exported) } + .onFailure { + FileLogger.e(TAG, "导出 provider 配置失败", it) + persistInfoBubble(sessionId, context.getString(R.string.command_setup_opencode_export_failed)) + return@launch + } + // 3. 把技能任务正文发给 AI 执行(提示词引导其检测安装 opencode 并按导出文件生成配置) + executeAgentRequestStream( + request = SETUP_OPENCODE_PROMPT, + modelRequest = SETUP_OPENCODE_PROMPT, + targetSessionId = sessionId, + skipTitleUpdate = true + ) + } + } + + /** JSON 字符串字面量转义。 */ + private fun jsonQuote(text: String): String = + "\"" + text.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"" + + /** 用户批准计划,唤醒 workflow 继续在 BUILD 模式执行。 */ fun approvePlanAndBuild() { diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/MessageSearchUtils.kt b/app/src/main/java/com/aicode/feature/agent/presentation/MessageSearchUtils.kt new file mode 100644 index 00000000..f06f7e0b --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/presentation/MessageSearchUtils.kt @@ -0,0 +1,28 @@ +package com.aicode.feature.agent.presentation + +/** 会话内消息搜索的纯函数:LIKE 转义与命中片段截取。 */ +internal object MessageSearchUtils { + + /** 搜索片段:命中词前后保留的上下文字符数。 */ + const val SNIPPET_CONTEXT_CHARS = 40 + + /** 搜索片段总长上限。 */ + const val SNIPPET_MAX_CHARS = 120 + + /** LIKE 转义:`\`、`%`、`_` 按字面量匹配(配合 DAO 查询的 ESCAPE '\')。 */ + fun escapeLike(query: String): String = + query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + /** 截取命中词附近的上下文片段(前后各保留 [SNIPPET_CONTEXT_CHARS] 字符)。 */ + fun buildSearchSnippet(content: String, query: String): String { + val clean = content.replace("\n", " ") + val index = clean.indexOf(query, ignoreCase = true) + if (index < 0) return clean.take(SNIPPET_MAX_CHARS) + val start = (index - SNIPPET_CONTEXT_CHARS).coerceAtLeast(0) + val end = (index + query.length + SNIPPET_CONTEXT_CHARS) + .coerceAtMost(clean.length) + val prefix = if (start > 0) "…" else "" + val suffix = if (end < clean.length) "…" else "" + return (prefix + clean.substring(start, end) + suffix).take(SNIPPET_MAX_CHARS) + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt index 92b4bdbb..3a26a661 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt @@ -62,11 +62,17 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aicode.R import com.aicode.core.theme.Spacing +import com.aicode.core.ui.glass.GlassPanelArea +import com.aicode.core.ui.glass.LocalGlassSettings +import com.aicode.core.ui.glass.glassPanel +import com.aicode.core.ui.glass.isEnabled +import androidx.compose.foundation.shape.RoundedCornerShape import com.aicode.core.ui.ImageViewerHost import com.aicode.core.ui.LocalImageViewer import com.aicode.core.ui.readableContentMaxWidth import com.aicode.core.ui.rememberImageViewerState import com.aicode.core.ui.rememberViewerDecodeSpec +import com.aicode.feature.agent.domain.model.AgentMode import com.aicode.feature.agent.domain.tool.question.UserQuestionAnswer import com.aicode.feature.agent.presentation.AgentUIMessage import com.aicode.feature.agent.presentation.AgentUIState @@ -267,6 +273,20 @@ fun AIChatPanel( val sessionTitle = currentSession?.title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_new_session_btn) val sessionInputTokens = currentSession?.totalInputTokens ?: 0 val sessionOutputTokens = currentSession?.totalOutputTokens ?: 0 + // 会话内消息搜索:顶栏按钮开关面板,搜索/跳转/高亮状态在 ViewModel + val messageSearchQuery by viewModel.messageSearchQuery.collectAsStateWithLifecycle() + val messageSearchResults by viewModel.messageSearchResults.collectAsStateWithLifecycle() + val messageSearching by viewModel.messageSearching.collectAsStateWithLifecycle() + val pendingJumpMessageId by viewModel.pendingJumpMessageId.collectAsStateWithLifecycle() + val highlightMessageId by viewModel.highlightMessageId.collectAsStateWithLifecycle() + var searchOpen by remember { mutableStateOf(false) } + // 输入防抖:停顿 250ms 后自动搜索;键盘搜索键/回车即时触发 + LaunchedEffect(messageSearchQuery) { + if (messageSearchQuery.isNotBlank()) { + delay(250) + viewModel.searchMessages() + } + } val sessionLastInputTokens = currentSession?.lastInputTokens ?: 0 val messagesReady = messagesState.loaded && messagesState.sessionId == currentSessionId val runningTool by viewModel.runningTool.collectAsStateWithLifecycle() @@ -279,6 +299,7 @@ fun AIChatPanel( val pendingQuestion by viewModel.pendingUserQuestion.collectAsStateWithLifecycle() val queuedRequests by viewModel.queuedRequests.collectAsStateWithLifecycle() val targetRewindMessageId by viewModel.targetRewindMessageId.collectAsStateWithLifecycle() + val modelSheetRequested by viewModel.modelSheetRequested.collectAsStateWithLifecycle() val providers = (settingsViewModel?.providers?.collectAsStateWithLifecycle()?.value ?: emptyList()).filter { it.isEnabled } val modelMetadata = settingsViewModel?.modelMetadata?.collectAsStateWithLifecycle()?.value.orEmpty() val sessionProviderModel by viewModel.currentSessionProviderModel.collectAsStateWithLifecycle() @@ -307,6 +328,11 @@ fun AIChatPanel( val currentWorkspace = workspaceViewModel?.current?.collectAsStateWithLifecycle()?.value val projectRoot = currentWorkspace?.path ?: "" val currentMode by viewModel.currentSessionMode.collectAsStateWithLifecycle() + val currentGoal by viewModel.currentSessionGoal.collectAsStateWithLifecycle() + val goalStepCount by viewModel.currentGoalStepCount.collectAsStateWithLifecycle() + val goalFailCount by viewModel.currentGoalFailCount.collectAsStateWithLifecycle() + val actionSuggestions by viewModel.actionSuggestions.collectAsStateWithLifecycle() + val optimizingInput by viewModel.optimizingInput.collectAsStateWithLifecycle() var inputText by remember { mutableStateOf("") } val inputDraft by viewModel.inputDraft.collectAsStateWithLifecycle() @@ -347,6 +373,12 @@ fun AIChatPanel( } val activeModel = activeProvider?.effectiveModel.orEmpty() val activeModelMetadata = modelMetadata[activeModel] + // 上下文占用 = 最近一次请求的 inputTokens(当前上下文窗口实际占用,非累计消耗) + val lastContextUsedTokens = remember(messages) { + messages.asReversed().firstOrNull { + it.role == MessageRole.ASSISTANT && it.inputTokens > 0 + }?.inputTokens ?: 0 + } val canUploadFiles = projectRoot.isNotBlank() && activeModelMetadata?.supportsTools == true val canUploadImages = projectRoot.isNotBlank() val reasoningEffort by viewModel.currentSessionReasoningEffort.collectAsStateWithLifecycle() @@ -825,6 +857,11 @@ fun AIChatPanel( onOpenDrawer() }, onNewChat = { viewModel.newSession() }, + onToggleSearch = { + searchOpen = !searchOpen + if (!searchOpen) viewModel.clearMessageSearch() + }, + searchActive = searchOpen, onNavigateToTerminal = onNavigateToTerminal, onNavigateToGit = onNavigateToGit, currentMode = currentMode, @@ -832,7 +869,9 @@ fun AIChatPanel( connectionState = connectionState?.takeIf { isRemote }, showMenuButton = showMenuButton, terminalActive = terminalActive, - gitActive = gitActive + gitActive = gitActive, + contextUsedTokens = lastContextUsedTokens, + contextMaxTokens = activeModelMetadata?.contextTokens ?: 0 ) } ) { padding -> @@ -851,7 +890,26 @@ fun AIChatPanel( ) { // 内容层:消息列表延伸到屏幕底部,输入框悬浮其上,滚动时卡片可滑入输入框后面 Column(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.weight(1f)) { + if (currentMode == AgentMode.TARGET && currentGoal != null) { + TargetModeStatusBar( + goal = currentGoal!!, + stepCount = goalStepCount, + failCount = goalFailCount + ) + } + val contentGlass = LocalGlassSettings.current + val contentGlassOn = contentGlass.enabled && + contentGlass.isEnabled(GlassPanelArea.CONTENT) && + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU + Box( + modifier = Modifier + .weight(1f) + .then( + if (contentGlassOn) Modifier.glassPanel( + RoundedCornerShape(0.dp), GlassPanelArea.CONTENT + ) else Modifier + ) + ) { if (!messagesReady) { // 远程模式连接未就绪时显示连接状态占位,避免空白或旧工作区记录闪烁 if (isRemote && connectionState != null && connectionState != com.aicode.feature.agent.domain.container.ConnectionState.CONNECTED) { @@ -913,6 +971,19 @@ fun AIChatPanel( } }.flatten() } + // 消息搜索跳转:定位滚动 + 高亮;目标不在已加载分页时扩页重试(messages 变化重新触发), + // 扩到上限仍找不到则 ViewModel 清空请求结束。 + LaunchedEffect(pendingJumpMessageId, chatItems) { + val targetId = pendingJumpMessageId ?: return@LaunchedEffect + val index = chatItems.indexOfFirst { it.message.id == targetId } + if (index < 0) { + viewModel.expandMessageLimitForJump() + } else { + listState.scrollToItem(index) + viewModel.highlightMessage(targetId) + viewModel.clearPendingJump() + } + } LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), @@ -933,6 +1004,7 @@ fun AIChatPanel( contentSlice = item.slice, isChunkHeader = item.isChunkHeader, isChunkFooter = item.isChunkFooter, + isHighlighted = message.id == highlightMessageId, onRewindClick = { viewModel.openRewindMenu(it) }, onMoreClick = { messageForMenu = it }, onToolToggle = { @@ -1114,6 +1186,21 @@ fun AIChatPanel( } } + // 行动建议条:每轮对话结束后展示,点击将建议文本填入输入框;流式进行中隐藏 + AnimatedVisibility( + visible = actionSuggestions.isNotEmpty() && !isBusy, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + ActionSuggestionsRow( + suggestions = actionSuggestions, + onSelected = { suggestion -> + inputText = suggestion + viewModel.updateInputDraft(suggestion) + } + ) + } + ChatInputBar( value = inputText, onValueChange = { inputText = it; viewModel.updateInputDraft(it) }, @@ -1131,6 +1218,9 @@ fun AIChatPanel( }, currentMode = currentMode, onToggleMode = { viewModel.setSessionMode(it) }, + targetGoalPending = currentMode == AgentMode.TARGET && currentGoal == null, + optimizingInput = optimizingInput, + onOptimizeInput = { viewModel.optimizeInputExpression() }, reasoningEffort = reasoningEffort, onReasoningEffortChange = { viewModel.setSessionReasoningEffort(it) }, pendingAttachments = pendingAttachments, @@ -1169,14 +1259,43 @@ fun AIChatPanel( } else 0f }, isScrolling = listState.isScrollInProgress, - forceOpenModelSheet = onboardingStep == OnboardingStep.SIMULATE_CHOOSE_MODEL, - onModelSheetDismiss = onSelectModelInOnboarding, + forceOpenModelSheet = onboardingStep == OnboardingStep.SIMULATE_CHOOSE_MODEL || modelSheetRequested, + onModelSheetDismiss = { + onSelectModelInOnboarding?.invoke() + viewModel.onModelSheetDismissed() + }, modifier = Modifier .fillMaxWidth() .onboardingTarget(OnboardingStep.SEND_MESSAGE) ) } // 悬浮层结束 + // 消息搜索面板:顶栏搜索按钮开关,悬浮在消息列表上方;点击结果请求跳转并收起面板 + androidx.compose.animation.AnimatedVisibility( + visible = searchOpen, + modifier = Modifier + .align(Alignment.TopCenter) + .widthIn(max = readableContentMaxWidth()), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + MessageSearchPanel( + query = messageSearchQuery, + results = messageSearchResults, + searching = messageSearching, + onQueryChange = { viewModel.setMessageSearchQuery(it) }, + onSearch = { viewModel.searchMessages() }, + onResultClick = { result -> + viewModel.requestJumpToMessage(result.id) + searchOpen = false + }, + onClose = { + searchOpen = false + viewModel.clearMessageSearch() + } + ) + } + // 滚动到底部按钮:悬浮在输入框右上角上方(悬浮层高度 + 间距定位),离底超过半屏时 // 显示,不看滚动方向——往上翻历史后停住恰恰是最需要一键回底的时刻;滚动时跟随输入框淡出。 androidx.compose.animation.AnimatedVisibility( diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ActionSuggestionsRow.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ActionSuggestionsRow.kt new file mode 100644 index 00000000..6f65f8ea --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ActionSuggestionsRow.kt @@ -0,0 +1,52 @@ +package com.aicode.feature.agent.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import com.aicode.core.theme.Spacing + +/** + * 行动建议条:每轮对话结束后展示至多 3 条后续行动建议,点击将文本填入输入框。 + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun ActionSuggestionsRow( + suggestions: List<String>, + onSelected: (String) -> Unit, + modifier: Modifier = Modifier +) { + FlowRow( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = Spacing.md), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.xs) + ) { + suggestions.take(3).forEach { suggestion -> + SuggestionChip( + onClick = { onSelected(suggestion) }, + label = { + Text( + text = suggestion, + style = MaterialTheme.typography.labelMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) + } + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatDrawer.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatDrawer.kt index 45d84068..301dde7a 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatDrawer.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatDrawer.kt @@ -114,6 +114,8 @@ fun ChatDrawerContent( currentSessionId: String?, agentStates: Map<String, AgentUIState>, awaitingPermissionSessionIds: Set<String> = emptySet(), + sessionTitleFilter: String = "", + onSessionTitleFilterChange: (String) -> Unit = {}, onSelect: (ChatSession) -> Unit, onDelete: (ChatSession) -> Unit, onRename: (ChatSession, String) -> Unit, @@ -186,6 +188,8 @@ fun ChatDrawerContent( currentSessionId = currentSessionId, agentStates = agentStates, awaitingPermissionSessionIds = awaitingPermissionSessionIds, + sessionTitleFilter = sessionTitleFilter, + onSessionTitleFilterChange = onSessionTitleFilterChange, subSessionsByParent = subSessionsByParent, listState = listState, onSelect = onSelect, @@ -319,6 +323,8 @@ private fun SessionListTab( currentSessionId: String?, agentStates: Map<String, AgentUIState>, awaitingPermissionSessionIds: Set<String>, + sessionTitleFilter: String, + onSessionTitleFilterChange: (String) -> Unit, subSessionsByParent: Map<String, List<ChatSession>>, listState: LazyListState, onSelect: (ChatSession) -> Unit, @@ -338,25 +344,55 @@ private fun SessionListTab( } return } + // 标题过滤:仅作用于显示,空串显示全部 + val filteredSessions = remember(sessions, sessionTitleFilter) { + if (sessionTitleFilter.isBlank()) sessions + else sessions.filter { it.title.contains(sessionTitleFilter, ignoreCase = true) } + } + if (filteredSessions.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + stringResource(R.string.chat_session_search_no_match), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Spacing.md) + ) + } + return + } var expandedIds by rememberSaveable( stateSaver = listSaver<Set<String>, String>( save = { it.toList() }, restore = { it.toSet() } ) ) { mutableStateOf(emptySet<String>()) } - val groups = remember(sessions) { + val groups = remember(filteredSessions) { val now = System.currentTimeMillis() - val pinned = sessions.filter { it.isPinned } - val unpinned = sessions.filterNot { it.isPinned } + val pinned = filteredSessions.filter { it.isPinned } + val unpinned = filteredSessions.filterNot { it.isPinned } buildList { if (pinned.isNotEmpty()) add(SessionGroup("pinned", pinned)) addAll(buildSessionGroups(unpinned, now)) } } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize() - ) { + Column(modifier = Modifier.fillMaxSize()) { + AppTextField( + value = sessionTitleFilter, + onValueChange = onSessionTitleFilterChange, + placeholder = stringResource(R.string.chat_session_search_hint), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.md, vertical = Spacing.xs) + ) + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { // 每个分组占一个 item、组内 forEach 全量渲染会让 LazyColumn 失去惰性: // 「最近 30 天」这种分组有上百个会话时,一个 item 就要组合上百行。分组头与会话各自成 item。 groups.forEach { group -> @@ -422,6 +458,7 @@ private fun SessionListTab( } } } + } } } diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatHeaderComponents.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatHeaderComponents.kt index d1f098e7..6667029a 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatHeaderComponents.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatHeaderComponents.kt @@ -1,5 +1,6 @@ package com.aicode.feature.agent.presentation.component +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,6 +28,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -42,6 +48,7 @@ import compose.icons.FeatherIcons import compose.icons.feathericons.GitBranch import compose.icons.feathericons.Menu import compose.icons.feathericons.Plus +import compose.icons.feathericons.Search import compose.icons.feathericons.Terminal @Composable @@ -52,6 +59,8 @@ internal fun ChatHeader( outputTokens: Int, onOpenDrawer: () -> Unit, onNewChat: () -> Unit, + onToggleSearch: () -> Unit, + searchActive: Boolean = false, onNavigateToTerminal: () -> Unit, onNavigateToGit: () -> Unit, currentMode: AgentMode, @@ -59,7 +68,9 @@ internal fun ChatHeader( connectionState: com.aicode.feature.agent.domain.container.ConnectionState? = null, showMenuButton: Boolean = true, terminalActive: Boolean = false, - gitActive: Boolean = false + gitActive: Boolean = false, + contextUsedTokens: Int = 0, + contextMaxTokens: Int = 0 ) { Surface( color = MaterialTheme.colorScheme.background @@ -113,6 +124,12 @@ internal fun ChatHeader( ) } } + WorkbenchIconButton( + icon = FeatherIcons.Search, + contentDescription = stringResource(R.string.common_search), + active = searchActive, + onClick = onToggleSearch + ) IconButton(onClick = onNewChat) { Icon( FeatherIcons.Plus, @@ -131,6 +148,12 @@ internal fun ChatHeader( active = terminalActive, onClick = onNavigateToTerminal ) + if (contextMaxTokens > 0) { + ContextUsageCapsule( + usedTokens = contextUsedTokens, + maxTokens = contextMaxTokens + ) + } } // 远程模式:左边 SSH 连接状态,右边 token 累计统计 if (connectionState != null) { @@ -292,4 +315,69 @@ internal fun WelcomeState(modifier: Modifier = Modifier) { ) } } +} + +/** + * 上下文占用胶囊:圆环进度(按占用率变色)+ 已用/最大 token 文本。 + * 占用率 = 最近一次请求的 inputTokens / 模型 contextTokens(当前上下文窗口占用,非累计消耗)。 + */ +@Composable +private fun ContextUsageCapsule(usedTokens: Int, maxTokens: Int) { + val progress = if (maxTokens > 0) (usedTokens.toFloat() / maxTokens).coerceIn(0f, 1f) else 0f + val color = when { + progress >= 0.9f -> MaterialTheme.colorScheme.error + progress >= 0.7f -> Color(0xFFF59E0B) + else -> MaterialTheme.colorScheme.primary + } + val trackColor = MaterialTheme.colorScheme.surfaceVariant + Row( + modifier = Modifier + .background(trackColor.copy(alpha = 0.6f), RoundedCornerShape(Radius.pill)) + .clip(RoundedCornerShape(Radius.pill)) + .padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.xs) + ) { + ContextUsageRing(progress = progress, color = color, trackColor = trackColor, modifier = Modifier.size(16.dp)) + Text( + text = "${formatTokenCount(usedTokens.toLong())}/${formatTokenCount(maxTokens.toLong())}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } +} + +@Composable +private fun ContextUsageRing( + progress: Float, + color: Color, + trackColor: Color, + modifier: Modifier = Modifier, +) { + Canvas(modifier) { + val stroke = size.minDimension / 8f + val inset = stroke / 2f + val arcSize = Size(size.minDimension - stroke, size.minDimension - stroke) + drawArc( + color = trackColor, + startAngle = 0f, + sweepAngle = 360f, + useCenter = false, + topLeft = Offset(inset, inset), + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round) + ) + if (progress > 0f) { + drawArc( + color = color, + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + topLeft = Offset(inset, inset), + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round) + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt index a53f7ea1..9e9d03fc 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt @@ -1,6 +1,7 @@ package com.aicode.feature.agent.presentation.component import android.content.ClipData +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -30,6 +31,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -40,6 +44,10 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import com.aicode.core.ui.glass.GlassPanelArea +import com.aicode.core.ui.glass.LocalGlassSettings +import com.aicode.core.ui.glass.glassPanel +import com.aicode.core.ui.glass.isEnabled import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -113,6 +121,9 @@ internal fun ChatInputBar( onSelectModel: (String, String) -> Unit, currentMode: AgentMode, onToggleMode: (AgentMode) -> Unit, + targetGoalPending: Boolean = false, + optimizingInput: Boolean = false, + onOptimizeInput: () -> Unit = {}, reasoningEffort: ReasoningEffort, onReasoningEffortChange: (ReasoningEffort) -> Unit, pendingAttachments: List<PendingUploadAttachment>, @@ -139,6 +150,10 @@ internal fun ChatInputBar( ) { val hasContent = value.isNotBlank() || pendingAttachments.isNotEmpty() val canSend = hasContent + val glass = LocalGlassSettings.current + val inputGlassOn = glass.enabled && + glass.isEnabled(GlassPanelArea.INPUT) && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU var showAttachmentSheet by remember { mutableStateOf(false) } val showSlashMenu = !isBusy && slashCommands.isNotEmpty() && value.startsWith("/") && !value.contains("\n") @@ -260,7 +275,13 @@ internal fun ChatInputBar( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(Radius.lg)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)) + .then( + if (inputGlassOn) { + Modifier.glassPanel(RoundedCornerShape(Radius.lg), GlassPanelArea.INPUT) + } else { + Modifier.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)) + } + ) .border( 1.dp, MaterialTheme.colorScheme.outlineVariant, @@ -280,8 +301,13 @@ internal fun ChatInputBar( .fillMaxWidth() .heightIn(min = 44.dp, max = 140.dp), placeholder = { + val hintRes = when { + isBusy -> R.string.chat_queue_hint + targetGoalPending -> R.string.mode_target_input_hint + else -> R.string.chat_input_placeholder + } Text( - stringResource(if (isBusy) R.string.chat_queue_hint else R.string.chat_input_placeholder), + stringResource(hintRes), color = MaterialTheme.colorScheme.onSurfaceVariant ) }, @@ -310,11 +336,13 @@ internal fun ChatInputBar( val modeColor = when (currentMode) { AgentMode.PLAN -> MaterialTheme.colorScheme.primaryContainer AgentMode.AUTO -> MaterialTheme.colorScheme.error + AgentMode.TARGET -> MaterialTheme.colorScheme.tertiaryContainer AgentMode.BUILD -> MaterialTheme.semanticColors.success } val modeTextColor = when (currentMode) { AgentMode.PLAN -> MaterialTheme.colorScheme.onPrimaryContainer AgentMode.AUTO -> MaterialTheme.colorScheme.onError + AgentMode.TARGET -> MaterialTheme.colorScheme.onTertiaryContainer AgentMode.BUILD -> MaterialTheme.semanticColors.onSuccess } Surface( @@ -325,7 +353,8 @@ internal fun ChatInputBar( val nextMode = when (currentMode) { AgentMode.BUILD -> AgentMode.PLAN AgentMode.PLAN -> AgentMode.AUTO - AgentMode.AUTO -> AgentMode.BUILD + AgentMode.AUTO -> AgentMode.TARGET + AgentMode.TARGET -> AgentMode.BUILD } onToggleMode(nextMode) } @@ -388,6 +417,24 @@ internal fun ChatInputBar( contentDescription = stringResource(R.string.chat_add_attachment), onClick = { showAttachmentSheet = true } ) + IconButton( + enabled = !isBusy && value.isNotBlank() && !optimizingInput, + onClick = onOptimizeInput + ) { + if (optimizingInput) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } else { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = stringResource(R.string.chat_optimize_input), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } SendButton(canSend = canSend, hasContent = hasContent, isBusy = isBusy, tokenProgress = tokenProgress, onSend = onSend, onStop = onStop) } } diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatModelSheet.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatModelSheet.kt index 7a72998e..108f5aa6 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatModelSheet.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatModelSheet.kt @@ -1,6 +1,7 @@ package com.aicode.feature.agent.presentation.component import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,6 +16,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -27,8 +29,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt import com.aicode.R import com.aicode.core.theme.Radius import com.aicode.core.theme.Spacing @@ -127,45 +131,58 @@ internal fun ReasoningEffortSelector( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = Spacing.sm) + modifier = Modifier.padding(bottom = Spacing.md) ) - availableEfforts.forEach { e -> - val selected = e == effort + // 当前档位大字(ChatGPT 风格:滑块上方突出显示选中档位) + Text( + text = stringResource(effort.labelRes()), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = Spacing.md) + ) + val n = availableEfforts.size + if (n > 1) { + val selectedIndex = availableEfforts.indexOf(effort).coerceIn(0, n - 1) + Slider( + value = selectedIndex.toFloat(), + onValueChange = { v -> + val idx = v.roundToInt().coerceIn(0, n - 1) + if (idx != selectedIndex) { + onChange(availableEfforts[idx]) + } + }, + valueRange = 0f..(n - 1).toFloat(), + steps = (n - 2).coerceAtLeast(0), + enabled = enabled, + modifier = Modifier.fillMaxWidth() + ) + // 档位标签行:均匀分布,当前档位高亮 Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(Radius.sm)) - .clickable { - showSheet = false - onChange(e) - } - .padding(horizontal = Spacing.md, vertical = Spacing.md), - verticalAlignment = Alignment.CenterVertically + .padding(top = Spacing.xs), + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - Icon( - FeatherIcons.Zap, - contentDescription = null, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.width(Spacing.md)) - Text( - text = stringResource(e.labelRes()), - style = MaterialTheme.typography.bodyLarge, - color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (selected) { - Icon( - FeatherIcons.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) + availableEfforts.forEach { e -> + Text( + text = stringResource(e.labelRes()), + style = MaterialTheme.typography.labelSmall, + color = if (e == effort) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) ) } } + } else { + // 仅一档可用:不显示滑块,只提示档位名 + Text( + text = stringResource(availableEfforts.firstOrNull()?.labelRes() ?: effort.labelRes()), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageBubbles.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageBubbles.kt index 76e50154..dc5eaaa0 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageBubbles.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageBubbles.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -170,6 +171,8 @@ internal fun AgentMessageItem( isChunkHeader: Boolean = true, /** 是否为分块的末块(渲染操作行、底部圆角、与下一条列表 item 的间距);非分块消息恒为 true。 */ isChunkFooter: Boolean = true, + /** 消息搜索跳转定位高亮:true 时气泡加主色描边与浅色底。 */ + isHighlighted: Boolean = false, ) { if (message.isCompactionMarker) { // 压缩内部锚点不再渲染分隔线:摘要卡片已提供压缩反馈,避免与卡片重复。 @@ -234,6 +237,16 @@ internal fun AgentMessageItem( Column( modifier = Modifier .fillMaxWidth() + .then( + if (isHighlighted) { + Modifier + .clip(RoundedCornerShape(Radius.md)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)) + .border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), RoundedCornerShape(Radius.md)) + } else { + Modifier + } + ) // LazyColumn 不再统一 spacedBy:末块(或非分块消息)自带与下一条 item 的间距, // 相邻分块之间零间距无缝衔接,整个长消息在外观上仍是一条气泡。 .padding(bottom = if (isChunkFooter) Spacing.sm else 0.dp), diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageSearchPanel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageSearchPanel.kt new file mode 100644 index 00000000..de43354f --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageSearchPanel.kt @@ -0,0 +1,210 @@ +package com.aicode.feature.agent.presentation.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aicode.core.theme.Spacing +import com.aicode.core.ui.AppTextField +import com.aicode.feature.agent.presentation.MessageRole +import com.aicode.feature.agent.presentation.MessageSearchResult +import com.aicode.R +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 会话内消息搜索面板:顶栏搜索按钮开关,悬浮在消息列表上方。 + * 结果按时间倒序(至多 50 条),点击请求跳转定位。 + */ +@Composable +internal fun MessageSearchPanel( + query: String, + results: List<MessageSearchResult>, + searching: Boolean, + onQueryChange: (String) -> Unit, + onSearch: () -> Unit, + onResultClick: (MessageSearchResult) -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 4.dp + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.sm, vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + AppTextField( + value = query, + onValueChange = onQueryChange, + placeholder = stringResource(R.string.chat_search_messages_hint), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onClose) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.common_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + when { + searching -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.md, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + text = stringResource(R.string.common_loading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + query.isNotBlank() && results.isEmpty() -> { + Text( + text = stringResource(R.string.chat_search_no_results), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.sm) + ) + } + else -> { + val timeFormat = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + ) { + itemsIndexed(items = results, key = { _, result -> result.id }) { _, result -> + MessageSearchResultRow( + result = result, + query = query, + timeText = timeFormat.format(Date(result.timestamp)), + onClick = { onResultClick(result) } + ) + } + } + } + } + } + } +} + +@Composable +private fun MessageSearchResultRow( + result: MessageSearchResult, + query: String, + timeText: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = Spacing.md, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + val (roleLabel, roleIcon) = when (result.role) { + MessageRole.USER.name -> stringResource(R.string.chat_search_role_user) to Icons.Filled.Person + MessageRole.TOOL.name -> stringResource(R.string.chat_search_role_tool) to Icons.Filled.Build + else -> stringResource(R.string.chat_search_role_ai) to Icons.Filled.AutoAwesome + } + Icon( + roleIcon, + contentDescription = roleLabel, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = roleLabel, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + val highlightColor = MaterialTheme.colorScheme.primary + val snippet = remember(result.snippet, query, highlightColor) { + highlightQueryInSnippet(result.snippet, query, highlightColor) + } + Text( + text = snippet, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = timeText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** 片段内高亮全部命中词(忽略大小写):主色加粗,其余样式保持正文。 */ +private fun highlightQueryInSnippet( + snippet: String, + query: String, + highlightColor: Color +): AnnotatedString { + if (query.isBlank()) return AnnotatedString(snippet) + return buildAnnotatedString { + append(snippet) + var index = snippet.indexOf(query, ignoreCase = true) + while (index >= 0) { + addStyle( + SpanStyle(color = highlightColor, fontWeight = FontWeight.Bold), + index, + index + query.length + ) + index = snippet.indexOf(query, index + query.length, ignoreCase = true) + } + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/TargetModeStatusBar.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/TargetModeStatusBar.kt new file mode 100644 index 00000000..98c0fe5b --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/TargetModeStatusBar.kt @@ -0,0 +1,88 @@ +package com.aicode.feature.agent.presentation.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aicode.R +import com.aicode.core.theme.Spacing + +/** + * TARGET 模式状态条:显示当前目标、已执行步数与连续失败次数。 + * 悬浮于消息列表顶部,仅在 TARGET 模式且已设定目标时展示。 + */ +@Composable +internal fun TargetModeStatusBar( + goal: String, + stepCount: Int, + failCount: Int, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Column( + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.sm), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Flag, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary, shape = CircleShape) + .padding(2.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = goal, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(start = Spacing.sm) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.md) + ) { + Text( + text = stringResource(R.string.mode_target_status_steps, stepCount), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + Text( + text = stringResource(R.string.mode_target_status_fails, failCount), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + } + } + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/data/repository/BackgroundSettingsRepository.kt b/app/src/main/java/com/aicode/feature/settings/data/repository/BackgroundSettingsRepository.kt index f662d293..db944ba2 100644 --- a/app/src/main/java/com/aicode/feature/settings/data/repository/BackgroundSettingsRepository.kt +++ b/app/src/main/java/com/aicode/feature/settings/data/repository/BackgroundSettingsRepository.kt @@ -2,13 +2,18 @@ package com.aicode.feature.settings.data.repository import android.content.Context import android.net.Uri +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.aicode.core.ui.glass.GlassMode +import com.aicode.core.ui.glass.GlassPanelArea +import com.aicode.core.ui.glass.GlassSettings import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -34,6 +39,33 @@ class BackgroundSettingsRepository @Inject constructor( const val MIN_ALPHA = 0f private val IMAGE_PATH_KEY = stringPreferencesKey("background_image_path") private val ALPHA_KEY = floatPreferencesKey("background_alpha") + private val FROST_INTENSITY_KEY = floatPreferencesKey("frost_intensity") + const val DEFAULT_FROST_INTENSITY = 0.5f + const val MIN_FROST_INTENSITY = 0f + const val MAX_FROST_INTENSITY = 1f + private val GLASS_ENABLED_KEY = booleanPreferencesKey("glass_enabled") + private val GLASS_MODE_KEY = stringPreferencesKey("glass_mode") + private val GLASS_SIDEBAR_ENABLED_KEY = booleanPreferencesKey("glass_sidebar_enabled") + private val GLASS_INPUT_ENABLED_KEY = booleanPreferencesKey("glass_input_enabled") + private val GLASS_CONTENT_ENABLED_KEY = booleanPreferencesKey("glass_content_enabled") + private val WATER_WAVE_ANIMATED_KEY = booleanPreferencesKey("water_wave_animated") + + const val MODE_FROSTED = "frosted" + const val MODE_WATER = "water" + const val MODE_LIQUID = "liquid" + + /** 未知值与未设置一律回退磨砂档。 */ + fun parseGlassMode(raw: String?): GlassMode = when (raw) { + MODE_WATER -> GlassMode.WATER + MODE_LIQUID -> GlassMode.LIQUID + else -> GlassMode.FROSTED + } + + fun glassModeToRaw(mode: GlassMode): String = when (mode) { + GlassMode.FROSTED -> MODE_FROSTED + GlassMode.WATER -> MODE_WATER + GlassMode.LIQUID -> MODE_LIQUID + } /** 把 UI 百分比(0~100)线性映射为实际透明度(0~MAX_ALPHA)。 */ fun sliderToAlpha(percent: Float): Float = @@ -42,6 +74,12 @@ class BackgroundSettingsRepository @Inject constructor( /** 把实际透明度反映射为 UI 百分比(0~100)。 */ fun alphaToSlider(alpha: Float): Float = (alpha / MAX_ALPHA).coerceIn(0f, 1f) * 100f + + fun frostToSlider(intensity: Float): Float = + intensity.coerceIn(MIN_FROST_INTENSITY, MAX_FROST_INTENSITY) * 100f + + fun sliderToFrost(percent: Float): Float = + (percent / 100f).coerceIn(MIN_FROST_INTENSITY, MAX_FROST_INTENSITY) } /** 当前背景图文件绝对路径;null 表示未设置。 */ @@ -52,6 +90,60 @@ class BackgroundSettingsRepository @Inject constructor( (it[ALPHA_KEY] ?: DEFAULT_ALPHA).coerceIn(MIN_ALPHA, MAX_ALPHA) } + val frostIntensityFlow: Flow<Float> = context.backgroundDataStore.data.map { + (it[FROST_INTENSITY_KEY] ?: DEFAULT_FROST_INTENSITY) + .coerceIn(MIN_FROST_INTENSITY, MAX_FROST_INTENSITY) + } + + val glassEnabledFlow: Flow<Boolean> = context.backgroundDataStore.data.map { + it[GLASS_ENABLED_KEY] ?: false + } + + val glassModeFlow: Flow<GlassMode> = context.backgroundDataStore.data.map { + parseGlassMode(it[GLASS_MODE_KEY]) + } + + val glassSidebarEnabledFlow: Flow<Boolean> = context.backgroundDataStore.data.map { + it[GLASS_SIDEBAR_ENABLED_KEY] ?: true + } + + val glassInputEnabledFlow: Flow<Boolean> = context.backgroundDataStore.data.map { + it[GLASS_INPUT_ENABLED_KEY] ?: true + } + + val glassContentEnabledFlow: Flow<Boolean> = context.backgroundDataStore.data.map { + it[GLASS_CONTENT_ENABLED_KEY] ?: true + } + + val waterWaveAnimatedFlow: Flow<Boolean> = context.backgroundDataStore.data.map { + it[WATER_WAVE_ANIMATED_KEY] ?: false + } + + /** 玻璃配置聚合流:半径 = frost_intensity × 32dp,三档共用同一换算点。 */ + val glassStateFlow: Flow<GlassSettings> = + combine( + combine( + glassEnabledFlow, + glassModeFlow, + glassSidebarEnabledFlow, + glassInputEnabledFlow, + glassContentEnabledFlow, + ) { enabled, mode, sidebar, input, content -> + GlassPrefs(enabled, mode, sidebar, input, content) + }, + combine(frostIntensityFlow, waterWaveAnimatedFlow, ::Pair), + ) { core, (frost, animated) -> + GlassSettings( + enabled = core.enabled, + mode = core.mode, + sidebarEnabled = core.sidebar, + inputEnabled = core.input, + contentEnabled = core.content, + radiusDp = frost * GlassSettings.MAX_RADIUS_DP, + waterWaveAnimated = animated, + ) + } + /** * 选择新背景图:把 [uri] 拷贝到私有目录 backgrounds/ 后替换旧图。 * 先写新文件成功再更新 DataStore,失败时旧背景保持不变。 @@ -87,6 +179,46 @@ class BackgroundSettingsRepository @Inject constructor( } } + suspend fun setFrostIntensity(intensity: Float) { + context.backgroundDataStore.edit { + it[FROST_INTENSITY_KEY] = + intensity.coerceIn(MIN_FROST_INTENSITY, MAX_FROST_INTENSITY) + } + } + + suspend fun setGlassEnabled(enabled: Boolean) { + context.backgroundDataStore.edit { it[GLASS_ENABLED_KEY] = enabled } + } + + suspend fun setGlassMode(mode: GlassMode) { + context.backgroundDataStore.edit { it[GLASS_MODE_KEY] = glassModeToRaw(mode) } + } + + suspend fun setGlassPanelAreaEnabled( + area: GlassPanelArea, + enabled: Boolean, + ) { + context.backgroundDataStore.edit { + when (area) { + GlassPanelArea.SIDEBAR -> it[GLASS_SIDEBAR_ENABLED_KEY] = enabled + GlassPanelArea.INPUT -> it[GLASS_INPUT_ENABLED_KEY] = enabled + GlassPanelArea.CONTENT -> it[GLASS_CONTENT_ENABLED_KEY] = enabled + } + } + } + + suspend fun setWaterWaveAnimated(animated: Boolean) { + context.backgroundDataStore.edit { it[WATER_WAVE_ANIMATED_KEY] = animated } + } + + private data class GlassPrefs( + val enabled: Boolean, + val mode: GlassMode, + val sidebar: Boolean, + val input: Boolean, + val content: Boolean, + ) + private fun extensionFor(uri: Uri): String = when (context.contentResolver.getType(uri)) { "image/png" -> "png" "image/webp" -> "webp" diff --git a/app/src/main/java/com/aicode/feature/settings/data/repository/TargetModeSettingsRepository.kt b/app/src/main/java/com/aicode/feature/settings/data/repository/TargetModeSettingsRepository.kt new file mode 100644 index 00000000..38836bf4 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/data/repository/TargetModeSettingsRepository.kt @@ -0,0 +1,51 @@ +package com.aicode.feature.settings.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.targetModeDataStore by preferencesDataStore(name = "target_mode_prefs") + +/** + * TARGET 目标驱动模式的阈值配置。默认最大步数 50、连续失败 5。 + * 由 [com.aicode.feature.agent.domain.workflow.StatefulAgentWorkflow] 在工具循环中读取 + * 当前阈值用于终止判定(步数超限 / 连续失败)。 + */ +@Singleton +class TargetModeSettingsRepository @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private companion object { + val MAX_STEP_BUDGET = intPreferencesKey("max_step_budget") + val MAX_CONSECUTIVE_FAILURES = intPreferencesKey("max_consecutive_failures") + const val DEFAULT_MAX_STEP_BUDGET = 50 + const val DEFAULT_MAX_CONSECUTIVE_FAILURES = 5 + } + + data class Thresholds( + val maxStepBudget: Int = DEFAULT_MAX_STEP_BUDGET, + val maxConsecutiveFailures: Int = DEFAULT_MAX_CONSECUTIVE_FAILURES + ) + + /** 当前阈值流;未设置时回退到默认值(50 / 5)。 */ + val thresholds: Flow<Thresholds> = context.targetModeDataStore.data.map { prefs -> + Thresholds( + maxStepBudget = prefs[MAX_STEP_BUDGET] ?: DEFAULT_MAX_STEP_BUDGET, + maxConsecutiveFailures = prefs[MAX_CONSECUTIVE_FAILURES] ?: DEFAULT_MAX_CONSECUTIVE_FAILURES + ) + } + + suspend fun setMaxStepBudget(value: Int) { + context.targetModeDataStore.edit { it[MAX_STEP_BUDGET] = value.coerceAtLeast(1) } + } + + suspend fun setMaxConsecutiveFailures(value: Int) { + context.targetModeDataStore.edit { it[MAX_CONSECUTIVE_FAILURES] = value.coerceAtLeast(1) } + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt index f485640f..2b70ad8c 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt @@ -5,6 +5,9 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aicode.core.net.AppProxy +import com.aicode.core.ui.glass.GlassMode +import com.aicode.core.ui.glass.GlassPanelArea +import com.aicode.core.ui.glass.GlassSettings import com.aicode.core.util.FileLogger import com.aicode.core.util.LogLevel import com.aicode.feature.agent.data.local.dao.LlmCallRecordDao @@ -58,6 +61,7 @@ import com.aicode.feature.settings.data.repository.ExecutionModeHolder import com.aicode.feature.settings.data.repository.ExecutionModeRepository import com.aicode.feature.settings.data.repository.AgentSoundSettingsRepository import com.aicode.feature.settings.data.repository.KeepaliveSettingsRepository +import com.aicode.feature.settings.data.repository.TargetModeSettingsRepository import com.aicode.feature.settings.data.repository.LanguageSettingsRepository import com.aicode.feature.settings.data.repository.LogSettingsRepository import com.aicode.feature.settings.data.repository.ProxyConfig @@ -94,8 +98,13 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import javax.inject.Inject @@ -106,6 +115,15 @@ sealed class FetchState { data class Error(val message: String, val debugInfo: ModelTestResult? = null) : FetchState() } +/** 批量模型连通性测试进度:限并发 4,running=true 时可调 stopAllModels 取消。 */ +data class BatchTestState( + val providerId: String = "", + val total: Int = 0, + val done: Int = 0, + val success: Int = 0, + val running: Boolean = false, +) + /** 镜像下载页的完整 UI 状态:空闲 / 下载中(进度)/ 下载完成(可安装)/ 失败。 */ sealed interface ContainerImageDownloadUiState { data object Idle : ContainerImageDownloadUiState @@ -305,7 +323,8 @@ class SettingsViewModel @Inject constructor( private val updateCheckService: UpdateCheckService, private val providerBalanceRunner: ProviderBalanceRunner, private val terminalSettingsRepository: TerminalSettingsRepository, - private val proxySettingsRepository: ProxySettingsRepository + private val proxySettingsRepository: ProxySettingsRepository, + private val targetModeSettingsRepository: TargetModeSettingsRepository ) : ViewModel() { private companion object { const val MAX_LOG_LINES = 1200 @@ -434,6 +453,11 @@ class SettingsViewModel @Inject constructor( private val _keepaliveEnabled = MutableStateFlow(false) val keepaliveEnabled: StateFlow<Boolean> = _keepaliveEnabled.asStateFlow() + val targetModeThresholds: StateFlow<TargetModeSettingsRepository.Thresholds> = + targetModeSettingsRepository.thresholds.stateIn( + viewModelScope, SharingStarted.Eagerly, TargetModeSettingsRepository.Thresholds() + ) + private val _screenOnEnabled = MutableStateFlow(false) val screenOnEnabled: StateFlow<Boolean> = _screenOnEnabled.asStateFlow() @@ -457,8 +481,17 @@ class SettingsViewModel @Inject constructor( private val _backgroundAlpha = MutableStateFlow(BackgroundSettingsRepository.DEFAULT_ALPHA) val backgroundAlpha: StateFlow<Float> = _backgroundAlpha.asStateFlow() + private val _frostIntensity = + MutableStateFlow(BackgroundSettingsRepository.DEFAULT_FROST_INTENSITY) + val frostIntensity: StateFlow<Float> = _frostIntensity.asStateFlow() + + /** 玻璃材质全局配置聚合(含半径换算),供 glassPanel Modifier 与设置面板消费。 */ + private val _glassState = MutableStateFlow(GlassSettings.DISABLED) + val glassState: StateFlow<GlassSettings> = _glassState.asStateFlow() + /** 透明度落盘的节流 job,见 [setBackgroundAlpha]。 */ private var backgroundAlphaWriteJob: Job? = null + private var frostIntensityWriteJob: Job? = null /** 用户选择的应用语言 tag(null 表示跟随系统)。 */ private val _languageTag = MutableStateFlow<String?>(null) @@ -496,6 +529,11 @@ class SettingsViewModel @Inject constructor( private val _testing = MutableStateFlow<Set<String>>(emptySet()) val testing: StateFlow<Set<String>> = _testing.asStateFlow() + private val _batchTestState = MutableStateFlow(BatchTestState()) + val batchTestState: StateFlow<BatchTestState> = _batchTestState.asStateFlow() + + private var batchTestJob: Job? = null + private val _balanceTestState = MutableStateFlow<ProviderBalanceState>(ProviderBalanceState.Idle) val balanceTestState: StateFlow<ProviderBalanceState> = _balanceTestState.asStateFlow() @@ -726,6 +764,18 @@ class SettingsViewModel @Inject constructor( } } + launch { + backgroundSettingsRepository.frostIntensityFlow.collectLatest { + _frostIntensity.value = it + } + } + + launch { + backgroundSettingsRepository.glassStateFlow.collectLatest { + _glassState.value = it + } + } + launch { languageSettingsRepository.languageFlow.collectLatest { _languageTag.value = it @@ -1209,6 +1259,14 @@ class SettingsViewModel @Inject constructor( } } + fun setTargetMaxStepBudget(value: Int) { + viewModelScope.launch { targetModeSettingsRepository.setMaxStepBudget(value) } + } + + fun setTargetMaxConsecutiveFailures(value: Int) { + viewModelScope.launch { targetModeSettingsRepository.setMaxConsecutiveFailures(value) } + } + // 仅持久化标志位——窗口 FLAG_KEEP_SCREEN_ON 的增删由 MainActivity 监听 enabledFlow 统一完成。 fun setScreenOnEnabled(enabled: Boolean) { viewModelScope.launch { @@ -1261,13 +1319,49 @@ class SettingsViewModel @Inject constructor( * 回读又会把整个设置页带着重组。此处只保留最后一个值,停手约 80ms 后写一次。 */ fun setBackgroundAlpha(alpha: Float) { + val bounded = alpha.coerceIn( + BackgroundSettingsRepository.MIN_ALPHA, + BackgroundSettingsRepository.MAX_ALPHA + ) + _backgroundAlpha.value = bounded backgroundAlphaWriteJob?.cancel() backgroundAlphaWriteJob = viewModelScope.launch { delay(BACKGROUND_ALPHA_WRITE_DEBOUNCE_MS) - backgroundSettingsRepository.setBackgroundAlpha(alpha) + backgroundSettingsRepository.setBackgroundAlpha(bounded) + } + } + + fun setFrostIntensity(intensity: Float) { + val bounded = intensity.coerceIn( + BackgroundSettingsRepository.MIN_FROST_INTENSITY, + BackgroundSettingsRepository.MAX_FROST_INTENSITY + ) + _frostIntensity.value = bounded + frostIntensityWriteJob?.cancel() + frostIntensityWriteJob = viewModelScope.launch { + delay(BACKGROUND_ALPHA_WRITE_DEBOUNCE_MS) + backgroundSettingsRepository.setFrostIntensity(bounded) } } + fun setGlassEnabled(enabled: Boolean) { + viewModelScope.launch { backgroundSettingsRepository.setGlassEnabled(enabled) } + } + + fun setGlassMode(mode: GlassMode) { + viewModelScope.launch { backgroundSettingsRepository.setGlassMode(mode) } + } + + fun setGlassPanelAreaEnabled(area: GlassPanelArea, enabled: Boolean) { + viewModelScope.launch { + backgroundSettingsRepository.setGlassPanelAreaEnabled(area, enabled) + } + } + + fun setWaterWaveAnimated(animated: Boolean) { + viewModelScope.launch { backgroundSettingsRepository.setWaterWaveAnimated(animated) } + } + /** 设置应用语言;tag 为空字符串或 null 表示跟随系统。 */ fun setLanguage(tag: String?) { viewModelScope.launch { @@ -1778,6 +1872,59 @@ class SettingsViewModel @Inject constructor( _testing.value = emptySet() } + /** + * 一键测试[provider]模型列表中的全部模型连通性,限并发 4:复用单测的 + * [_testing]/[_testResults],逐条实时反映到每行模型;running 时调 [stopAllModels] 取消。 + */ + fun testAllModels(provider: AIProviderConfig) { + if (batchTestJob?.isActive == true) return + val models = provider.models + if (models.isEmpty()) return + batchTestJob = viewModelScope.launch { + _batchTestState.value = BatchTestState(provider.id, models.size, 0, 0, true) + val semaphore = Semaphore(4) + try { + coroutineScope { + models.map { model -> + async { + semaphore.withPermit { + _testing.update { it + model } + val result = modelApiService.testModel( + provider.baseUrl, + provider.firstUsableApiKey, + provider.type, + provider.useFullUrl, + provider.useResponseApi, + model, + provider.customHeaders, + ) + _testResults.update { it + (model to result) } + _testing.update { it - model } + _batchTestState.update { + it.copy( + done = it.done + 1, + success = it.success + if (result.success) 1 else 0, + ) + } + } + } + }.awaitAll() + } + } finally { + _batchTestState.update { it.copy(running = false) } + // 取消时正在跑的 OkHttp 阻塞调用不响应 cancel,清空残留 testing 标记兜底 + _testing.value = emptySet() + } + } + } + + fun stopAllModels() { + batchTestJob?.cancel() + batchTestJob = null + _testing.value = emptySet() + _batchTestState.update { it.copy(running = false) } + } + fun listAvailableBalanceScripts(): List<String> { return providerBalanceRunner.listAvailableScripts() } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/BackgroundImageSheet.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/BackgroundImageSheet.kt index 81013340..21db3bf0 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/BackgroundImageSheet.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/BackgroundImageSheet.kt @@ -2,6 +2,7 @@ package com.aicode.feature.settings.presentation.component import android.graphics.BitmapFactory import android.net.Uri +import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -40,6 +41,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.aicode.R import com.aicode.core.theme.Spacing +import com.aicode.core.ui.AppSwitch +import com.aicode.core.ui.SegmentedTabs +import com.aicode.core.ui.glass.GlassMode +import com.aicode.core.ui.glass.GlassPanelArea +import com.aicode.core.ui.glass.GlassSettings import com.aicode.feature.settings.data.repository.BackgroundSettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -75,10 +81,17 @@ internal fun decodeBackgroundBitmap(path: String, maxWidth: Int, maxHeight: Int) internal fun BackgroundImageSheet( imagePath: String?, alpha: Float, + frostIntensity: Float, onPickImage: (Uri) -> Unit, onAlphaChange: (Float) -> Unit, + onFrostIntensityChange: (Float) -> Unit, onRemove: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + glassState: GlassSettings = GlassSettings.DISABLED, + onGlassEnabledChange: (Boolean) -> Unit = {}, + onGlassModeChange: (GlassMode) -> Unit = {}, + onGlassAreaEnabledChange: (GlassPanelArea, Boolean) -> Unit = { _, _ -> }, + onWaterWaveAnimatedChange: (Boolean) -> Unit = {}, ) { val sheetState = rememberModalBottomSheetState() val context = LocalContext.current @@ -98,6 +111,9 @@ internal fun BackgroundImageSheet( // 滑块位置由本地状态驱动:直接绑外部 alpha 会让每次拖动都走「写 DataStore → 回读 → 整个设置页重组」 // 的往返,滑块因此跟不上手指。弹窗每次打开都重建,初值取当前设置即可。 var sliderPercent by remember { mutableFloatStateOf(BackgroundSettingsRepository.alphaToSlider(alpha)) } + var frostPercent by remember { + mutableFloatStateOf(BackgroundSettingsRepository.frostToSlider(frostIntensity)) + } ModalBottomSheet( onDismissRequest = onDismiss, @@ -156,6 +172,119 @@ internal fun BackgroundImageSheet( valueRange = 0f..100f ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.settings_background_frost_intensity), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = "${frostPercent.toInt()}%", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + Slider( + value = frostPercent, + onValueChange = { percent -> + frostPercent = percent + onFrostIntensityChange(BackgroundSettingsRepository.sliderToFrost(percent)) + }, + valueRange = 0f..100f + ) + + Spacer(Modifier.height(Spacing.lg)) + Text( + text = stringResource(R.string.settings_glass_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = Spacing.md) + ) + val glassSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + if (!glassSupported) { + Text( + text = stringResource(R.string.settings_glass_unsupported), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.settings_glass_enabled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + AppSwitch( + checked = glassState.enabled, + onCheckedChange = onGlassEnabledChange + ) + } + if (glassState.enabled) { + Spacer(Modifier.height(Spacing.md)) + Text( + text = stringResource(R.string.settings_glass_mode), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = Spacing.xs) + ) + SegmentedTabs( + selected = glassState.mode.ordinal, + labels = listOf( + stringResource(R.string.settings_glass_mode_frosted), + stringResource(R.string.settings_glass_mode_water), + stringResource(R.string.settings_glass_mode_liquid), + ), + onSelect = { idx -> + onGlassModeChange(GlassMode.entries[idx]) + } + ) + Spacer(Modifier.height(Spacing.md)) + GlassAreaToggle( + label = R.string.settings_glass_area_sidebar, + checked = glassState.sidebarEnabled, + onCheckedChange = { onGlassAreaEnabledChange(GlassPanelArea.SIDEBAR, it) } + ) + GlassAreaToggle( + label = R.string.settings_glass_area_input, + checked = glassState.inputEnabled, + onCheckedChange = { onGlassAreaEnabledChange(GlassPanelArea.INPUT, it) } + ) + GlassAreaToggle( + label = R.string.settings_glass_area_content, + checked = glassState.contentEnabled, + onCheckedChange = { onGlassAreaEnabledChange(GlassPanelArea.CONTENT, it) } + ) + if (glassState.mode == GlassMode.WATER) { + Spacer(Modifier.height(Spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.settings_glass_water_animated), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + AppSwitch( + checked = glassState.waterWaveAnimated, + onCheckedChange = onWaterWaveAnimatedChange + ) + } + } + } + } + Spacer(Modifier.height(Spacing.lg)) + Row( modifier = Modifier .fillMaxWidth() @@ -186,3 +315,24 @@ internal fun BackgroundImageSheet( } } } + +/** 玻璃区域开关行:标签 + AppSwitch。 */ +@Composable +private fun GlassAreaToggle( + @androidx.annotation.StringRes label: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Text( + text = stringResource(label), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + AppSwitch(checked = checked, onCheckedChange = onCheckedChange) + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt index ab5a8b7e..89cd58c7 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt @@ -152,6 +152,8 @@ import compose.icons.feathericons.Slash import compose.icons.feathericons.Sliders import compose.icons.feathericons.Trash2 import compose.icons.feathericons.X +import compose.icons.feathericons.Square +import compose.icons.feathericons.Zap import com.aicode.feature.agent.presentation.component.AdaptiveCardView import com.aicode.feature.settings.domain.model.ProviderBalanceResult import com.aicode.feature.settings.domain.model.ProviderBalanceState @@ -254,6 +256,7 @@ fun ProviderEditorScreen( val fetchState by viewModel.fetchState.collectAsStateWithLifecycle() val testResults by viewModel.testResults.collectAsStateWithLifecycle() val testing by viewModel.testing.collectAsStateWithLifecycle() + val batchTestState by viewModel.batchTestState.collectAsStateWithLifecycle() val proxyTestState by viewModel.proxyTestState.collectAsStateWithLifecycle() val balanceTestState by viewModel.balanceTestState.collectAsStateWithLifecycle() val modelMetadata by viewModel.modelMetadata.collectAsStateWithLifecycle() @@ -719,6 +722,45 @@ fun ProviderEditorScreen( Spacer(Modifier.width(Spacing.xs)) Text(stringResource(R.string.provider_fetch_models)) } + if (batchTestState.running) { + TextButton( + onClick = { viewModel.stopAllModels() }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon(FeatherIcons.Square, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(Spacing.xs)) + Text(stringResource(R.string.provider_test_stop)) + } + } else { + TextButton( + onClick = { viewModel.testAllModels(currentConfig()) }, + enabled = models.isNotEmpty(), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon(FeatherIcons.Zap, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(Spacing.xs)) + Text(stringResource(R.string.provider_test_all)) + } + } + } + if (batchTestState.running) { + Text( + text = stringResource( + R.string.provider_test_batch_summary, + batchTestState.done, + batchTestState.total, + batchTestState.success + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(start = Spacing.md, end = Spacing.md, bottom = Spacing.xs) + ) } if (models.isEmpty()) { Box( diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index e311e8f0..c28756ad 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -105,6 +105,7 @@ import compose.icons.feathericons.Save import compose.icons.feathericons.Server import compose.icons.feathericons.Shield import compose.icons.feathericons.Terminal +import compose.icons.feathericons.Target import compose.icons.feathericons.Trash2 import compose.icons.feathericons.Users import compose.icons.feathericons.Zap @@ -149,6 +150,7 @@ internal enum class SettingsSection(@param:StringRes val titleRes: Int) { RemoteServers(R.string.settings_remote_servers), Storage(R.string.settings_storage), TokenStats(R.string.settings_token_stats_title), + TargetMode(R.string.settings_target_mode_title), Backup(R.string.settings_backup), About(R.string.settings_about) } @@ -196,6 +198,7 @@ fun SettingsScreen( val projectRules by viewModel.projectRules.collectAsStateWithLifecycle() val currentProjectName by viewModel.currentProjectName.collectAsStateWithLifecycle() val keepaliveEnabled by viewModel.keepaliveEnabled.collectAsStateWithLifecycle() + val targetModeThresholds by viewModel.targetModeThresholds.collectAsStateWithLifecycle() val screenOnEnabled by viewModel.screenOnEnabled.collectAsStateWithLifecycle() val agentSoundEnabled by viewModel.agentSoundEnabled.collectAsStateWithLifecycle() val themeMode by viewModel.themeMode.collectAsStateWithLifecycle() @@ -203,6 +206,8 @@ fun SettingsScreen( val dynamicColorEnabled by viewModel.dynamicColorEnabled.collectAsStateWithLifecycle() val backgroundImagePath by viewModel.backgroundImagePath.collectAsStateWithLifecycle() val backgroundAlpha by viewModel.backgroundAlpha.collectAsStateWithLifecycle() + val frostIntensity by viewModel.frostIntensity.collectAsStateWithLifecycle() + val glassState by viewModel.glassState.collectAsStateWithLifecycle() val languageTag by viewModel.languageTag.collectAsStateWithLifecycle() val visionProviderId by viewModel.visionProviderId.collectAsStateWithLifecycle() val visionModel by viewModel.visionModel.collectAsStateWithLifecycle() @@ -860,6 +865,11 @@ fun SettingsScreen( onSelectModelPage = { viewModel.setModelStatsPage(it) } ) SettingsSection.Storage -> storageViewModel?.let { StorageSectionHost(viewModel = it) } + SettingsSection.TargetMode -> TargetModeSection( + thresholds = targetModeThresholds, + onSetMaxStepBudget = { viewModel.setTargetMaxStepBudget(it) }, + onSetMaxConsecutiveFailures = { viewModel.setTargetMaxConsecutiveFailures(it) } + ) SettingsSection.ProviderEditor -> {} // 已在上方 early return 处理 SettingsSection.SkillEditor -> {} // 已在上方 early return 处理 SettingsSection.SubAgentEditor -> {} // 已在上方 early return 处理 @@ -947,10 +957,17 @@ fun SettingsScreen( BackgroundImageSheet( imagePath = backgroundImagePath, alpha = backgroundAlpha, + frostIntensity = frostIntensity, onPickImage = { viewModel.setBackgroundImage(it) }, onAlphaChange = { viewModel.setBackgroundAlpha(it) }, + onFrostIntensityChange = { viewModel.setFrostIntensity(it) }, onRemove = { viewModel.clearBackgroundImage() }, - onDismiss = { showBackgroundSheet = false } + onDismiss = { showBackgroundSheet = false }, + glassState = glassState, + onGlassEnabledChange = { viewModel.setGlassEnabled(it) }, + onGlassModeChange = { viewModel.setGlassMode(it) }, + onGlassAreaEnabledChange = { area, enabled -> viewModel.setGlassPanelAreaEnabled(area, enabled) }, + onWaterWaveAnimatedChange = { viewModel.setWaterWaveAnimated(it) }, ) } @@ -1287,6 +1304,12 @@ internal fun SettingsMenu( onClick = { onOpen(SettingsSection.Storage) } ) SettingsDivider() + SettingsRow( + icon = FeatherIcons.Target, + title = stringResource(SettingsSection.TargetMode.titleRes), + onClick = { onOpen(SettingsSection.TargetMode) } + ) + SettingsDivider() SettingsRow( icon = FeatherIcons.Save, title = stringResource(SettingsSection.Backup.titleRes), diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/TargetModeSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/TargetModeSection.kt new file mode 100644 index 00000000..3f343774 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/TargetModeSection.kt @@ -0,0 +1,99 @@ +package com.aicode.feature.settings.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.aicode.R +import com.aicode.core.theme.Spacing +import com.aicode.feature.settings.data.repository.TargetModeSettingsRepository + +/** + * 目标驱动模式设置:调整 TARGET 模式的最大步数上限与连续失败阈值。 + * 阈值经 TargetModeSettingsRepository 持久化,StatefulAgentWorkflow 工具循环读取用于终止判定。 + */ +@Composable +internal fun TargetModeSection( + thresholds: TargetModeSettingsRepository.Thresholds, + onSetMaxStepBudget: (Int) -> Unit, + onSetMaxConsecutiveFailures: (Int) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md) + ) { + Text( + text = stringResource(R.string.mode_target_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + SettingsGroup { + IntEditRow( + title = stringResource(R.string.settings_target_max_step_budget), + value = thresholds.maxStepBudget, + onCommit = onSetMaxStepBudget + ) + SettingsDivider() + IntEditRow( + title = stringResource(R.string.settings_target_max_consecutive_failures), + value = thresholds.maxConsecutiveFailures, + onCommit = onSetMaxConsecutiveFailures + ) + } + } +} + +@Composable +private fun IntEditRow( + title: String, + value: Int, + onCommit: (Int) -> Unit +) { + var text by remember(value) { mutableStateOf(value.toString()) } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(Spacing.sm)) + OutlinedTextField( + value = text, + onValueChange = { input -> text = input.filter { c -> c.isDigit() }.take(4) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(96.dp), + textStyle = MaterialTheme.typography.bodyLarge + ) + TextButton(onClick = { text.toIntOrNull()?.let { onCommit(it.coerceAtLeast(1)) } }) { + Text(stringResource(R.string.common_save)) + } + } +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index ca60c61e..a8c475a4 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -135,6 +135,19 @@ <string name="settings_background_image">Background image</string> <string name="settings_background_image_none">None</string> <string name="settings_background_image_alpha">Opacity</string> + <string name="settings_background_frost_intensity">Frost intensity</string> + <string name="settings_glass_title">Glass material</string> + <string name="settings_glass_enabled">Enable glass material</string> + <string name="settings_glass_mode">Material tier</string> + <string name="settings_glass_mode_frosted">Frosted</string> + <string name="settings_glass_mode_water">Water glass</string> + <string name="settings_glass_mode_liquid">Liquid glass</string> + <string name="settings_glass_radius">Glass blur radius</string> + <string name="settings_glass_area_sidebar">Sidebar</string> + <string name="settings_glass_area_input">Input bar</string> + <string name="settings_glass_area_content">Content panel</string> + <string name="settings_glass_water_animated">Water wave animation</string> + <string name="settings_glass_unsupported">Glass material requires Android 13+ device</string> <string name="settings_background_image_pick">Pick image</string> <string name="settings_background_image_remove">Remove background</string> <string name="provider_cache_anthropic_title">Anthropic cache breakpoints</string> @@ -201,6 +214,11 @@ <string name="agent_complete_notification_body">Tap to view</string> <string name="agent_complete_notification_background_body">Background task "%1$s" finished</string> <string name="agent_complete_notification_background_multi_body">%1$d background tasks finished</string> + <string name="notification_permission_title">Awaiting tool permission</string> + <string name="notification_permission_approve">Allow once</string> + <string name="notification_permission_deny">Deny</string> + <string name="notification_plan_title">Awaiting plan approval</string> + <string name="notification_plan_approve">Approve and build</string> <string name="notification_channel_terminal_service">Terminal Services</string> <string name="notification_channel_terminal_service_desc">Notifications for background terminal tasks</string> <string name="notification_channel_agent_complete">Agent completion notifications</string> @@ -240,6 +258,8 @@ <string name="common_paste">Paste</string> <string name="common_allow">Allow</string> <string name="common_close">Close</string> + <string name="common_search">Search</string> + <string name="common_loading">Loading…</string> <string name="workbench_split_handle">Drag to resize panes</string> <string name="common_other">Other</string> <string name="common_switch">Switch</string> @@ -313,6 +333,7 @@ <string name="chat_new_session_btn">New Chat</string> <string name="chat_open_sidebar">Open sidebar</string> <string name="chat_add_attachment">Add attachment</string> + <string name="chat_optimize_input">Optimize expression</string> <string name="chat_take_photo">Take photo</string> <string name="chat_reasoning_effort">Reasoning effort</string> <string name="chat_reasoning_effort_none">None</string> @@ -343,6 +364,9 @@ <string name="agent_tool_executing">%1$s running…</string> <string name="agent_stop_refusal">The model declined to answer under its content safety policy.</string> <string name="agent_stop_context_exceeded">This request exceeds the model\'s context window. Compact the context or start a new session.</string> <string name="agent_context_no_compaction">No compaction needed (too few messages)</string> + <string name="command_rewind_no_target">No rewindable messages in this session yet</string> + <string name="command_setup_opencode_no_provider">No model provider configured yet. Add one in Settings before running /setup-opencode</string> + <string name="command_setup_opencode_export_failed">Failed to export model config. Check the logs and retry</string> <string name="agent_compaction_failed">Compaction failed: %1$s</string> <string name="agent_model_not_selected">Not selected</string> <!-- About --> @@ -429,6 +453,13 @@ <!-- Chat Drawer --> <string name="chat_history">History</string> <string name="chat_no_sessions_hint">No sessions yet</string> + <string name="chat_session_search_hint">Search session titles</string> + <string name="chat_session_search_no_match">No matching sessions</string> + <string name="chat_search_messages_hint">Search message content</string> + <string name="chat_search_no_results">No matching messages</string> + <string name="chat_search_role_user">Me</string> + <string name="chat_search_role_ai">AI</string> + <string name="chat_search_role_tool">Tool</string> <string name="session_group_today">Today</string> <string name="session_group_yesterday">Yesterday</string> <string name="session_group_last_7_days">Last 7 Days</string> @@ -988,6 +1019,9 @@ <string name="provider_no_matching_models">No matching models</string> <string name="provider_please_wait">Please wait…</string> <string name="provider_test">Test</string> + <string name="provider_test_all">Test all</string> + <string name="provider_test_stop">Stop</string> + <string name="provider_test_batch_summary">Tested %1$d/%2$d · %3$d ok</string> <!-- Provider Logo --> <string name="provider_brand_doubao">Doubao</string> @@ -1350,4 +1384,13 @@ <string name="onboarding_demo_model_anthropic_desc">Anthropic · Recommended for coding</string> <string name="onboarding_demo_model_choose_desc">Current pick · Capable of code generation and analysis</string> <string name="onboarding_simulated_badge">Preview</string> -</resources> \ No newline at end of file + + <!-- TARGET goal-driven mode --> + <string name="mode_target_desc">AI autonomously works toward a goal until achieved or failed</string> + <string name="mode_target_input_hint">TARGET mode: this message will be set as the goal and execution starts</string> + <string name="settings_target_mode_title">Target Mode</string> + <string name="settings_target_max_step_budget">Max step budget</string> + <string name="settings_target_max_consecutive_failures">Max consecutive failures</string> + <string name="mode_target_status_steps">Steps %1$d</string> + <string name="mode_target_status_fails">Consecutive failures %1$d</string> +</resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17838634..e533399e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,6 +135,19 @@ <string name="settings_background_image">背景图</string> <string name="settings_background_image_none">未设置</string> <string name="settings_background_image_alpha">透明度</string> + <string name="settings_background_frost_intensity">磨砂强度</string> + <string name="settings_glass_title">玻璃材质</string> + <string name="settings_glass_enabled">启用玻璃材质</string> + <string name="settings_glass_mode">材质档位</string> + <string name="settings_glass_mode_frosted">磨砂</string> + <string name="settings_glass_mode_water">水玻璃</string> + <string name="settings_glass_mode_liquid">液体玻璃</string> + <string name="settings_glass_radius">玻璃模糊半径</string> + <string name="settings_glass_area_sidebar">侧边栏</string> + <string name="settings_glass_area_input">输入框</string> + <string name="settings_glass_area_content">内容面板</string> + <string name="settings_glass_water_animated">水波流动动画</string> + <string name="settings_glass_unsupported">玻璃材质需要 Android 13+ 设备</string> <string name="settings_background_image_pick">选择图片</string> <string name="settings_background_image_remove">移除背景</string> <string name="provider_cache_anthropic_title">Anthropic 缓存断点</string> @@ -201,6 +214,11 @@ <string name="agent_complete_notification_body">点击返回查看</string> <string name="agent_complete_notification_background_body">后台任务「%1$s」已完成</string> <string name="agent_complete_notification_background_multi_body">%1$d 个后台任务已完成</string> + <string name="notification_permission_title">等待工具授权</string> + <string name="notification_permission_approve">允许一次</string> + <string name="notification_permission_deny">拒绝</string> + <string name="notification_plan_title">等待计划批准</string> + <string name="notification_plan_approve">批准执行</string> <string name="notification_channel_terminal_service">终端服务</string> <string name="notification_channel_terminal_service_desc">后台终端任务通知</string> <string name="notification_channel_agent_complete">Agent 完成通知</string> @@ -240,6 +258,8 @@ <string name="common_paste">粘贴</string> <string name="common_allow">允许</string> <string name="common_close">关闭</string> + <string name="common_search">搜索</string> + <string name="common_loading">加载中…</string> <string name="workbench_split_handle">拖动调整分栏宽度</string> <string name="common_other">其他</string> <string name="common_switch">切换</string> @@ -313,6 +333,7 @@ <string name="chat_new_session_btn">新会话</string> <string name="chat_open_sidebar">打开侧边栏</string> <string name="chat_add_attachment">添加附件</string> + <string name="chat_optimize_input">优化表达</string> <string name="chat_take_photo">拍照</string> <string name="chat_reasoning_effort">思考强度</string> <string name="chat_reasoning_effort_none">关闭</string> @@ -343,6 +364,9 @@ <string name="agent_tool_executing">%1$s 执行中…</string> <string name="agent_stop_refusal">模型基于内容安全策略拒绝了本次回答。</string> <string name="agent_stop_context_exceeded">本次输入超出模型上下文窗口,请先压缩上下文或新建会话。</string> <string name="agent_context_no_compaction">当前上下文无需压缩(消息过少)</string> + <string name="command_rewind_no_target">当前会话还没有可回退的消息</string> + <string name="command_setup_opencode_no_provider">尚未配置任何模型提供商,请先在设置中添加后再执行 /setup-opencode</string> + <string name="command_setup_opencode_export_failed">导出模型配置失败,请查看日志后重试</string> <string name="agent_compaction_failed">压缩失败: %1$s</string> <string name="agent_model_not_selected">未选择</string> <!-- About --> @@ -429,6 +453,13 @@ <!-- Chat Drawer --> <string name="chat_history">历史记录</string> <string name="chat_no_sessions_hint">暂无会话</string> + <string name="chat_session_search_hint">搜索会话标题</string> + <string name="chat_session_search_no_match">没有匹配的会话</string> + <string name="chat_search_messages_hint">搜索消息内容</string> + <string name="chat_search_no_results">没有匹配的消息</string> + <string name="chat_search_role_user">我</string> + <string name="chat_search_role_ai">AI</string> + <string name="chat_search_role_tool">工具</string> <string name="session_group_today">今天</string> <string name="session_group_yesterday">昨天</string> <string name="session_group_last_7_days">7天内</string> @@ -988,6 +1019,9 @@ <string name="provider_no_matching_models">没有匹配的模型</string> <string name="provider_please_wait">请稍候…</string> <string name="provider_test">测试</string> + <string name="provider_test_all">测试全部</string> + <string name="provider_test_stop">停止</string> + <string name="provider_test_batch_summary">已测 %1$d/共 %2$d · 成功 %3$d</string> <!-- Provider Logo --> <string name="provider_brand_doubao">豆包</string> @@ -1349,4 +1383,13 @@ <string name="onboarding_demo_model_anthropic_desc">Anthropic · 智能编程推荐模型</string> <string name="onboarding_demo_model_choose_desc">当前推荐 · 具备代码生成与分析能力</string> <string name="onboarding_simulated_badge">演示预览</string> + + <!-- TARGET 目标驱动模式 --> + <string name="mode_target_desc">AI 依据目标自主执行,达成或失败即终止</string> + <string name="mode_target_input_hint">TARGET 模式:本条消息将自动设为目标并开始执行</string> + <string name="settings_target_mode_title">目标驱动模式</string> + <string name="settings_target_max_step_budget">最大步数上限</string> + <string name="settings_target_max_consecutive_failures">连续失败阈值</string> + <string name="mode_target_status_steps">步数 %1$d</string> + <string name="mode_target_status_fails">连续失败 %1$d</string> </resources> diff --git a/app/src/test/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngineTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngineTest.kt index 68172ea1..40faeb0c 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngineTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngineTest.kt @@ -96,6 +96,46 @@ class ToolPermissionPolicyEngineTest { assertEquals(ToolPermissionPolicyEngine.Verdict.ALLOW, r.verdict) } + // ── TARGET 模式:与 AUTO 同口径放行,灾难性 rm 防护同样保留 ────── + + @Test + fun targetMode_allowsAnyTool() = runTest { + val e = engine() + val r = e.evaluate(tool(ToolCapability.WRITE_WORKSPACE), "writeFile", emptyMap(), AgentMode.TARGET) + assertEquals(ToolPermissionPolicyEngine.Verdict.ALLOW, r.verdict) + } + + @Test + fun targetMode_allowsBash() = runTest { + val e = engine() + val r = e.evaluate(tool(ToolCapability.EXECUTE_COMMANDS), "Bash", bash("ls -la"), AgentMode.TARGET) + assertEquals(ToolPermissionPolicyEngine.Verdict.ALLOW, r.verdict) + } + + @Test + fun targetMode_stillBlocksCatastrophicRm() = runTest { + val e = engine() + val r = e.evaluate(tool(ToolCapability.EXECUTE_COMMANDS), "Bash", bash("rm -rf /"), AgentMode.TARGET) + assertEquals(ToolPermissionPolicyEngine.Verdict.DENY, r.verdict) + assertEquals("安全防护:禁止执行高危删除操作(根目录删除)", r.denyReason) + } + + @Test + fun targetMode_stillBlocksWorkspaceRm() = runTest { + val e = engine() + val r = e.evaluate(tool(ToolCapability.EXECUTE_COMMANDS), "Bash", bash("rm -rf ~/workspace/*"), AgentMode.TARGET) + assertEquals(ToolPermissionPolicyEngine.Verdict.DENY, r.verdict) + } + + @Test + fun targetMode_catastrophicRmDeniedEvenWithAllowRule() = runTest { + val e = engine( + PermissionRule("Bash", "rm -rf", PermissionDecision.ALLOW) + ) + val r = e.evaluate(tool(ToolCapability.EXECUTE_COMMANDS), "Bash", bash("rm -rf /"), AgentMode.TARGET) + assertEquals(ToolPermissionPolicyEngine.Verdict.DENY, r.verdict) + } + @Test fun autoMode_stillBlocksCatastrophicRm() = runTest { val e = engine() diff --git a/app/src/test/java/com/aicode/feature/agent/domain/tool/mode/CompleteGoalToolTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/tool/mode/CompleteGoalToolTest.kt new file mode 100644 index 00000000..17a1f5db --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/tool/mode/CompleteGoalToolTest.kt @@ -0,0 +1,126 @@ +package com.aicode.feature.agent.domain.tool.mode + +import com.aicode.feature.agent.data.local.dao.ChatSessionDao +import com.aicode.feature.agent.data.local.entity.ChatSessionEntity +import com.aicode.feature.agent.domain.model.AgentContext +import com.aicode.feature.agent.domain.model.AgentMode +import com.aicode.feature.agent.domain.model.GoalTerminationReason +import com.aicode.feature.agent.domain.tool.ToolResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [CompleteGoalTool] 的分支覆盖:仅 TARGET 模式可用、summary 必需非空、 + * 成功时把会话切回 BUILD 且终止原因记 ACHIEVED。 + */ +class CompleteGoalToolTest { + + private val dao = mockk<ChatSessionDao>(relaxed = true) + private val tool = CompleteGoalTool(dao) + + private fun context(mode: AgentMode) = AgentContext( + currentFile = null, + selectedCode = null, + projectRoot = "/workspace", + language = null, + sessionId = "s1", + mode = mode + ) + + private fun session(mode: String = AgentMode.TARGET.name) = ChatSessionEntity( + id = "s1", + title = "t", + createdAt = 0, + updatedAt = 0, + mode = mode, + goalStatement = "完成单测" + ) + + @Test + fun notInTargetMode_returnsError() = runTest { + val r = tool.executeWithContext( + mapOf("summary" to JsonPrimitive("done")), + context(AgentMode.BUILD) + ) + assertTrue(r is ToolResult.Error) + assertEquals("NOT_IN_TARGET_MODE", (r as ToolResult.Error).code) + } + + @Test + fun missingSummary_returnsError() = runTest { + val r = tool.executeWithContext(emptyMap(), context(AgentMode.TARGET)) + assertTrue(r is ToolResult.Error) + assertEquals("MISSING_SUMMARY", (r as ToolResult.Error).code) + } + + @Test + fun emptySummary_returnsError() = runTest { + val r = tool.executeWithContext( + mapOf("summary" to JsonPrimitive(" ")), + context(AgentMode.TARGET) + ) + assertTrue(r is ToolResult.Error) + assertEquals("EMPTY_SUMMARY", (r as ToolResult.Error).code) + } + + @Test + fun noSession_returnsError() = runTest { + val noSession = context(AgentMode.TARGET).let { it.copy(sessionId = null) } + val r = tool.executeWithContext( + mapOf("summary" to JsonPrimitive("done")), + noSession + ) + assertTrue(r is ToolResult.Error) + assertEquals("NO_SESSION", (r as ToolResult.Error).code) + } + + @Test + fun sessionNotFound_returnsError() = runTest { + coEvery { dao.getById("s1") } returns null + val r = tool.executeWithContext( + mapOf("summary" to JsonPrimitive("done")), + context(AgentMode.TARGET) + ) + assertTrue(r is ToolResult.Error) + assertEquals("SESSION_NOT_FOUND", (r as ToolResult.Error).code) + } + + @Test + fun success_updatesSessionToBuildWithAchieved() = runTest { + coEvery { dao.getById("s1") } returns session() + val upserted = slot<ChatSessionEntity>() + coEvery { dao.upsert(capture(upserted)) } just runs + + val r = tool.executeWithContext( + mapOf("summary" to JsonPrimitive("全部用例通过")), + context(AgentMode.TARGET) + ) + + assertTrue(r is ToolResult.Success) + assertEquals(AgentMode.BUILD.name, upserted.captured.mode) + assertEquals(GoalTerminationReason.ACHIEVED.name, upserted.captured.goalTerminationReason) + coVerify(exactly = 1) { dao.upsert(any()) } + } + + @Test + fun buildPermissionRequest_carriesSummaryInDetails() { + val request = tool.buildPermissionRequest( + callId = "c1", + args = mapOf("summary" to JsonPrimitive("目标达成总结")), + argsPreview = "preview" + ) + assertEquals("completeGoal", request.toolName) + assertEquals("c1", request.id) + assertTrue(request.details.contains("目标达成总结")) + assertTrue(request.rememberablePatterns.isEmpty()) + } +} diff --git a/app/src/test/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeToolTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeToolTest.kt new file mode 100644 index 00000000..a6eecf5a --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeToolTest.kt @@ -0,0 +1,139 @@ +package com.aicode.feature.agent.domain.tool.mode + +import com.aicode.feature.agent.data.local.dao.ChatSessionDao +import com.aicode.feature.agent.data.local.entity.ChatSessionEntity +import com.aicode.feature.agent.domain.model.AgentContext +import com.aicode.feature.agent.domain.model.AgentMode +import com.aicode.feature.agent.domain.model.GoalTerminationReason +import com.aicode.feature.agent.domain.tool.ToolResult +import io.mockk.coEvery +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [SwitchModeTool] 的 TARGET 相关分支:切 TARGET 必须带 goal、AUTO 只能切 PLAN 退出、 + * 进入 TARGET 重置步数/失败计数、从 TARGET 切出记录 INTERRUPTED。 + */ +class SwitchModeToolTest { + + private val dao = mockk<ChatSessionDao>(relaxed = true) + private val tool = SwitchModeTool(dao) + + private fun context(mode: AgentMode) = AgentContext( + currentFile = null, + selectedCode = null, + projectRoot = "/workspace", + language = null, + sessionId = "s1", + mode = mode + ) + + private fun session(mode: String) = ChatSessionEntity( + id = "s1", + title = "t", + createdAt = 0, + updatedAt = 0, + mode = mode, + goalStatement = "旧目标", + goalStepCount = 12, + goalFailCount = 3 + ) + + private fun args( + mode: String, + reason: String = "r", + goal: String? = null + ): Map<String, JsonElement> = buildMap { + put("mode", JsonPrimitive(mode)) + put("reason", JsonPrimitive(reason)) + if (goal != null) put("goal", JsonPrimitive(goal)) + } + + @Test + fun missingMode_returnsError() = runTest { + val r = tool.executeWithContext(mapOf("reason" to JsonPrimitive("r")), context(AgentMode.BUILD)) + assertTrue(r is ToolResult.Error) + assertEquals("MISSING_MODE", (r as ToolResult.Error).code) + } + + @Test + fun invalidMode_returnsError() = runTest { + val r = tool.executeWithContext(args("WHATEVER"), context(AgentMode.BUILD)) + assertTrue(r is ToolResult.Error) + assertEquals("INVALID_MODE", (r as ToolResult.Error).code) + } + + @Test + fun autoMode_isRejectedAsTarget() = runTest { + val r = tool.executeWithContext(args("AUTO"), context(AgentMode.BUILD)) + assertTrue(r is ToolResult.Error) + assertEquals("AUTO_MODE_MANUAL_ONLY", (r as ToolResult.Error).code) + } + + @Test + fun fromAuto_canOnlyExitToPlan() = runTest { + val r = tool.executeWithContext(args("BUILD"), context(AgentMode.AUTO)) + assertTrue(r is ToolResult.Error) + assertEquals("AUTO_EXIT_PLAN_ONLY", (r as ToolResult.Error).code) + } + + @Test + fun fromAuto_toPlan_succeeds() = runTest { + coEvery { dao.getById("s1") } returns session(AgentMode.AUTO.name) + val r = tool.executeWithContext(args("PLAN"), context(AgentMode.AUTO)) + assertTrue(r is ToolResult.Success) + } + + @Test + fun toTarget_requiresGoal() = runTest { + val r = tool.executeWithContext(args("TARGET", goal = null), context(AgentMode.BUILD)) + assertTrue(r is ToolResult.Error) + assertEquals("MISSING_GOAL", (r as ToolResult.Error).code) + } + + @Test + fun sameMode_returnsSuccessWithoutDbWrite() = runTest { + val r = tool.executeWithContext(args("BUILD"), context(AgentMode.BUILD)) + assertTrue(r is ToolResult.Success) + io.mockk.coVerify(exactly = 0) { dao.upsert(any()) } + } + + @Test + fun success_toTarget_resetsCountersAndSavesGoal() = runTest { + coEvery { dao.getById("s1") } returns session(AgentMode.BUILD.name) + val upserted = slot<ChatSessionEntity>() + coEvery { dao.upsert(capture(upserted)) } just runs + + val r = tool.executeWithContext(args("TARGET", goal = "跑通全部单测"), context(AgentMode.BUILD)) + + assertTrue(r is ToolResult.Success) + assertEquals(AgentMode.TARGET.name, upserted.captured.mode) + assertEquals("跑通全部单测", upserted.captured.goalStatement) + assertEquals(0, upserted.captured.goalStepCount) + assertEquals(0, upserted.captured.goalFailCount) + } + + @Test + fun fromTarget_recordsInterrupted() = runTest { + coEvery { dao.getById("s1") } returns session(AgentMode.TARGET.name) + val upserted = slot<ChatSessionEntity>() + coEvery { dao.upsert(capture(upserted)) } just runs + + val r = tool.executeWithContext(args("BUILD"), context(AgentMode.TARGET)) + + assertTrue(r is ToolResult.Success) + assertEquals(AgentMode.BUILD.name, upserted.captured.mode) + assertEquals(GoalTerminationReason.INTERRUPTED.name, upserted.captured.goalTerminationReason) + // 中断切出不清零:步数/失败计数保留供检查点回滚参考 + assertEquals(12, upserted.captured.goalStepCount) + assertEquals(3, upserted.captured.goalFailCount) + } +} diff --git a/app/src/test/java/com/aicode/feature/agent/presentation/MessageSearchHelpersTest.kt b/app/src/test/java/com/aicode/feature/agent/presentation/MessageSearchHelpersTest.kt new file mode 100644 index 00000000..5bdeae22 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/presentation/MessageSearchHelpersTest.kt @@ -0,0 +1,78 @@ +package com.aicode.feature.agent.presentation + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 会话内搜索的两个纯函数([MessageSearchUtils]):LIKE 转义(`\`、`%`、`_` 字面量匹配, + * 配合 DAO 的 ESCAPE '\')与命中片段截取(前后各 40 字符、上限 120、换行折叠、无命中取开头)。 + */ +class MessageSearchHelpersTest { + + // ── escapeLike ─────────────────────────────────────────────────── + + @Test + fun escapeLike_escapesBackslash() { + assertEquals("\\\\", MessageSearchUtils.escapeLike("\\")) + } + + @Test + fun escapeLike_escapesPercent() { + assertEquals("\\%", MessageSearchUtils.escapeLike("%")) + } + + @Test + fun escapeLike_escapesUnderscore() { + assertEquals("\\_", MessageSearchUtils.escapeLike("_")) + } + + @Test + fun escapeLike_combinesAllInOrder() { + // 先转义反斜杠,再转义 % 与 _,避免二次转义 + assertEquals("a\\\\b\\%c\\_d", MessageSearchUtils.escapeLike("a\\b%c_d")) + } + + @Test + fun escapeLike_plainTextUnchanged() { + assertEquals("hello 世界 123", MessageSearchUtils.escapeLike("hello 世界 123")) + } + + // ── buildSearchSnippet ────────────────────────────────────────── + + @Test + fun snippet_hitAtStart_noPrefixEllipsis() { + val content = "目标在开头" + "x".repeat(100) + val s = MessageSearchUtils.buildSearchSnippet(content, "目标") + assertEquals(true, s.startsWith("目标")) + assertEquals(false, s.startsWith("…")) + } + + @Test + fun snippet_hitInMiddle_wrapsBothSides() { + val content = "a".repeat(100) + "命中" + "b".repeat(100) + val s = MessageSearchUtils.buildSearchSnippet(content, "命中") + assertEquals(true, s.startsWith("…")) + assertEquals(true, s.endsWith("…")) + assertEquals(true, s.contains("命中")) + } + + @Test + fun snippet_caseInsensitiveHit() { + val s = MessageSearchUtils.buildSearchSnippet("say Hello world", "hello") + assertEquals(true, s.contains("Hello")) + } + + @Test + fun snippet_noHit_takesHead() { + val content = "z".repeat(200) + val s = MessageSearchUtils.buildSearchSnippet(content, "不存在") + assertEquals(120, s.length) + } + + @Test + fun snippet_newlinesFoldedToSpaces() { + val s = MessageSearchUtils.buildSearchSnippet("第一行\n第二行 命中\n第三行", "命中") + assertEquals(true, s.contains("命中")) + assertEquals(false, s.contains("\n")) + } +} diff --git a/app/src/test/java/com/aicode/feature/settings/data/repository/BackgroundGlassSettingsRepositoryTest.kt b/app/src/test/java/com/aicode/feature/settings/data/repository/BackgroundGlassSettingsRepositoryTest.kt new file mode 100644 index 00000000..86746d0b --- /dev/null +++ b/app/src/test/java/com/aicode/feature/settings/data/repository/BackgroundGlassSettingsRepositoryTest.kt @@ -0,0 +1,118 @@ +package com.aicode.feature.settings.data.repository + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.aicode.core.ui.glass.GlassMode +import com.aicode.core.ui.glass.GlassPanelArea +import com.aicode.core.ui.glass.GlassSettings +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * [BackgroundSettingsRepository] 玻璃材质配置:模式容错解析、六键读写、 + * glassStateFlow 聚合与 frost_intensity × 32 半径换算。 + * DataStore 依赖 Android Context,用 Robolectric;Android PRoot 容器加载不了 + * Robolectric native 库,非标准 Linux 环境跳过。 + * 同一 JVM 内 DataStore 单例跨方法共享状态,写入类断言集中在单个方法内保证顺序。 + */ +@RunWith(AndroidJUnit4::class) +class BackgroundGlassSettingsRepositoryTest { + + private lateinit var repo: BackgroundSettingsRepository + + @Before + fun setUp() { + guardEnvironment() + repo = BackgroundSettingsRepository(ApplicationProvider.getApplicationContext<Context>()) + } + + @Test + fun parseGlassMode_fallsBackToFrostedOnNullOrUnknown() { + assertEquals(GlassMode.FROSTED, BackgroundSettingsRepository.parseGlassMode(null)) + assertEquals(GlassMode.FROSTED, BackgroundSettingsRepository.parseGlassMode("frosted")) + assertEquals(GlassMode.FROSTED, BackgroundSettingsRepository.parseGlassMode("watery")) + assertEquals(GlassMode.WATER, BackgroundSettingsRepository.parseGlassMode("water")) + assertEquals(GlassMode.LIQUID, BackgroundSettingsRepository.parseGlassMode("liquid")) + } + + @Test + fun glassModeToRaw_roundTripsThroughParse() { + GlassMode.entries.forEach { mode -> + assertEquals(mode, BackgroundSettingsRepository.parseGlassMode( + BackgroundSettingsRepository.glassModeToRaw(mode) + )) + } + } + + @Test + fun glassState_writeReadAndAggregateInOrder() = runTest { + // 重置到默认基准:总开关关、磨砂档、三区域开、动画关 + repo.setGlassEnabled(false) + repo.setGlassMode(GlassMode.FROSTED) + repo.setGlassPanelAreaEnabled(GlassPanelArea.SIDEBAR, true) + repo.setGlassPanelAreaEnabled(GlassPanelArea.INPUT, true) + repo.setGlassPanelAreaEnabled(GlassPanelArea.CONTENT, true) + repo.setWaterWaveAnimated(false) + repo.setFrostIntensity(BackgroundSettingsRepository.DEFAULT_FROST_INTENSITY) + + // 默认态聚合:关闭、磨砂、半径 = 0.5 × 32 + val defaults = repo.glassStateFlow.first() + assertEquals(false, defaults.enabled) + assertEquals(GlassMode.FROSTED, defaults.mode) + assertEquals(true, defaults.sidebarEnabled) + assertEquals(true, defaults.inputEnabled) + assertEquals(true, defaults.contentEnabled) + assertEquals( + BackgroundSettingsRepository.DEFAULT_FROST_INTENSITY * GlassSettings.MAX_RADIUS_DP, + defaults.radiusDp, + 1e-4f + ) + assertEquals(false, defaults.waterWaveAnimated) + + // 写入自定义配置后聚合立即生效 + repo.setGlassEnabled(true) + repo.setGlassMode(GlassMode.LIQUID) + repo.setGlassPanelAreaEnabled(GlassPanelArea.SIDEBAR, false) + repo.setWaterWaveAnimated(true) + repo.setFrostIntensity(0.25f) + val custom = repo.glassStateFlow.first() + assertEquals(true, custom.enabled) + assertEquals(GlassMode.LIQUID, custom.mode) + assertEquals(false, custom.sidebarEnabled) + assertEquals(true, custom.inputEnabled) + assertEquals(true, custom.contentEnabled) + assertEquals(0.25f * GlassSettings.MAX_RADIUS_DP, custom.radiusDp, 1e-4f) + assertEquals(true, custom.waterWaveAnimated) + + // 半径越界夹紧到 0..1(×32 后 0..32dp) + repo.setFrostIntensity(2f) + assertEquals( + GlassSettings.MAX_RADIUS_DP, + repo.glassStateFlow.first().radiusDp, + 1e-4f + ) + + // 恢复默认基准,避免污染同 JVM 内后续运行 + repo.setGlassEnabled(false) + repo.setGlassMode(GlassMode.FROSTED) + repo.setGlassPanelAreaEnabled(GlassPanelArea.SIDEBAR, true) + repo.setWaterWaveAnimated(false) + repo.setFrostIntensity(BackgroundSettingsRepository.DEFAULT_FROST_INTENSITY) + } + + private fun guardEnvironment() { + val androidContainer = File("/system").exists() || + System.getProperty("java.library.path")?.contains("/data/app") == true + assumeTrue( + "Robolectric 仅支持标准 Linux/CI 环境(当前为 Android PRoot 容器,会 UnsatisfiedLinkError)", + !androidContainer + ) + } +} diff --git a/app/src/test/java/com/aicode/feature/settings/data/repository/TargetModeSettingsRepositoryTest.kt b/app/src/test/java/com/aicode/feature/settings/data/repository/TargetModeSettingsRepositoryTest.kt new file mode 100644 index 00000000..5c1d4e30 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/settings/data/repository/TargetModeSettingsRepositoryTest.kt @@ -0,0 +1,64 @@ +package com.aicode.feature.settings.data.repository + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * [TargetModeSettingsRepository] 阈值读写:默认 50/5,写入后立即生效且下限夹到 1。 + * DataStore 依赖 Android Context,用 Robolectric;Android PRoot 容器加载不了 + * Robolectric native 库(同 MigrationTest),非标准 Linux 环境跳过。 + * 同一 JVM 内 DataStore 单例跨方法共享状态,写入类断言集中在单个方法内保证顺序。 + */ +@RunWith(AndroidJUnit4::class) +class TargetModeSettingsRepositoryTest { + + private lateinit var repo: TargetModeSettingsRepository + + @Before + fun setUp() { + guardEnvironment() + repo = TargetModeSettingsRepository(ApplicationProvider.getApplicationContext<Context>()) + } + + @Test + fun thresholds_writeReadAndClampInOrder() = runTest { + // 重置到默认基准后再验证默认值读取 + repo.setMaxStepBudget(50) + repo.setMaxConsecutiveFailures(5) + assertEquals(50, repo.thresholds.first().maxStepBudget) + assertEquals(5, repo.thresholds.first().maxConsecutiveFailures) + + // 写入自定义值立即生效 + repo.setMaxStepBudget(80) + repo.setMaxConsecutiveFailures(9) + val custom = repo.thresholds.first() + assertEquals(80, custom.maxStepBudget) + assertEquals(9, custom.maxConsecutiveFailures) + + // 下限夹到 1 + repo.setMaxStepBudget(0) + repo.setMaxConsecutiveFailures(-3) + val clamped = repo.thresholds.first() + assertEquals(1, clamped.maxStepBudget) + assertEquals(1, clamped.maxConsecutiveFailures) + + // 恢复默认基准,避免污染同 JVM 内后续运行 + repo.setMaxStepBudget(50) + repo.setMaxConsecutiveFailures(5) + } + + private fun guardEnvironment() { + val androidContainer = File("/system").exists() || + System.getProperty("java.library.path")?.contains("/data/app") == true + assumeTrue("Robolectric 仅支持标准 Linux/CI 环境(当前为 Android PRoot 容器,会 UnsatisfiedLinkError)", !androidContainer) + } +} diff --git a/docs-site/docs/guide/appearance.md b/docs-site/docs/guide/appearance.md index 3cb1bc3a..b5cae66b 100644 --- a/docs-site/docs/guide/appearance.md +++ b/docs-site/docs/guide/appearance.md @@ -39,6 +39,22 @@ Git 泳道色、日志级别色、diff 增删的红绿属于功能标识,不 背景图文件保存在应用私有目录,**不随备份导出**,换设备需要重新设置。 +## 玻璃材质 + +在背景图设置面板里还能给侧边栏、输入框、内容面板套上玻璃材质,让这些区域折射背景图,看起来像一块块玻璃浮在壁纸上。**需要 Android 13 及以上设备**,低版本会显示提示且不能开启。 + +开启后可选三档: + +- **磨砂**:常规毛玻璃,模糊背景图。 +- **水玻璃**:在磨砂基础上叠加水波纹折射,背景像透过水面看。默认水纹静止以省电,可以在设置里打开「水波流动动画」让水纹动起来。 +- **液体玻璃**:透镜折射加色散,背景有明显的形变和边缘高光,视觉最强烈。 + +其他选项: + +- **玻璃模糊半径**:就是上面的「磨砂强度」滑块,三档共用,值越大越糊。 +- **侧边栏 / 输入框 / 内容面板**:三个独立开关,想给哪块加玻璃就开哪块,不想给的区域保持原样。 +- 染色层会自动跟随当前主题色,保证玻璃上的文字在任何壁纸上都清晰可读。 + ## 语言 可以跟随系统语言,也可以直接指定一种已支持的语言。 diff --git a/docs-site/docs/guide/chat.md b/docs-site/docs/guide/chat.md index 0789abc9..d1ed7139 100644 --- a/docs-site/docs/guide/chat.md +++ b/docs-site/docs/guide/chat.md @@ -6,6 +6,7 @@ - **菜单按钮**(左上):打开侧边栏,管理会话列表和浏览文件。 - **会话标题与模型名**:上面一行是当前会话名,下面一行是正在用的模型。 +- **搜索按钮**:展开会话内搜索面板,按内容查找当前会话的历史消息(详见[会话内搜索](#会话内搜索))。 - **新建会话**:开一个新的对话。 - **Git 入口**:跳转到 Git 版本管理页。 - **终端入口**:跳转到终端页。 @@ -18,6 +19,8 @@ ### 会话页 +会话页顶部有搜索框,输入关键词即可按标题过滤会话列表,清空输入恢复全部显示。 + 会话按最后回复时间从新到旧排列,并按时间分组(今天 / 昨天 / 7 天内 / 30 天内 / 更早按月份)。置顶的会话单独放在最前面的「置顶」分组,卡片是浅蓝色背景。 短按切换会话,**长按弹出操作菜单**: @@ -44,16 +47,34 @@ - **工作区按钮**:显示当前工作区名,点击弹出工作区管理面板(见下)。 - **文件按钮**:上传文件作为附件,AI 可以读它的内容。 - **图片按钮**:上传图片作为附件。点击直接打开系统相册,可以多选。需要当前模型支持图片输入。 +- **优化表达按钮**:把输入框里的文字用当前对话模型结合最近对话上下文优化后回填输入框(补全指代、去歧义、去寒暄),确认无误再发送。输入框为空或 AI 正在工作时不可用。 - **发送按钮**:AI 空闲时是普通发送;AI 正在工作时,输入框为空则显示停止按钮,有内容则显示橙色发送按钮(消息会排队)。 上传的附件会以预览卡显示在输入框上方,发送前可以移除。点图片缩略图可以[全屏看大图](#全屏看图)。 +## 行动建议 + +每轮对话正常结束后,输入框上方会自动弹出 3 条后续行动建议(基于刚完成的对话内容生成:自然的下一步、明显的缺口、验证或交付动作)。点击任一条,建议文本会填入输入框,可直接修改或发送。 + +发送新消息后旧建议自动消失,本轮结束后生成新的建议;AI 连续自动执行(AUTO 模式链式轮次)期间只在最后一轮结束后弹出。 + 未发送的草稿内容会按会话分别保存:在当前会话输入内容后切换至其它会话,返回时草稿依然保留。草稿会持久化到本地,杀掉 App 重开(进程重启)后依然在。 滚动消息列表时,输入框区域会淡出到 40% 透明度以减少干扰,停止滚动后恢复。 翻历史消息时去开编辑页、终端页或设置页,返回后仍停在原来的位置,不会被拉回最新消息;离开前本就贴在底部的话,返回后继续跟随新消息。 +## 会话内搜索 + +点标题栏的搜索按钮,消息列表上方会展开搜索面板: + +- 输入关键词后自动搜索(停顿一下即出结果,也可点键盘上的搜索键立即查),匹配当前会话的已保存消息,最多显示 50 条,按时间倒序。 +- 结果按「我 / AI / 工具」标注消息来源,显示内容片段和时间,方便辨认。 +- 点任意结果会自动滚动定位到那条消息并高亮数秒;目标消息距离较远时 App 会自动加载更多历史消息再定位。 +- 点结果条目右侧的关闭按钮或收起面板即退出搜索。 + +侧边栏会话页的搜索框按**标题**过滤会话列表;标题栏搜索按钮按**消息内容**查找当前会话,两者配合可以快速翻到「当时聊了什么」的位置。 + ## 工作区管理 点工作区按钮弹出的面板里可以: @@ -70,6 +91,13 @@ 在输入框输入 `/` 会弹出命令菜单: - `/status`:查看当前会话状态(token 用量、模型、模式等),结果以 AI 气泡输出。 +- `/model`:打开模型选择弹窗,切换当前会话的模型(等同点输入栏的模型按钮)。 +- `/memory`:查看当前会话生效的规则构成——内置提示词、项目规则文件(AGENTS.md / CLAUDE.md)、全局与项目记忆。 +- `/skills`:列出已安装的技能与子代理,含作用域和启用状态。 +- `/mcp`:列出 MCP 服务器连接状态与已注册的工具数。 +- `/tools`:列出当前可用的工具清单及各自的权限策略(自动放行 / 每次询问)。 +- `/rewind`:打开回退菜单,把对话和代码回滚到最近一条用户消息的检查点;无历史消息时提示。 +- `/setup-opencode`:AI 自动完成 opencode CLI 的安装配置——检测容器内 opencode、没有则安装,然后读取 App 当前生效的模型提供商配置,生成 opencode 的配置文件并验证。执行前 App 会把当前 provider 导出到容器内(密钥只写入配置文件,不进入对话)。 - `/compress`:手动触发当前会话的上下文压缩。 命令在 AI 忙碌时会进队列,空闲后自动执行。命令文本会作为用户消息显示在对话里,并进入上下文。 diff --git a/docs-site/docs/guide/modes.md b/docs-site/docs/guide/modes.md index a1df09ee..3685d286 100644 --- a/docs-site/docs/guide/modes.md +++ b/docs-site/docs/guide/modes.md @@ -1,6 +1,6 @@ -# 三种模式:Build / Plan / Auto +# 四种模式:Build / Plan / Auto / Target -输入框左侧的模式按钮点一下循环切换:BUILD → PLAN → AUTO → BUILD。三种模式的区别是 AI 的**权限范围**。 +输入框左侧的模式按钮点一下循环切换:BUILD → PLAN → AUTO → TARGET → BUILD。四种模式的区别是 AI 的**权限范围**与**终止方式**。 ## BUILD(构建模式) @@ -28,8 +28,21 @@ 只有灾难性删除(比如 `rm` 根目录或系统目录)仍会被拦截,其余一律放行。适合你已经清楚知道风险、希望 AI 全自动跑批量任务的场景。不熟悉的项目或者不确定 AI 会干什么的时候,别用这个。 ::: +## TARGET(目标驱动模式) + +权限与 AUTO 一致全放行,区别在于**有明确的终止条件**。进入 TARGET 后,你发送的**第一条消息会自动设为目标声明**(由当前对话模型优化表达后生效,发送不阻塞),AI 会围绕这个目标自主规划并执行,直到以下任一情况终止并自动切回 BUILD: + +- **目标达成**:AI 调用 `completeGoal` 声明完成,你在弹窗确认后退出。 +- **连续失败**:连续多次工具调用失败达到阈值(默认 5 次)。 +- **步数超限**:单次目标执行的工具调用次数达到上限(默认 50 步)。 +- **手动中断**:你随时点按钮切到其他模式,已产生的检查点保留供回滚。 + +进入 TARGET 后输入框会提示「本条消息将自动设为目标」,发送第一条消息即设定目标并开始执行;顶部状态条会实时显示目标与步数/失败计数。与 AUTO 一样保留灾难性删除防护;与 AUTO 不同的是,AI 也能通过 `switchMode` 工具带 `goal` 参数主动进入 TARGET,而 AUTO 只能你手动进入。 + +阈值(最大步数、连续失败次数)可在设置页「目标驱动模式」分组里调整,适配不同复杂度的任务。 + ## AI 自己也能请求切换 -除了你手动点按钮,AI 在规划清楚后也可以主动请求切换模式,你在界面上一键批准即可。比如它在 PLAN 模式下做完方案,会申请切到 BUILD 开始实施。 +除了你手动点按钮,AI 在规划清楚后也可以主动请求切换模式,你在界面上一键批准即可。比如它在 PLAN 模式下做完方案,会申请切到 BUILD 开始实施;它也能带目标声明申请切到 TARGET。 -有一个方向上的限制:**AUTO 模式只能你自己手动进入**,AI 无法通过工具切进 AUTO。但反过来,处于 AUTO 模式时 AI 可以主动切到 PLAN——这是 AI 退出 AUTO 的唯一路径。 +有两个方向上的限制:**AUTO 模式只能你自己手动进入**,AI 无法通过工具切进 AUTO。但反过来,处于 AUTO 模式时 AI 可以主动切到 PLAN——这是 AI 退出 AUTO 的唯一路径。TARGET 则可以由 AI 带 `goal` 参数主动进入,达成目标后调 `completeGoal` 退出。 diff --git a/docs-site/docs/guide/overview.md b/docs-site/docs/guide/overview.md index 048b5886..5d95be92 100644 --- a/docs-site/docs/guide/overview.md +++ b/docs-site/docs/guide/overview.md @@ -11,7 +11,7 @@ | 功能 | 说明 | | --- | --- | | 聊天交互与工作区 | 标题栏、侧边栏、工具栏、消息队列、工作区切换 → [文档](/guide/chat) | -| 运行模式(三种模式) | Build / Plan / Auto 的权限控制与场景 → [文档](/guide/modes) | +| 运行模式(四种模式) | Build / Plan / Auto / Target 的权限控制与终止条件 → [文档](/guide/modes) | | 检查点与撤销 | AI 修改前自动快照,支持一键安全回滚 → [文档](/guide/checkpoint) | | 终端交互 | 多标签会话、辅助按键栏、配色与字体设置 → [文档](/guide/terminal) | | 文件浏览与代码编辑 | 缩进树形目录、语法高亮、内置代码编辑器(1.11.0 起)→ [文档](/guide/files) | diff --git a/docs-site/docs/guide/permissions.md b/docs-site/docs/guide/permissions.md index 596a5790..9a428871 100644 --- a/docs-site/docs/guide/permissions.md +++ b/docs-site/docs/guide/permissions.md @@ -19,6 +19,14 @@ AI 要执行命令、写文件之前需要你授权。这一页管理已经保 - 弹窗在任意会话界面都会展示,处理完自动切到下一个待授权请求。 - 侧边栏会话列表里,**正在等待授权**的会话行左侧指示灯是**橙色常亮**(执行中是绿色脉冲)。据此可以发现被挂起等待授权的后台会话,点进去处理。 +## 后台通知快捷处理 + +App 切到后台时,如果 AI 在等授权(或 PLAN 模式下等计划批准),会弹一条系统通知,不用回 App 也能直接处理: + +- 通知上带**允许一次 / 拒绝**(授权请求)或**批准执行**(计划批准)按钮,点按钮即完成处理,AI 继续往下跑。 +- 点通知正文打开 App 回到会话界面,照常弹窗处理。 +- 已经在 App 内处理过的请求,通知会自动消失;杀掉 App 后残留的旧通知在下次启动时清理。 + ## 页面结构 规则分「当前项目」和「全局」两组,点标题可折叠。