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/container-preview/design.md b/.monkeycode/specs/container-preview/design.md new file mode 100644 index 00000000..4fe680f1 --- /dev/null +++ b/.monkeycode/specs/container-preview/design.md @@ -0,0 +1,26 @@ +# 容器内 Web 服务预览 — 技术设计 + +Feature Name: container-preview +Updated: 2026-09-17 + +## Design + +### TerminalViewModel + +新增公开 `isRemoteMode(): Boolean`(复用私有 `isRemote()`),供 UI 判断本地/远程。 + +### TerminalScreen + +顶栏 actions 在设置按钮旁新增 Monitor 图标预览按钮;远程模式点击 Toast 提示,本地模式置 `showPreviewDialog = true` 渲染 [ContainerPreviewDialog]。 + +### ContainerPreviewDialog(新组件) + +- 输入态:`AlertDialog` + `AppTextField`(数字过滤、≤5 位、1..65535 校验),确认后切浏览态。 +- 浏览态:`Dialog(usePlatformDefaultWidth=false, decorFitsSystemWindows=false)` 全屏,顶栏显示 URL + 关闭/重载/外部打开,`AndroidView(WebView)` 占满剩余空间;WebView 启用 JS 与 DOM storage、支持缩放,factory 建一次、update 加载 URL。 +- 外部打开:`ACTION_VIEW` intent + `FLAG_ACTIVITY_NEW_TASK`,`runCatching` 兜底。 + +## References + +- `app/src/main/java/com/aicode/feature/terminal/presentation/TerminalViewModel.kt:46` — isRemote +- `app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt:124` — 顶栏 actions +- `app/src/main/java/com/aicode/feature/terminal/presentation/component/ContainerPreviewDialog.kt` — 预览对话框 \ No newline at end of file diff --git a/.monkeycode/specs/container-preview/requirements.md b/.monkeycode/specs/container-preview/requirements.md new file mode 100644 index 00000000..3749932b --- /dev/null +++ b/.monkeycode/specs/container-preview/requirements.md @@ -0,0 +1,14 @@ +# Requirements Document + +## Introduction + +AI 在容器里跑起 Web 服务后,手机上没法访问容器内 localhost 端口,全栈开发闭环断在最后一步。PRoot 容器与宿主共享网络命名空间,容器内绑定端口的服务在 App 内可直接以 `127.0.0.1:` 访问——本功能用内嵌 WebView 提供预览。 + +## Requirements + +1. WHEN 用户处于本地容器模式并点击终端页顶栏「预览」按钮,AiCode SHALL 弹出端口输入对话框。 +2. WHEN 用户输入合法端口(1-65535)并确认,AiCode SHALL 以全屏内嵌 WebView 打开 `http://127.0.0.1:`。 +3. 预览页 SHALL 提供关闭、重载,以及转系统浏览器打开的入口。 +4. WHEN 用户处于远程 SSH 模式点击「预览」,AiCode SHALL 提示「预览仅适用于本地容器模式」且不打开对话框。 +5. 端口输入 SHALL 仅允许数字并限制 5 位,非法端口禁用确认按钮。 +6. 相关文案 SHALL 走双语 strings.xml(`terminal_preview_*`)。 diff --git a/.monkeycode/specs/export-session/design.md b/.monkeycode/specs/export-session/design.md new file mode 100644 index 00000000..157a231e --- /dev/null +++ b/.monkeycode/specs/export-session/design.md @@ -0,0 +1,91 @@ +# 单会话导出 Markdown — 技术设计 + +Feature Name: export-session-markdown +Updated: 2026-09-17 + +## Description + +在现有「导出会话」(tar.gz 备份)旁并列增加 Markdown 可读格式导出。生成器为纯函数放 `feature/backup/domain/`,ViewModel 负责取数(sessionUseCase.getSessionById + agentMessageDao.getMessagesBySessionOnce)与写流,入口复用会话长按菜单(SessionActionSheet 加一项),保存走 SAF CreateDocument(text/markdown)。 + +## Architecture + +```mermaid +flowchart LR + Sheet["SessionActionSheet 菜单「导出 Markdown」"] -->|"onExportMarkdown(session)"| Drawer["ChatDrawer"] + Drawer --> Main["MainActivity"] + Main -->|"CreateDocument(text/markdown) 选位置"| VM["AIAgentViewModel.exportSessionMarkdown"] + VM -->|"getSessionById + getMessagesBySessionOnce"| Repo["Room"] + VM --> Exp["SessionMarkdownExporter.build 纯函数"] + Exp -->|"String → OutputStream"| File[".md 文件"] +``` + +## Components and Interfaces + +### 1. SessionMarkdownExporter(feature/backup/domain/SessionMarkdownExporter.kt) + +```kotlin +object SessionMarkdownExporter { + fun build( + title: String, + messages: List, + exportedAt: Long = System.currentTimeMillis(), + ): String +} +``` + +- 过滤:`isCompacted || isContextSummary || isCompactionMarker` 及 USER 且 content 以 `BACKGROUND_NOTIFICATION_PREFIX` 开头的行。 +- 渲染顺序保持 DAO 返回序(timestamp 升序)。 +- 工具行:`> 工具 \`toolName\`` +(isError 时)`· 失败` + 参数单行化截断 300 字符。 +- 附件:USER 消息后逐行 `[附件: fileName]`。 +- 时间格式 `yyyy-MM-dd HH:mm`(java.time,minSdk 26 可用)。 +- 头部 token 合计取消息行 inputTokens/outputTokens 求和。 + +### 2. AIAgentViewModel.exportSessionMarkdown + +与 `exportSession`(AIAgentViewModel.kt:2271)同构: + +```kotlin +fun exportSessionMarkdown(sessionId: String, output: OutputStream, onResult: (Boolean) -> Unit) +``` + +取数 → build → `output.write(text.toByteArray())`(IO dispatcher)→ finally 关流。 + +### 3. MainActivity launcher + +`markdownExportLauncher = CreateDocument("text/markdown")`,同款 pendingExportMarkdownSessionId + openOutputStream + Toast 模式(对照 sessionExportLauncher,MainActivity.kt:445-471)。ChatDrawer 新参 `onExportMarkdown: (ChatSession) -> Unit`,文件名 `aicode-session--.md`。 + +### 4. SessionActionSheet 菜单项 + +在「导出」(tar.gz)后插入 `SheetActionRow(icon = FeatherIcons.FileText, label = chat_export_session_markdown)`,回调 `onExportMarkdown()`。 + +## Correctness Properties + +- 纯函数无副作用:相同输入(消息列表 + 时间)产出相同 Markdown。 +- 消息顺序与 DAO 返回一致(timestamp 升序),对话可读。 +- 过滤规则保证内部消息(压缩/摘要/锚点/后台通知)零泄漏。 +- tar.gz 备份导出路径完全不动,向后兼容。 + +## Error Handling + +| 场景 | 处理 | +|------|------| +| 会话无消息 | 仍产出头部(0 条消息),不报错 | +| openOutputStream 失败 | Toast chat_export_session_failed(现有文案) | +| 写流异常 | runCatching 捕获记 FileLogger,回调 false,finally 关流 | + +## Test Strategy + +`SessionMarkdownExporterTest`(纯 JVM,AgentMessageEntity 为纯数据类): +- 头部含标题/导出时间/消息数/token 合计 +- USER 附件标注 +- TOOL 记录含工具名、失败标记、参数单行截断 +- 过滤 isCompacted / isContextSummary / isCompactionMarker / 后台通知 +- 空 content 的 ASSISTANT 跳过 + +## References + +- `app/src/main/java/com/aicode/feature/backup/data/BackupManagerImpl.kt:123` — 现有 tar.gz 导出 +- `app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt:2271` — exportSession 模式 +- `app/src/main/java/com/aicode/MainActivity.kt:445` — sessionExportLauncher 模式 +- `app/src/main/java/com/aicode/feature/agent/presentation/component/ChatDrawer.kt:962` — SessionActionSheet +- `app/src/main/java/com/aicode/feature/agent/data/local/entity/AgentMessageEntity.kt:18` — 消息字段 diff --git a/.monkeycode/specs/export-session/requirements.md b/.monkeycode/specs/export-session/requirements.md new file mode 100644 index 00000000..622946d5 --- /dev/null +++ b/.monkeycode/specs/export-session/requirements.md @@ -0,0 +1,35 @@ +# 单会话导出 Markdown + +Feature Name: export-session-markdown +Updated: 2026-09-17 + +## Introduction + +会话长按菜单已有「导出」(tar.gz 备份格式,供导入恢复)。本功能并列增加「导出 Markdown」:生成人类可读的对话文档(用户/AI 正文 + 工具调用记录),SAF 保存为 .md 文件,便于发给同事或存档。 + +## Glossary + +- **备份导出**: 现有 tar.gz JSONL 机器格式,供备份恢复用,本功能保留并与其并列。 +- **工具记录**: ROLE=TOOL 的消息行(工具名 + 参数摘要 + 是否失败)。 + +## Requirements + +### Requirement 1: 导出入口 + +1. WHEN 用户在会话长按菜单点击「导出 Markdown」,AiCode SHALL 弹出 SAF 保存对话框,默认文件名 `aicode-session-<标题>-<时间戳>.md`,MIME 为 `text/markdown`。 +2. WHEN 保存完成,AiCode SHALL 展示成功/失败提示(复用现有 chat_export_session_done/failed 文案)。 + +### Requirement 2: Markdown 内容 + +1. 文档 SHALL 以一级标题(会话标题)与引用行(导出时间、消息数、token 用量)开头。 +2. 导出 SHALL 按时间顺序渲染用户消息(二级标题「用户」)、AI 消息(二级标题「AI」)与工具调用(引用块标注工具名、失败状态与参数摘要)。 +3. 工具参数 SHALL 单行化并截断至 300 字符,避免超长命令撑爆文档。 +4. 附件 SHALL 以 `[附件: 文件名]` 标注在所属用户消息之后。 +5. 内部消息 SHALL 被排除:已压缩消息(isCompacted)、上下文摘要(isContextSummary)、压缩锚点(isCompactionMarker)、后台任务通知(BACKGROUND_NOTIFICATION_PREFIX 开头)。 +6. 正文为空的 AI 消息 SHALL 跳过(纯工具调用轮次由工具记录说明)。 +7. 思考过程(reasoning)SHALL 排除在导出之外。 + +### Requirement 3: 一致性 + +1. 所有用户可见文案 SHALL 走双语 strings.xml(`chat_export_session_markdown`)。 +2. 生成器 SHALL 为纯函数(输入实体、输出字符串),附 JVM 单测覆盖头部、附件、工具记录、过滤与截断规则。 diff --git a/.monkeycode/specs/git-merge-rebase/design.md b/.monkeycode/specs/git-merge-rebase/design.md new file mode 100644 index 00000000..41458310 --- /dev/null +++ b/.monkeycode/specs/git-merge-rebase/design.md @@ -0,0 +1,43 @@ +# Git 可视化合并 / 变基 + +Feature Name: git-merge-rebase +Updated: 2026-09-17 + +## Introduction + +Git 页此前只有 push/pull/分支管理,合并与变基只能在终端手敲命令,冲突状态也没有可视化提示。本功能在分支操作菜单增加「合并」入口(可合并或变基),并新增跨 tab 冲突横幅:执行后处于冲突状态时顶部提示冲突文件列表,提供「中止」与「继续变基」操作。 + +## Requirements + +1. WHEN 用户点本地分支操作菜单的「合并」,AiCode SHALL 弹出确认对话框展示合并目标,并提供「合并」「变基」「取消」三个选择。 +2. WHEN 用户确认,AiCode SHALL 执行 `git merge ` 或 `git rebase `,成功后刷新分支与状态。 +3. WHEN 合并/变基产生冲突(退出码非零),AiCode SHALL 在 Git 页顶部显示冲突横幅,标题区分合并/变基,列出冲突文件数量与前几个路径。 +4. WHEN 冲突横幅可见,AiCode SHALL 提供「中止」按钮(执行 `git merge --abort` / `git rebase --abort`);变基时另提供「继续变基」按钮(执行 `git rebase --continue`)。 +5. 合并/变基命令 SHALL 以 `true` 作为 editor,避免冲突解决提交时卡在交互编辑器。 +6. 相关文案 SHALL 走双语 strings.xml(`git_action_*` / `git_*_conflict_*` / `git_merge_confirm_message`)。 + +## Design + +### GitRepository + +- `merge(branch)` / `rebase(branch)`:`gitChecked("-c","core.editor=true", ...)`,冲突时抛 `GitCommandFailureException`(携带 git 输出)。 +- `abortMerge()` / `abortRebase()` / `continueRebase()`(后者同样 `core.editor=true`)。 +- `isMerging()` / `isRebasing()`:`git rev-parse -q --verify MERGE_HEAD / REBASE_HEAD` 非空判断。 +- `mergeConflicts()`:解析 `git status --porcelain=v1` 中两列状态码均为字母(U/A/D)的冲突行 → 纯函数 `mergeConflictsFromPorcelain`(单测覆盖 UU/AA/DD/UD/AU/UA/DU、重命名取新路径、引号路径、非冲突行排除)。 + +### GitViewModel + +- `GitUiState` 新增 `isMerging` / `isRebasing` / `conflicts`;`RepoSnapshot` 并发拉取对应仓库状态,refresh 与 runAction 刷新时同步写入。 +- `merge(branch)` / `rebase(branch)` / `abortOperation()`(按状态自动选 abort 目标)/ `continueRebase()`。 + +### UI + +- `BranchesTab`:本地非当前分支行新增 Merge 动作 → `pendingMerge` 确认对话框(合并/变基/取消)。 +- `GitScreen`:Pager 上方按 `isMerging || isRebasing` 渲染 `MergeConflictBanner`(errorContainer 底色、冲突文件列表、中止按钮、变基时继续按钮)。 + +## References + +- `app/src/main/java/com/aicode/feature/git/domain/GitRepository.kt` — merge/rebase/冲突解析 +- `app/src/main/java/com/aicode/feature/git/presentation/GitViewModel.kt` — 冲突状态与方法 +- `app/src/main/java/com/aicode/feature/git/presentation/component/GitBranchesTab.kt` — Merge 动作与确认框 +- `app/src/main/java/com/aicode/feature/git/presentation/component/GitScreen.kt` — 冲突横幅 diff --git a/.monkeycode/specs/git-merge-rebase/requirements.md b/.monkeycode/specs/git-merge-rebase/requirements.md new file mode 100644 index 00000000..ebce2827 --- /dev/null +++ b/.monkeycode/specs/git-merge-rebase/requirements.md @@ -0,0 +1,38 @@ +# Requirements Document + +## Introduction + +Git 页分支操作只有切换/重命名/删除/拉取/推送,缺少合并与变基;冲突时完全无可视化。本功能补齐分支合并/变基入口与冲突横幅,让「整合分支 → 遇冲突 → 处理/中止」在 Git 页内闭环。 + +## Glossary + +- **合并**: `git merge`,把目标分支合入当前分支。 +- **变基**: `git rebase`,把当前分支重新垫到目标分支之上。 +- **冲突横幅**: 合并/变基产生未解决冲突时,Git 页顶部显示的状态栏。 + +## Requirements + +### Requirement 1: 合并/变基入口 + +**User Story:** AS 开发者,I want 在分支菜单直接合并或变基其他分支,so that 不必切到终端手敲命令。 + +1. WHEN 用户点本地非当前分支的操作菜单,AiCode SHALL 提供「合并」动作。 +2. WHEN 用户点击「合并」,AiCode SHALL 弹出确认框,展示目标分支与当前分支,并提供「合并」「变基」「取消」三个选项。 +3. WHEN 用户选择合并或变基,AiCode SHALL 执行对应 git 命令并刷新分支、状态与历史。 + +### Requirement 2: 冲突状态可见 + +1. WHEN 合并/变基因冲突失败,AiCode SHALL 在 Git 页顶部(跨 tab)显示冲突横幅,标题区分合并/变基冲突。 +2. 冲突横幅 SHALL 展示未解决冲突文件数量与最多 3 个文件路径。 +3. WHEN 仓库处于冲突状态(存在 MERGE_HEAD/REBASE_HEAD),横幅 SHALL 持续显示直至冲突解决或操作中止。 + +### Requirement 3: 处理与中止 + +1. WHEN 冲突横幅可见,AiCode SHALL 提供「中止」按钮,按当前状态执行 `git merge --abort` 或 `git rebase --abort`,回到操作前状态。 +2. WHEN 冲突横幅可见且当前为变基,AiCode SHALL 另提供「继续变基」按钮,供用户解决冲突并暂存后继续。 +3. 冲突解决需手动完成(工作区终端 + `git add`),AiCode SHALL 在横幅中提示该流程。 + +### Requirement 4: 一致性 + +1. 所有文案 SHALL 走双语 strings.xml。 +2. 合并/变基命令 SHALL 用 `-c core.editor=true`,避免冲突解决提交打开交互编辑器阻塞。 \ No newline at end of file diff --git a/.monkeycode/specs/god-mode/design.md b/.monkeycode/specs/god-mode/design.md new file mode 100644 index 00000000..0e21c8a4 --- /dev/null +++ b/.monkeycode/specs/god-mode/design.md @@ -0,0 +1,224 @@ +# God Mode(甲方-乙方协作模式) + +Feature Name: god-mode +Updated: 2026-09-18 + +## Description + +God Mode 把会话角色重构成「甲方/乙方」协作:用户作为投资者只提需求与目标,AI(主会话)充当 +**CEO/总汇报员**,通过新的 `orchestrate` 工具把需求分解为子任务派发给**AI 部门**(可指定不同 +模型与双执行风格),关键产出经**评审团**(多立场子代理辩论)与**评审长**裁决,最终向甲方汇报。 +治理层(权限审批 / 模式切换)由 CEO 代为决策,用户不再逐项弹窗。 + +本特性最大化复用现有子代理体系(AgentDefinition 定义、`task` 派发、AUTO/TARGET 预授权、 +完成通知回传、`sideLlmCall` 旁路调用、`review.md` 评审基座),新增一个 `orchestrate` 工具与一组 +角色资产撬动「分解 → 派发 → 评审 → 裁决 → 汇报」闭环。 + +## Architecture + +```mermaid +graph TD + A["甲方用户"] -->|需求/目标/预算| C["CEO(主会话, GOD 模式)"] + C -->|orchestrate(decompose) 拆解| S["orchestrate 工具 / OrchestratorService"] + S -->|创建子会话| D1["部门 exec-conservative"] + S -->|创建子会话| D2["部门 exec-modern"] + S -->|等待完成通知| E["SubAgentEventBus"] + E -.COMPLETED.-> C + C -->|orchestrate(review) 成立| J1["评 trou 严苛"] + C -->|orchestrate(review) 成立| J2["评 examplar 务实"] + C -->|orchestrate(review) 成立| J3["评 optimist 建设"] + J1 & J2 & J3 -->|三份意见| F["评审长 forehead"] + F -->|裁决 approved/rework/switch_style| C + C -->|approved 汇报 / 返工再派| A + S -->|预算聚合| G["BudgetLedger(汇总子部门 cost)"] +``` + +`orchestrate` 是唯一新增工具;部门、评审员、评审长均为现有 AgentDefinition 资产(`assets/agents/*.md`), +由 `SystemPromptProvider` 按 name 注入与 `TaskTool` 式派发。 + +## Components and Interfaces + +### 1. `AgentMode.GOD` 接入(模式枚举扩展) + +- `ChatSession.kt#AgentMode` 增加 `GOD` 值;不改变 `BUILD/PLAN/AUTO/TARGET` 已有语义。 +- 进入路径:会话头超小菜单「切换到 God Mode」(复用现有 mode 切换 UI 位,`ChatInputBar.kt:340-380`); + 切换前用 `askUserQuestion` 确认治理策略(见 R1-A验2)。 +- 退出:复用 `switchMode` 工具回 BUILD;退出时清理编排状态与预算 ledger 临时态。 + +**边界**:GOD 不改变 `TARGET` 终止机制(goalStepCount/failCount 仍只对 TARGET 生效)。 +GOD 下 `orchestrate` 需要在 workflow 内可见并注入 sessionId(经 AgentContext,现有 `context.sessionId`)。 + +### 2. `OrchestratorService`(域层编排器,新) + +```kotlin +interface OrchestratorService { + /** 分解:按 spec 一次创建多个部门子会话 */ + suspend fun decompose(spec: DecomposeSpec): List + /** 评审:成立评审团,等 3 份意见后成立评审长裁决 */ + suspend fun runJury(context: JuryContext): JuryVerdict + /** 汇总预算(父 + 子 cost) */ + suspend fun budget(): BudgetSnapshot +} +``` + +- 监听 `SubAgentEventBus`:SPAWNED → 子会话 workflow 启动(ViewModel 现有 `spawnSubAgentWorkflow` 路径); + COMPLETED/FAILED → `notifyParentSubAgentFinished` 已现成,CEO 经 `task(read)` 拉结论。 +- `runJury` 用「父代理串行派发只读子代理」的现有能力:3 名评审员各自 `task(create, agent=review-tough/...)` + 高优先,等三道通知全部回落后以评审长 `forehead` 汇总。实现行进一版不做子代理间直连,只经 CEO 中转 + (对齐现有「只能读最后一条回复」约束)。 + +### 3. `orchestrate` 工具(AgentTool,新) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `phase` | STRING | 否 | `decompose`(默认)/ `review` / `status` / `budget`。`phase=decompose` 派部门,`review` 组评审团,`status`/`budget` 只读查。 | +| `goal` | STRING | 否 | decompose 时的一段需求 / 任务描述(喂给部门的完整目标)。 | +| `tasks` | ARRAY | 否 | decompose 拆出的子任务:`{name, agent, model?, reasoningEffort?, style?}` | +| `diffSpec` | STRING | 否 | review 时的改动范围(文件清单 / PR diff / 未提交改动)。 | +| `budget` | INTEGER | 否 | 一次设置本场协作预算(tokens 或条数),覆盖会话默认。 | + +- `permissionPolicy`:`ASK`(编排创建子会话属高影响),但 God 治理策略为「甲方全交」时被策略引擎放行。 +- capability:`EXTERNAL_TOOL` / `WRITE_WORKSPACE`(decompose 派发不直接写工作区,重新审视列入 + `EXTERNAL_TOOL` 即可,避免误触发 PLAN 拦截)。 +- 复用 `TaskTool` 解析 `agent=` provider/model/effort 的现成函数(`TaskTool.kt:161-171`),做成 `AgentParamsResolver` 共享。 + +### 4. 部门与评审角色资产(`assets/agents/*.md`,新 5 个) + +| 文件名 | name | 风格/定位 | 工具白名单 | 模型策略 | +| --- | --- | --- | --- | --- | +| `exec-conservative.md` | exec-conservative | 遵循现有代码风格、最小 diff、保项目惯例 | 与普通执行 agent 一致(含 writeFile/editFile) | 继承父会话 | +| `exec-modern.md` | exec-modern | 革新/现代化格式与最佳实践 | 同上 | 继承父会话 | +| `review-tough.md` | review-tough | 严苛挑刺:只找必挂 bug 与硬约束 | `[readFile, list, search]` | 继承 | +| `review-pragmatic.md` | review-pragmatic | 务实:关注可维护性、测试、性价比 | `[readFile, list, search]` | 继承 | +| `review-optimist.md` | review-optimist | 建设:找亮点与可长线方案,平衡风险 | `[readFile, list, search]` | 继承 | +| `forehead.md` | forehead | 评审长:读三份意见裁决 | `[readFile, list, search]` | 继承 | + +- 内部复用现成 `review.md` 的「怎么审」六维框架(`app/src/main/assets/agents/review.md`),每个角色在同 + 框架上叠加性格倾向一段。 +- 新版本第 1 步先各需内容由 `DEFAULT_INJECT`(base+skills+memory+projectRules)组装,并显式 + `disallowedTools` 禁掉 `writeFile`/`editFile`/`Bash`(评审只读)。评审角色 prompt 声明 + 「只能被 CEO 读到最后一条回复」,沿用 `90-subagent-base.md` 约束。 + +### 5. 预算账本 `BudgetLedger`(域层,轻量) + +- 复用会话 token 累积字段(`ChatSession.totalInputTokens/totalOutputTokens`)与 DB DAO, + 在 `orchestrate(status/budget)` 时聚合父+子会话数值。 +- 不新增表:会话已持久化 cost;GOD 子部门即是子会话,天然携带自身 cost。 +- 预算上限存 `ChatSession` 新增字段 `godBudgetTokens: Int? = null`(null=不限额); + `executeEvents` 主循环在每次 `LlmResponse` 入账后检查,超限则把 `BudgetExhausted` 通知搭到下一次 + 工具结果顶层 `notifications`,CEO 据此 `switchMode(BUILD)` 并向用户汇报。 + +### 6. War Room UI(战区视图,新) + +- 新增 `GodWarRoomOverlay` / `GodDashboardCard`(Compose)挂在 `AIChatPanel` 顶部,仅 `GOD` 模式显示: + - 子会话卡片列表(`subSessionsByParent`,现成:`AIAgentViewModel.kt:390`):状态芯片、 + 所属模型、产出摘要(取子会话最后一条非空助手回复)、裁决结果 badge。 + - 预算条:聚合 cost / `godBudgetTokens`(R7)。 +- 新增 `AgentUIMessage.sender: String?`(默认 null,普通会话不变);GOD 下子会话卡片作为可展开行 + 渲染在 CEO 消息流之间,气泡左侧加来自 agent 名微标签(多语言进入 strings.xml)。 + +### 7. 评审团辩论协议(多立场采集 → 裁决,新) + +为避免「三份意见各说各话、无人收口」,Jury 采用 **两段式**: + +```mermaid +sequenceDiagram + actor CEO + participant O as OrchestratorService + participant T as review-tough + participant P as review-pragmatic + participant F as forehead + CEO->>O: orchestrate(review, diffSpec) + O->>T: task(create, agent=review-tough, range=diffSpec) + O->>P: task(create, agent=review-pragmatic, range=diffSpec) + O->>? : task(create, agent=review-optimist, range=diffSpec) + T-->>O: 意见.A (回声) + P-->>O: 意见.B + Note over O: 3 份意见就绪(max 等待超时 120s) + O->>F: task(create, agent=forehead, 输入=A/B/C 全文) + F-->>O: verdict(approved|rework|switch_style)+ must_fix[] + style_advice + O-->>CEO: tools result JSON +``` + +- `runJury` 实现为「等 3 名评审的 COMPLETED 通知全部落地后,一次性以 `sideLlmCall` 或 `task(forehead)` 汇总」; + 采用 `task` 派发 forehead 以复用子会话隔离与权限裁剪,避免主会话上下文被三份长意见撑爆。 +- 评审员 prompt 由角色文件给出性格倾向(见 §4)并复用 `review.md` 六维框架;`diffSpec` 用绝对文件范围 + 传给两轮子会话,避免 `diff` 全量过大。 + +### 8. 汇报协议(CEO → 甲方,新序列化) + +- 每次上帝会话的「里程碑」由 CEO 生成结构化汇报,`orchestrate(phase=report)` 返回 JSON: + ```json + { "milestone": "M1", "summary": "…", "changes": ["app/…"], "depts": [{"name","model","status","output"}], + "jury":{"verdict":"approved","reviewers":["tough","pragmatic","optimist"],"must_fix":[]}, + "budget":{"spent":1234,"limit":10000} } + ``` +- UI 在 War Room 里把 `report` 渲染为「里程碑卡片」(时间线),甲方可逐条追溯决策路径—— + 谁写了代码、谁审查、裁决为何、花了多少额度。 +- 序列化模型 `GodReport`(纯 data class)供 `AIAgentViewModel` 落库到 `session` 的 `godReports` + (长度受限 JSON 字段,非新表)。 + +### 9. 会话编排生命周期与持久化 + +- `ChatSession` 增字段:`mode`(扩枚举到 `GOD`)、`godBudgetTokens: Int?`、`godStrategy: String?` + (`full` / `keep`,R1 治理策略)、`godReportsJson: String?`(≤若干条里程碑 JSON)。 +- 生命周期:进入 God → `godStrategy` 入库 → CEO 通过 `orchestrate` 派生部门/评审(子会话,沿用黑名单剔除 + `task`)→ 里程碑经 `orchestrate(phase=report)` 写 `godReportsJson` → 退出/超预算 `switchMode(BUILD)` + 清 `godStrategy` 但保留子会话与报告(可读)。 +- 不新增 DB 表(全部落在既有 `chat_sessions` 列与消息语义),避免迁移版本冲突。 + +### 10. 双风格对比产物(P2 增强,作为设计扩展预留) + +- 并行「双风格」产物各占一个子会话;评审长裁决 `switch_style` 时 CEO 把对照 diff 以卡片形式贴给甲方, + 不再重跑(详见 append)。 + +## Data Models + +- `ChatSession` 增字段:`godBudgetTokens: Int?`、`godStrategy: String?`、`godReportsJson: String?`(新,非 TARGET)。 +- `AgentUIMessage` 增:`sender: String? = null`。 +- 复用 `AgentDefinition`(agent 资产模型,无 schema 变更),不新增库表;`god_mode_ledger` 无需建表, + 按需从 `session` 聚合。 +- `orchestrate` 工具的入参/出参以 `ToolResult` JSON 承载;子任务描述经 `tasks[].goal` 或 prompt 直传子会话。 + +## Correctness Properties + +1. `GOD` 不外泄执行权限:主会话最小权限(只读 + `orchestrate`),具体写由部门子会话承担。 +2. 评审只读不变式:评审团 agent 工具集不含任何写工具(filterToolNames 白名单保证),返回前断言。 +3. 编排循环有界:`runJury` 单轮三次意见 + 一次裁决;`rework` 循环计数上限(默认 3)到点停止并汇报。 +4. 预算聚合准确:`budget()` = 父会话 + 全部存活子会话 cost;超限同一轮只触发一次通知,不重复轰炸。 +5. 模态恢复:退出 GOD 同时还原治理策略缓存、预算上限与编排状态,不残留。 +6. 不破坏现有模式路径:GOD 不触碰 TARGET 终止机制、AUTO 手动唯一入口、PLAN 拦截。 + +## Error Handling + +| 场景 | 行为 | +| -- | -- | +| `task.agent` 名无效 | 复用 `AGENT_NOT_FOUND`(含可用名单) | +| 指定 model/provider 不可用 | `orchestrate` 返回明确错误,CEO 改派,节流回 fallback 默认模型 | +| 评审团某评审拉起失败 | 记录跳过,CEOs 以其余评审意见交由评审长裁决,通知中注明缺员 | +| 预算耗尽 | `BudgetExhausted` 通知上抛 → CEO `switchMode(BUILD)` + 汇报,新的子任务不再创建 | +| 子会话 workflow 失败 | COMPLETED/FAILED 通知链已现成;失败细节在 `detail` 提示 CEO 重派或标记返工 | +| 用户中途退出 God | 立即清理,子会话仍保留,用户可手动继续 | + +## Test Strategy + +- **纯 JVM/无 Android**: + - `AgentParamsResolverTest`:`agent=` 模型/provider/effort 解析与回退(复用 TaskTool 现成逻辑抽离后单测)。 + - `BudgetLedgerTest`:聚合父+子;超限判定与“一轮只报一次”。 + - `JuryVerdictTest`:三意见 → 裁决映射(approved/rework/switch_style)、跑缺少评审的容错。 + - `toolSchemaTest`:`orchestrate` 的 JSON Schema(= `toJsonSchema()` 复用通道)参数形状声明式断言。 +- **Robolectric/androidTest(如环境可用)**:GOD 模式状态机——`LlmResponse` 入账写 budget、超限通知。 +- **回归**:现有 `AIAgentViewModelTest`/provider 测试保持绿(不触碰 TARGET/AUTO 路径)。 + +## References + +[^1]: (app/src/main/java/com/aicode/feature/agent/domain/subagent/AgentDefinition.kt#L59) - 角色定义模型(providerId/model/effort/tools 白名单/inject) +[^2]: (app/src/main/java/com/aicode/feature/agent/domain/tool/subagent/TaskTool.kt#L161) - `agent=` 解析现成逻辑,`OrchestratorService` 复用 +[^3]: (app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt#L70) - AUTO/TARGET 预授权语义,God「甲方全交」复用 +[^4]: (app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt#L775) - 子代理完成通知链(忙碌搭车 / 空闲注入) +[^5]: (app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt#L1046) - `sideLlmCall` 旁路调用入口(评审长汇总可独立调用) +[^6]: (app/src/main/assets/agents/review.md) - 评审六维框架,供 3 名性格评审员复用 +[^7]: (app/src/main/java/com/aicode/feature/agent/domain/model/ChatSession.kt#L6) - AgentMode 枚举扩点 +[^8]: (app/src/main/java/com/aicode/feature/agent/domain/model/AgentUiModels.kt#L82) - `AgentUIMessage` 增 `sender` +[^9]: (app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt#L390) - `subSessionsByParent` 现成聚合,War Room 数据源 +[^10]: (docs-site/docs/guide/subagent.md) - 子代理用户文档同步点 \ No newline at end of file diff --git a/.monkeycode/specs/god-mode/requirements.md b/.monkeycode/specs/god-mode/requirements.md new file mode 100644 index 00000000..f145926e --- /dev/null +++ b/.monkeycode/specs/god-mode/requirements.md @@ -0,0 +1,103 @@ +# Requirements Document: god-mode + +## Introduction + +为 AiCode 引入 **God Mode(甲方-乙方协作模式)**:把「用户 ↔ AI」的关系重构成「投资者/甲方 ↔ 公司/乙方」。 + +- **用户(甲方 / 投资者)**:只提出需求与目标、提供资源(含模型额度),不再亲自处理权限询问、模式切换等执行层琐事。 +- **AI(乙方 / 一家公司)**:由 **CEO / 总汇报员**(主会话)担任最高决策者——解析需求、拆解为子任务、派发给 AI 部门(子代理)、组**评审团**辩论审查、按**双执行风格**产出,最后向甲方汇报;所有权限审批与模式切换由 CEO 代为处理,用户可从执行层抽身。 + +该模式建立在现有子代理系统(agent 定义 / `task` 派发 / AUTO-TARGET 预授权 / 完成通知回传 / review 评审基座)之上,复用最大化,新增一个 `orchestrate` 编排工具与一组角色资产来打通「分解 → 派发 → 评审 → 裁决 → 汇报」闭环。 + +## Glossary + +- **System**: AiCode 应用(Kotlin + Compose + Hilt + Coroutines)。God Mode 是其中的一种会话模式。 +- **甲方(A)**: 会话用户,仅提需求、提供服务与额度,不参与执行层操作。 +- **CEO / 总汇报员(C)**: 主会话中的 AI──God Mode 的操盘手,最高决策者与对外(对用户)唯一出口。 +- **AI 部门(Dept)**: 独立执行的子代理会话(如 `coder-conservative` / `coder-modern`),各按不同模型/思考强度/工具集/代码风格执行一项子任务。 +- **评审团(Jury)**: 一组(≥3)不同性格倾向、只读审查的子代理,对关键产出独立发表意见。 +- **评审长(Foreman)**: 汇总评审团意见、给出裁决(放行 / 返工 / 换风格)的子代理。 +- **执行风格(Style)**: 代码编写风格档位,控制产出代码遵循现有风格(`conservative`)还是革新风格(`modern`)。 +- **编排(Orchestrate)**: CEO 分解需求 → 派发部门 → 等待完成 → 组评审 → 吸收裁决 → 再派发/汇报的循环操作。 +- **治理(Governance)**: 权限与模式等「实施层动作」,God Mode 下由 CEO 决策,用户不逐一弹窗。 + +## Requirements + +### R1 进入与退出 God Mode + +**User Story:** AS 用户, I want 一键把一个会话切到 God Mode, SO THAT 把执行与权限交托给 CEO,自己只做甲方。 + +#### Acceptance Criteria + +1. 系统 SHALL 在会话模式集(BUILD/PLAN/AUTO/TARGET)之外提供 `GOD` 模式,用户可在会话头/设置手动进入。 +2. 系统 SHALL 要求在进入 God Mode 前确认任选其一:`甲方全权交托(权限全放行,仅灾难性命令保留拦截)` 或 `甲方留枢(大权限仍弹窗)`,把用户选择记为本次 God 会话的治理策略。 +3. IF 用户退出 God Mode,the system SHALL 清理游 per-session 编排状态并回落 BUILD 模式,不破坏既有消息。 + +### R2. CEO/总汇报员身份与权限代理 + +**User Story:** 甲方, I want 把「数据 EA → 切模式、审权限」这类操作交给 CEO, SO THAT 我不再处理执行琐事。 + +#### Acceptance Criteria + +1. God Mode 下会话 role 即 CEO;一次对话中所有对用户的文字输出都应标为来自 CEO,且是「对甲方的汇报」而非「执行细节」。 +2. WHILE 治理策略为「甲方留枢」,the system SHALL 在遇到高影响权限请求时优先转向 CEO 决策(CEO 权衡后直接 approve/deny),把 CEO 决策作为提示用户的替代通道。 +3. WHNF 治理策略为「甲方全交」,the system 复用 AUTO/TARGET 的预授权语义:策略引擎对全部工具放行(仅灾难性 `rm` 等保留拦截)。 +4. 系统 SHALL 允许该会话具备最少权限(ro `readFile`/`list`/`search` + `orchestrate`),保障 CEO 只做决策而不被要求高权限执行;具体执行下发到订阅生成的部门会话。 + +### R3 需求分解与部门派发 + +**User Story:** 作为 CEO, I want 支持用户需求分解为多个子任务并派发到多个部门(每个部门可指定不同模型/风格/工具集), 以便并行推进,并保证我能汇总。 + +#### Acceptance Criteria + +1. 系统 SHALL 提供 `orchestrate` 工具给 CEO,支持一次请求内描述「目标、拆分子任务、各自期望的 agent 名/模型/风格」,并创建一批对应部门会话(占 concurrent 预算)。 +2. 每个部门会话 SHALL 是独立子会话,继承「governance」但工具集按 AgentDefinition 裁剪;部门间在 CEO 明确转达前不可直接互读结果(保持单向)。 +3. CEO SHALL 能显式指定某个子部门使用不同的 provider/model/reasoningEffort(与 `task` 的 `agent=` 复用解析逻辑)。 +4. IF 某部门被派发时所用风格/模型不可用(provider 未配置/模型缺失),the system SHALL 返回明确错误并提示 CEO 改派而非静默回退。 + +### R4 评审团(Jury)辩论与裁决 + +**用户故事:** 作为甲方, I want 每次核心改动经过评审团多立场审查, SO THAT 大改不踩雷。 + +#### Acceptance Criteria + +1. 系统 SHALL 提供 `orchestrate(phase=review)`,由 CEO 对「已产生的改动集」(diff/文件清单)成立 3 名评审员子代理(`review-tough` 严苛型、`review-pragmatic` 务实型、`review-optimist` 建设型),各自只读审查并产出问题清单。 +2. 系统 SHALL 成立评审长(`forehead`)子代理,读三份评论后给出裁决:`approved`(放行)/ `revork`(返工,带必须修项)/ `switch_style`(换风格重跑)。 +3. WHNF 裁决为 `approved`, CEO SHALL 进入最终对甲方汇报;IF `rework`/`switch_style`, CEO SHALL 据裁决再派发部门执行后重新评审(循环有上限,防死循环)。 +4. 评审全程只读:评审团角色不持有 `writeFile`/`editFile` 权限,仅 `readFile`/`list`/`search`/`readExtern` 类只读工具。 + +### R5 双执行风格 + +**User Story:** 开发风格提供新旧双轨, 由 CEO 按需求/甲方偏好取舍。 + +#### Acceptance Criteria + +1. 系统 SHALL 提供两个部门角色:`exec-conservative`(遵循现有代码风格、最小 diff、保持项目惯例)与 `exec-modern`(用革新/现代化的格式与最佳实践重写)。两者共享同一任务描述但风格指引不同。 +2. CEO 可选择「单风格执行」或「并行双风格执行 + 对比」交由用户/评审们取舍。 +3. IF 并行双风格执行,the system SHALL 保证两部门从同一基线、同一目标文档开始,产物放到各自会话以供对比。 +4. 风格差异来自各自 AgentDefinition.prompt(角色头 + 风格指引),不新增全局「风格参数」,以减少对 AgentDefinition 解析器与表单的破坏。 + +### R6 对甲方汇报与交互界面 + +**User Story:** 作为甲方, 我要随时看到整场「公司运作」的直观视图(CEO/各部门/评审团状态、裁决、各自结论、消耗), 以便理解和指导. + +#### Acceptance Criteria + +1. God Mode 会话 UI SHALL 提供「战情室(War Room)」视图,列出各部门/评审团子会话的卡片:状态(running/finished/failed)、所属模型、产出摘要、裁决结果。 +2. 系统 SHALL 在消息流中用品牌标识区分来源:CEO 主气泡、部门/评审团作为可展开卡片,不混用普通 AI 气泡的样式。 + +### R7 资源(额度)治理 + +**User Story:** 作为甲方投资者, 我关心整场协作的资源用法(消耗 / 预算 / 健康)。 + +#### Acceptance Criteria + +1. God mode SHALL 聚合父会话 + 所有子部门/评审会话的 cost(input/output/cached tokens)汇总为该场协作的额度消耗(会话维度)。 +2. WHNF 会话余额耗尽(预算回调功), the system SHALL 自动中断编排,把「触达预算、已产出成果位置、可恢复方式」消息给 CEO,CEO 汇报用户并将模式回落到 BUILD。 +3. 预算默认 0(不限额);用户可在会话设置无条件设定限额。 + +### R8 资产同步与文档 + +1. 新增 `exec-conservative`, `exec-modern`, `review-tough`, `review-pragmatic`, `review-optimist`, `forehead` 等角色定义 SHALL 放到 `app/src/main/assets/agents/`(首次启动按现有逻辑释放到 `~/.aicode/agents/`)。 +2. `60-tools-and-paths.md` 的「子代理工具」节/「模式」文档 SHALL 增补 `orchestrate` 工具、风格与评审团说明;`docs-site/docs/guide/subagent.md` 与应用模式文档同步。 +3. 模式选择 UI、战情室字符串均在 `values/strings.xml` 与 `values-en/strings.xml` 双语提供。 \ No newline at end of file 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/receive-share/design.md b/.monkeycode/specs/receive-share/design.md new file mode 100644 index 00000000..89093ff8 --- /dev/null +++ b/.monkeycode/specs/receive-share/design.md @@ -0,0 +1,221 @@ +# 接收分享(Receive Share) + +Feature Name: receive-share +Updated: 2026-09-17 + +## Description + +为 MainActivity 增加 `ACTION_SEND` 接收能力,让用户从浏览器、代码编辑器、剪贴板工具等外部 App 通过系统分享菜单把选中的文本或图片发给 AiCode。App 冷启动时新建会话并注入内容,热启动时注入当前会话输入框(或无会话时新建)。内容一律填入输入框待用户确认发送,不自动触发,保留用户补充指令的空间。 + +## Architecture + +### 关键约束 + +`agentViewModel` 在 `AppNavigation` composable 内经 `hiltViewModel()` 创建(`MainActivity.kt:355`),Activity 层无法直接持有或调用。故采用「Activity 级 SharedFlow 桥 + Composable 观察」模式:Activity 解析 Intent 得文本/Uri,写入一个 Activity 持有的 `MutableStateFlow`;`AppNavigation` 用 `collectAsStateWithLifecycle` 观察该流,回调里调 `viewModel.updateInputDraft(...)` 注入文本、调 ViewModel 新增的注入入口处理图片,然后清空载荷。 + +### 数据流 + +```mermaid +flowchart LR + Ext["外部 App 分享菜单"] -->|"ACTION_SEND + EXTRA_TEXT/STREAM"| Act["MainActivity"] + Act -->|"onCreate/onNewIntent"| Parse["parseShareIntent(intent)"] + Parse -->|"MutableStateFlow SharePayload"| Bridge["Activity 级共享流"] + Bridge -->|"collectAsStateWithLifecycle 观察"| Nav["AppNavigation composable"] + Nav -->|"冷启动: newSession() + updateInputDraft"| VM["AIAgentViewModel"] + Nav -->|"热启动: updateInputDraft 追加"| VM + Nav -->|"图片: injectImageUri(uri)"| VM + VM -->|"inputDraft 流变化"| Panel["AIChatPanel LaunchedEffect 回填 inputText"] + Panel --> ChatInputBar["ChatInputBar 输入框"] +``` + +### 时序:冷启动注入文本 + +```mermaid +sequenceDiagram + participant Ext as 外部 App + participant Act as MainActivity + participant Bridge as Activity SharedFlow + participant Nav as AppNavigation + participant VM as AIAgentViewModel + participant Panel as AIChatPanel + + Ext->>Act: ACTION_SEND (EXTRA_TEXT) + Act->>Act: onCreate 解析 intent + Act->>Bridge: emit SharePayload(text=...) + Act->>Act: setContent + Note over Act,Nav: AppNavigation 首次组合 + Nav->>Bridge: collectAsStateWithLifecycle + Nav->>VM: newSession() + VM->>VM: createAndUpsertSession + _currentSessionId + Nav->>VM: updateInputDraft(text) + VM->>Panel: inputDraft 流发新值 + Panel->>Panel: LaunchedEffect 回填 inputText + 聚焦 + Nav->>Bridge: 消费后清空载荷 +``` + +## Components and Interfaces + +### 1. MainActivity intent-filter(manifest) + +`app/src/main/AndroidManifest.xml` 的 MainActivity 增设: + +```xml + + + + + + + + + + + + +``` + +- `launchMode="singleTop"`:已运行时收到分享走 `onNewIntent` 而非重建实例,保住终端/编辑器/聊天现场。 +- 同时支持 `text/plain` 与 `image/*`,由 manifest 按 MIME 路由到本 Activity。 + +### 2. SharePayload 数据模型 + +Activity 级持有,纯内存态: + +```kotlin +internal data class SharePayload( + val text: String?, // EXTRA_TEXT 解析结果(已截断至上限) + val imageUris: List, // EXTRA_STREAM / clipData 解析结果 + val receivedAt: Long, // 接收时间戳,用于去重 +) +``` + +### 3. MainActivity 解析与桥 + +```kotlin +private val pendingShare = MutableStateFlow(null) + +override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) // 让后续 getIntent 拿到最新 + handleShareIntent(intent) +} + +override fun onCreate(savedInstanceState: Bundle?) { + // ... 现有逻辑 ... + handleShareIntent(intent) // 冷启动入口,放在 setContent 之前或之后均可 +} + +private fun handleShareIntent(intent: Intent?) { + if (intent?.action != Intent.ACTION_SEND) return + val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.take(MAX_TEXT_CHARS) + val uris = mutableListOf() + intent.getParcelableExtraCompat(Intent.EXTRA_STREAM)?.let { uris.add(it) } + // ClipData 多选场景(SEND_MULTIPLE 暂不在本范围,单选即可) + if (text.isNullOrBlank() && uris.isEmpty()) return // 无效内容静默忽略,不弹错误 + pendingShare.value = SharePayload(text, uris, System.currentTimeMillis()) +} +``` + +- `pendingShare` 是 Activity 字段,在 `setContent` 闭包内通过 `LocalSharePayload` CompositionLocal 暴露给 `AppNavigation`,或直接由 `AppNavigation` 经 `LocalContext.current as MainActivity` 取(更简洁但耦合 Activity 类型;推荐用 CompositionLocal 解耦)。 +- 用 `take(MAX_TEXT_CHARS)` 兜底超长,截断后在注入处补提示(见错误处理)。 + +### 4. AppNavigation 消费回调 + +`MainActivity.kt` 的 `AppNavigation` composable 内新增: + +```kotlin +val sharePayload by sharePayloadFlow.collectAsStateWithLifecycle(initialValue = null) +LaunchedEffect(sharePayload) { + val p = sharePayload ?: return@LaunchedEffect + // 冷启动/无当前会话:新建 + if (viewModel.currentSessionId.value == null) viewModel.newSession() + // 文本:追加到当前输入框(保留用户已打的内容,换行分隔) + if (!p.text.isNullOrBlank()) { + val cur = viewModel.inputDraft.value + viewModel.updateInputDraft( + if (cur.isBlank()) p.text else "$cur\n\n${p.text}" + ) + } + // 图片:走 ViewModel 新增的图片注入入口 + p.imageUris.forEach { viewModel.injectImageUri(it) } + // 消费完成 + clearSharePayload() +} +``` + +- 文本追加而非覆盖,呼应需求 5(多次分享合并)。 +- `currentSessionId` 为 null 时新建,覆盖冷启动与「前台无会话」两个分支。 + +### 5. AIAgentViewModel 新增图片注入入口 + +`AIAgentViewModel.kt` 新增(复用现有 `copyUriToWorkspace` 链路,避免在 ViewModel 外散落 UI picker 逻辑): + +```kotlin +fun injectImageUri(uri: Uri) = viewModelScope.launch { + val ws = _currentWorkspace.value + if (ws.isBlank()) return@launch + runCatching { + ChatAttachmentUtils.copyUriToWorkspace( + context = getApplication(), uri = uri, + workspacePath = ws, includeImageData = true + ) + }.getOrNull()?.let { file -> + _pendingInjectedAttachments.update { it + file.toPendingAttachment() } + } +} +``` + +- 复用 `copyUriToWorkspace`(`ChatAttachmentUtils.kt:180`)+ `toPendingAttachment()`(`:60`)。 +- 注入的附件写入一个 ViewModel 级 `_pendingInjectedAttachments: MutableStateFlow>`,由 `AIChatPanel` 观察并合并进本地 `pendingAttachments`(`AIChatPanel.kt:342`),复用现有累积与上限校验(`MAX_PENDING_ATTACHMENTS`)。 +- 失败静默忽略(图片加载失败需求 2.2),仅文本仍注入。 + +## Data Models + +| 名称 | 位置 | 说明 | +|------|------|------| +| `SharePayload` | MainActivity 级 internal data class | 文本 + Uri 列表 + 时间戳,纯内存 | +| `PendingUploadAttachment` | `ChatAttachmentUtils.kt:23` | 复用现有,附件累积载体 | +| `AgentImage` | `AgentMessage.kt:46` | 复用现有,发送时图像数据 | +| `_pendingInjectedAttachments` | AIAgentViewModel 新增 StateFlow | 桥接注入附件到 AIChatPanel | + +## Correctness Properties + +- **不自动发送**:注入只改 `inputDraft` 与 `pendingAttachments`,不调 `enqueueAgentRequest`。 +- **不丢文本**:`newSession()` 内部落库完成后才 `_currentSessionId.value = sid`,`updateInputDraft` 在 `currentSessionId` 非 null 后调用;草稿按 sessionId 持久化(`AIAgentViewModel.kt:209`),切换会话不丢。 +- **去重**:`pendingShare` 是单值 StateFlow,消费即清空;同一 Intent 重复投递时 `receivedAt` 相同但已被消费为 null,二次 set 相同值不会触发新 LaunchedEffect(StateFlow 去重)。 +- **不误覆盖**:文本追加用换行拼接,保留输入框已有内容。 +- **singleTop 不重建**:`launchMode="singleTop"` 保证已运行实例收到 `onNewIntent`,终端/编辑器/聊天现场不丢。 + +## Error Handling + +| 场景 | 处理 | +|------|------| +| 分享文本超 20000 字符 | `take(MAX_TEXT_CHARS)` 截断;注入后在输入框下方 Snackbar 展示 `share_text_truncated` 文案 | +| 文本与 Uri 均为空 | `handleShareIntent` 直接 return,不创建会话、不弹窗 | +| 图片 Uri 读取失败 | `copyUriToWorkspace` 抛异常被 `runCatching` 吞,仅注入文本部分;可选 Snackbar `share_image_load_failed` | +| ViewModel 未就绪(无工作区) | `injectImageUri` / `newSession` 内部判 `_currentWorkspace.isBlank()` 提前 return | +| App 在非聊天页(设置/终端)热启动收到分享 | `LaunchedEffect` 仍触发 newSession/updateInputDraft;聊天页是 startDestination,切回聊天页即可见。可选:同时 `navController.navigate("chat")` 主动回聊天页 | + +## Test Strategy + +- **单元测试(`GitRepositoryTest` 同级目录风格)**:`parseShareIntent` 纯函数化抽取,对各种 Intent(纯文本/图片 Uri/空/超长)做表驱动断言。把解析逻辑提成可独立测试的顶层函数 `internal fun parseShareIntent(intent: Intent): SharePayload?`,Activity 只做转发。 +- **集成验证**:编译通过后真机从浏览器分享一段文本,确认填入输入框、聚焦、未自动发送;从相册分享一张图,确认进附件区。 +- **回归**:确认 `launchMode="singleTop"` 不影响正常启动与终端保活;确认冷启动未带 SEND Intent 时行为不变(`handleShareIntent` 早 return)。 + +## References + +- `app/src/main/java/com/aicode/MainActivity.kt:175` — onCreate,intent 接入点 +- `app/src/main/java/com/aicode/MainActivity.kt:355` — AppNavigation 内创建 agentViewModel,决定需用桥而非直调 +- `app/src/main/java/com/aicode/MainActivity.kt:566` — NavHost,startDestination="chat" +- `app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt:1787` — newSession() +- `app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt:221` — updateInputDraft(text) +- `app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt:215` — inputDraft StateFlow +- `app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt:337` — 本地 inputText + LaunchedEffect 同步 +- `app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt:524` — handlePickedAttachments(uris, images) +- `app/src/main/java/com/aicode/feature/agent/presentation/component/ChatAttachmentUtils.kt:180` — copyUriToWorkspace +- `app/src/main/java/com/aicode/feature/agent/presentation/component/ChatAttachmentUtils.kt:60` — toPendingAttachment +- `app/src/main/java/com/aicode/feature/agent/presentation/component/ChatAttachmentUtils.kt:286` — MAX_PENDING_ATTACHMENTS +- `app/src/main/AndroidManifest.xml:29` — MainActivity 声明,新增 intent-filter 与 launchMode diff --git a/.monkeycode/specs/receive-share/requirements.md b/.monkeycode/specs/receive-share/requirements.md new file mode 100644 index 00000000..7c6acde6 --- /dev/null +++ b/.monkeycode/specs/receive-share/requirements.md @@ -0,0 +1,79 @@ +# Requirements Document + +## Introduction + +AiCode 当前的主入口仅"从启动器点击进入",移动端最高频的"在浏览器/IDE 看到一段报错或代码 → 系统分享菜单 → 发给编程助手分析"路径完全缺失(`ACTION_SEND` 0 引用)。本功能为 MainActivity 增加 `text/plain` 与 `image/*` 的分享接收能力,把外部文本或图片注入会话,让 AI 直接分析,补齐移动端主入口。 + +## Glossary + +- **分享来源**: 通过系统分享菜单(`ACTION_SEND`)发起分享的外部 App,如浏览器、代码编辑器、剪贴板工具。 +- **冷启动**: AiCode 进程未在前台运行时收到分享 Intent,需由该 Intent 拉起 App。 +- **热启动**: AiCode 已在前台运行时收到分享 Intent(经 `onNewIntent` 投递)。 +- **会话输入框**: 聊天页底部 `ChatInputBar` 的文本编辑区。 +- **注入**: 把分享文本填入会话输入框,待用户确认或编辑后发送,而非自动发出。 + +## Requirements + +### Requirement 1: 接收纯文本分享 + +**User Story:** AS 开发者,I want 把外部 App 选中的文本/报错通过系统分享菜单发给 AiCode,so that AI 能直接分析这段内容而免手动复制粘贴。 + +#### Acceptance Criteria + +1. WHEN 分享来源以 `ACTION_SEND` 携带 `EXTRA_TEXT` 且 MIME 为 `text/plain` 发起分享,AiCode SHALL 把该文本填入目标会话输入框。 +2. WHEN 分享文本超过 20000 字符,AiCode SHALL 仅保留前 20000 字符填入输入框并向用户展示截断提示。 +3. IF 分享 Intent 既无 `EXTRA_TEXT` 又无可解析的文本流,AiCode SHALL 展示"未接收到有效内容"提示且不创建会话、不崩溃。 + +### Requirement 2: 接收图片分享 + +**User Story:** AS 开发者,I want 把截图或图片分享给 AiCode,so that AI 能识图分析界面或报错画面。 + +#### Acceptance Criteria + +1. WHEN 分享来源以 `ACTION_SEND` 携带 `EXTRA_STREAM` 且 MIME 为 `image/*` 发起分享,AiCode SHALL 走现有附件上传流程把图片加入当前会话输入区待发送。 +2. IF 图片 URI 读取失败,AiCode SHALL 展示"图片加载失败"提示且保留已注入的文本内容。 + +### Requirement 3: 冷启动路由 + +**User Story:** AS 开发者,I want App 没开时也能从分享菜单直接拉起 AiCode,so that 不必先手动启动再粘贴。 + +#### Acceptance Criteria + +1. WHEN AiCode 进程未在前台时收到分享 Intent,AiCode SHALL 新建一个会话并把分享文本注入该会话输入框,随后导航到聊天页。 +2. WHILE 新建会话尚未落库完成,AiCode SHALL 在输入框展示分享文本,避免文本因会话初始化时序丢失。 + +### Requirement 4: 热启动注入 + +**User Story:** AS 开发者,I want App 已在前台时分享内容直接进当前会话,so that 能在既有上下文里继续追问。 + +#### Acceptance Criteria + +1. WHILE AiCode 已在前台且当前存在会话,收到分享 Intent,AiCode SHALL 把分享文本追加到当前会话输入框末尾。 +2. WHILE AiCode 已在前台但当前无会话(如在设置页),收到分享 Intent,AiCode SHALL 新建会话、注入文本并导航到聊天页。 + +### Requirement 5: 多次分享去重与合并 + +**User Story:** AS 开发者,I want 连续分享多段内容能合并而不是互相覆盖,so that 能把报错和代码分两次发进来一起分析。 + +#### Acceptance Criteria + +1. WHEN 2 秒内连续收到多份分享文本,AiCode SHALL 把各段以换行拼接追加到输入框,保留每段内容。 +2. WHEN 同一 Intent(相同 `EXTRA_TEXT` 与时间戳)在 5 秒内被重复投递,AiCode SHALL 只注入一次。 + +### Requirement 6: 分享后由用户确认发送 + +**User Story:** AS 开发者,I want 分享进来的内容先填入输入框让我编辑确认,so that 我能补一句指令(如"分析这段报错")再发给 AI。 + +#### Acceptance Criteria + +1. WHEN 分享文本注入输入框后,AiCode SHALL 聚焦输入框并保持待发送状态,由用户点击发送按钮触发。 +2. AiCode SHALL NOT 在注入后自动触发发送。 + +### Requirement 7: 用户可见中文文案 + +**User Story:** AS 用户,I want 分享相关提示用我的语言显示,so that 截断、加载失败等状态能看懂。 + +#### Acceptance Criteria + +1. WHEN 需展示分享相关提示,AiCode SHALL 从 `values/strings.xml` 与 `values-en/strings.xml` 引用 `stringResource`,不在 `.kt` 中硬编码中文 UI 文案。 +2. AiCode SHALL 为分享相关文案使用 `share_` 前缀的语义化英文小写下划线命名。 diff --git a/.monkeycode/specs/semantic-index/design.md b/.monkeycode/specs/semantic-index/design.md new file mode 100644 index 00000000..5fd83e1c --- /dev/null +++ b/.monkeycode/specs/semantic-index/design.md @@ -0,0 +1,160 @@ +# Semantic Index (本地关键词召回语义索引) + +Feature Name: semantic-index +Updated: 2026-09-18 + +## Description + +为 AiCode 会话中的 AI 增加 `semantic_search` 工具:用自然语言(如“登录鉴权怎么实现”)在 +当前工作区召回相关代码文件。实现为**本地 TF-IDF 关键词召回**:不调用远端 embedding、 +不发任何网络请求,索引进程内存态、懒构建 + 失效重建。 + +区别于既有工具: + +- `search`(rg):精确正则/子串文本匹配,要求记住模式; +- `list`:目录枚举; +- `semantic_search`:对自然语言问句分词后按相关度打分召回文件列表,容忍词面差异。 + +## Architecture + +```mermaid +graph TD + A["AI conversation"] -->|"calls semantic_search(query)"| B["SemanticSearchTool"] + B -->|"ensureFresh()"| C["SemanticIndexer"] + C -->|"buildIfStale() 全量遍历"| D["FileAccessProvider"] + D -.->|"本地 java.io.File"| L("LocalFileAccess") + D -.->|"SFTP"| R("RemoteSftpFileAccess") + C -->|"文档词频 + 统计"| E["InvertedIndex (内存)"] + B -->|"query 分词"| F["CodeTokenizer"] + F --> E + E -->|"TF-IDF 打分 top-N"| B + B -->|"候选 {path, snippet, score}"| A + + subgraph "纯函数(JVM 单测)" + F + G["Ranker: score(query, doc)"] + E + end +``` + +## Components and Interfaces + +### 1. `CodeTokenizer`(纯函数,JVM 可测) + +对代码/自然语言文本产出可检索词元: + +- 以非字母数字切分,忽略空白与符号; +- 下划线 / 中划线 / 点 / 驼峰边界分别拆词(`userLoginHandler` → `user`,`login`,`handler`;`auth_token` → `auth`,`token`); +- 全小写归一; +- 长度 ≥ 1(把单字母如 `e`/`i` 保留,代码变量常见); +- 对中文:按连续 CJK 段切为二元组(bigram)以支持中文注释/问句召回。 + +```kotlin +fun tokenize(text: String): List +``` + +### 2. `InvertedIndex`(纯函数,JVM 可测) + +内存倒排索引: + +```kotlin +class InvertedIndex { + // term -> docId -> 词频;内部统计 df、文档长度 + fun addDocument(docId: String, terms: List) + fun score(queryTerms: List, k: Int): List +} +data class ScoredDoc(val docId: String, val score: Double) +``` + +打分用 TF 归一(`1 + log(tf)`)× IDF(`log((N+1)/(df+1)) + 1`)累积,路径片段中的词元额外加权 +(文件名命中权重高,便于“猜文件”)。不引入新依赖,自实现。 + +### 3. `SemanticIndexer`(Worker,串行执行) + +- 状态:`地 — 工作区路径、索引快照(docId → mtime/size)、InvertedIndex`。 +- `ensureFresh()`:每次查询前执行—— + 1. 若索引未构建 → 全量构建; + 2. 若已构建 → 对比每个已索引文件与「新出现的 svg>文件」的快照(mtime/size)是否变化; + 有变化 → 全量重建。新文件出现(listFiles 遍历 diff)也触发。 +- `build()` 全量遍历:从 `workspaceRepository.currentPath()` 递归 `listFiles`, + 跳过 `~/.git`、`build`、`.gradle`、`node_modules` 目录,跳过二进制(按扩展名黑名单 + + 大小上限,单个 > 1 MB 跳过)。每个文本文件 `readLines` 流式分词入索引。 + - 防护:索引文件数上限(MAX_FILES = 8000)与时间预算,超限即截断继续(只索引已扫部分), + 保证超大仓库不卡死工具。 +- 运行在工具执行的调度上下文(挂起函数),用互斥锁保证「构建中再查询」排队等待而非并行构建。 + +### 4. `SemanticSearchTool`(Agent 工具) + +- 注册名 `semantic_search`;`permissionPolicy = AUTO_APPROVE`;capability `READ_WORKSPACE`; + 与 `search` 一致,无需审批。 +- 参数:`query`(字符串,必填)。 +- 返回:Top-N(默认 8)候选,每项 `path` + `snippet`(命中词附近窗口,本地从文件读)+ `score`; + 同时返回 `indexed_files`、`elapsed_ms`、`backend = "local-tfidf"`。 +- 错误码:空 query → `MISSING_QUERY`;远程构建失败 → `INDEX_UNAVAILABLE`。 + +### 5. 集成为点(DI / 注册) + +- `AgentModule.kt` 工具注册处新增 `register("semantic_search", semanticSearchTool)`; + 新增 Hilt 绑定提供 `SemanticIndexer`(注入 `FileAccessProvider`、`WorkspaceRepository`); + 组合对象方式与 `SearchCodeTool` 一致。 +- `SemanticIndexer` 通过 `FileAccessProvider` 后端,本地/远程模式自动生效(远程走 SFTP 读取)。 + +## Data Models + +无持久化、无 Room 迁移。全部内存态: + +```kotlin +data class DocSnapshot(val mtime: Long, val size: Long) + +class InvertedIndex { + private val termDict = mutableMapOf>() // term -> docId -> tf + private val docTerms = mutableMapOf() // docId -> total terms + val docs = mutableSetOf() +} +``` + +不落盘的原因:索引重建成本对单项目量级(几百~几千文件)在秒级,跨进程持久化引入增量一致性 +复杂度与迁移风险,收益不匹配(见 Requirements 决策记录)。 + +## Correctness Properties + +1. 同一工作区、同一文件内容下,相同 query 的召回结果稳定可复现(无随机)。 +2. 索引快照与文件真实 `mtime/size` 一致时才返回缓存结果;任何被索引文件变化或新文件出现 + 必然触发重建后再查询。 +3. TF-IDF 打分单调:query 词全部命中的文档得分 ≥ query 词无命中的文档得分(无命中文档不返回)。 +4. 并发安全:构建期间并发查询排队,不出现两个并行构建或读到半成品索引。 +5. 隐私性与无外发:索引构建过程零网络调用,纯 CPU 与本地/SSH 文件读取。 + +## Error Handling + +| 场景 | 行为 | +| -- | -- | +| query 为空 / 仅空白 | 工具返回 `MISSING_QUERY` 错误,不查索引 | +| 某个文件读取失败 | 跳过该文件记日志,继续构建其余文件,不中断整体 | +| 工作区不存在 / 为空 | 索引为空,返回「未索引任何文件」的提示性结果而非异常 | +| 远程 SSH 构建时连接中断 / 权限不足 | 工具返回 `INDEX_UNAVAILABLE` 错误 | +| 文件超过大小上限 / 二进制 | 跳过不索引 | +| 索引文件数超过 MAX_FILES | 截断,仅索引已扫描部分并注明 `truncated=true` | + +## Test Strategy + +纯 JVM 单元测试(沿用现有 `feature/agent` 测试目录风格,不依赖 Robolectric): + +1. **CodeTokenizerTest**:驼峰/下划线/点拆分、全小写、符号忽略、中文 bigram、边界(空串、全符号)。 +2. **InvertedIndexTest**:单文档多 term 计数;跨文档 IDF 变化影响排序;词命中多于零次才返回; + 文件名列加权;同一 query 结果稳定。 +3. **SemanticIndexerTest**:用临时目录 + `LocalFileAccess` 真实构建—— + - 首次调用触发构建并返回结果; + - 修改既有文件 + 新增文件后再次查询返回新内容(失效重建); + - 二进制/超大/`.git`/`build` 目录被跳过; + - 文件读取失败不中断。 +4. **工具的注册与参数校验**:空 query 返回 `MISSING_QUERY`(沿用现有工具测试模式, + 如 `SearchCodeTool` 的解析逻辑若要测需容器,索引相关纯逻辑抽离即可测)。 + +## References + +[^1]: (app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt#L34) - `FileAccessProvider` 统一本地/SFTP 后端接口 +[^2]: (app/src/main/java/com/aicode/feature/workspace/domain/LocalFileAccess.kt#L26) - `readFile` / `readLines` 实现 +[^3]: (app/src/main/java/com/aicode/feature/agent/domain/tool/explorer/SearchCodeTool.kt#L36) - `search` 工具(rg),`semantic_search` 参照其注册与权限模型 +[^4]: (app/src/main/java/com/aicode/di/AgentModule.kt#L297) - 工具注册点 `register("search", searchCodeTool)` +[^5]: (app/src/main/java/com/aicode/feature/agent/domain/tool/explorer/ListFilesTool.kt) - 目录枚举工具,`semantic_search` 的兄弟工具 \ No newline at end of file diff --git a/.monkeycode/specs/semantic-index/requirements.md b/.monkeycode/specs/semantic-index/requirements.md new file mode 100644 index 00000000..84086da4 --- /dev/null +++ b/.monkeycode/specs/semantic-index/requirements.md @@ -0,0 +1,65 @@ +# Requirements Document: semantic-index + +## Introduction + +为 AiCode 提供语义代码检索能力:AI agent 可用自然语言(如“服务入口在哪”“登录鉴权怎么实现”) +在工作区内召回相关代码文件,而无需精确记忆文件路径或函数名。本地关键词(TF-IDF)召回, +零模型外发、零网络依赖、离线可用,区别于既有 `search`(rg 精确文本匹配)与 `list`(目录列举)。 + +## Glossary + +- **System**: AiCode Android 应用的 AI 对话 + Agent 工具执行层。 +- **semantic_search 工具**: 向 Agent 暴露的自然语言语义检索工具。 +- **索引(index)**: 工作区内文本文件的倒排关键词表,支持相关度打分。进程内内存态,不持久化。 +- **失效(invalidation)**: 检测到工作区文件变更后,把既有索引标记为陈旧并触发重建。 + +## Requirements + +### Requirement 1: 提供语义检索工具 + +**User Story:** AS 会话中的 AI, I want 按自然语言召回相关代码文件, SO THAT 不用记住精确路径即能找到实现。 + +#### Acceptance Criteria + +1. 系统 SHALL 为 Agent 注册名为 `semantic_search` 的工具,暴露参数 `query`(自然语言问句、必填)。 +2. 工具执行 SHAR 返回按相关度降序、数量有限(默认 ≤ 8)的候选,候选含文件路径与命中片段。 +3. WHEN 查询为空或仅空白,系统 SHALL 返回带 `MISSING_QUERY` 错误码的失败结果,不查索引。 + +### Requirement 2: 索引懒构建 + +**User Story:** AS 会话中的 AI, I want 无需手动初始化索引, that 首次使用即自动可用。 + +#### Acceptance Criteria + +1. WHEN `semantic_search` 首次被调用, the system SHALL 对当前工作区执行全量索引构建后返回结果。 +2. 索引构建 SHALL 跳过二进制文件、`.git`、`build`、`.gradle`、`node_modules` 等非代码目录。 +3. 文本文件 SHALL 在可读后按内容纳入索引,读取失败的文件 SHALL 被跳过而不中断整体构建。 + +### Requirement 3: 索引新鲜度 + +**User Story:** 工作区代码变化后, I want 检索反映最新内容, that 不返回陈旧命中。 + +#### Acceptance Criteria + +1. 每次查询前 the system SHALL 检测工作区感兴趣文件的 `mtime`/`size` 相对上次构建是否变化。 +2. IF 任一被索引文件发生变更, the system SHALL 使既有索引失效并在本次查询前重建。 +3. 索引构建 SHALL NOT 阻塞并发(在执行中尝试查询时走串行排队,而非并行构建)。 + +### Requirement 4: 纯本地运行与隐私 + +**User Story:** 用户, I want 代码不出本机, that 可离线环境放心使用。 + +#### Acceptance Criteria + +1. 索引构建与检索 SHALL 全部在本进程内完成,不发起任何网络请求,不调用任何远端 embedding。 +2. 构建 SHALL NOT 依赖任一 provider API key 或 GOCT 连接状态。 +3. IF provider 未配置,THE `semantic_search` 工具 SHALL 依然返回结果。 + +### Requirement 5: 远程模式行为 + +**User Story:** 使用远端 SSH 工作区时, I want 语义检索有明确约定, that 不静默失败。 + +#### Acceptance Criteria + +1. IF 当前工作区为远程 SSH 模式, the system SHALL 尝试通过远端文件读取构建索引。 +2. IF 远程索引构建因连接中断或权限问题失败, the system SHALL 返回带 `INDEX_UNAVAILABLE` 的错误给 Agent。 \ No newline at end of file diff --git a/.monkeycode/specs/session-fork/design.md b/.monkeycode/specs/session-fork/design.md new file mode 100644 index 00000000..0f0bb299 --- /dev/null +++ b/.monkeycode/specs/session-fork/design.md @@ -0,0 +1,96 @@ +# 对话分叉(Session Fork)— 技术设计 + +Feature Name: session-fork +Updated: 2026-09-17 + +## Description + +消息操作菜单新增「在此分叉新会话」:ViewModel `forkSessionAt(messageId)` 读取原会话全量消息,锚点及之前的消息复制进新会话(重生成 id + 替换 sessionId),切换当前会话。纯逻辑功能,无数据库迁移。 + +## Architecture + +```mermaid +flowchart LR + Msg["长按消息 → MessageActionsBottomSheet"] -->|"ForkSession 菜单项"| Panel["AIChatPanel"] + Panel -->|"viewModel.forkSessionAt(messageId)"| VM["AIAgentViewModel"] + VM -->|"getMessagesBySessionOnce"| DAO["AgentMessageDao"] + VM -->|"buildForkedMessages 纯函数"| Copy["新实体列表"] + VM -->|"createAndUpsertSession + insertAll"| DB["Room"] + VM -->|"_currentSessionId = 新会话"| PanelUI["UI 切换到新会话"] +``` + +## Components and Interfaces + +### 1. buildForkedMessages 纯函数(顶层,AIAgentViewModel.kt 内) + +```kotlin +internal fun buildForkedMessages( + source: List, + anchorId: String, + newSessionId: String, +): List +``` + +- 定位锚点 `indexOfFirst { it.id == anchorId }`,找不到返回空列表。 +- 截取 `[0, anchorIndex]` 闭区间。 +- 复制 = 原实体 `copy(id = 新UUID, sessionId = newSessionId)`,其余字段原样保留(content/role/timestamp/toolName/toolArgs/isError/attachmentsJson/reasoning/token 等)。 +- 时间戳保持原值 → 新会话内顺序与原会话一致。 +- id 用 `java.util.UUID.randomUUID().toString()`。 + +### 2. AIAgentViewModel.forkSessionAt + +```kotlin +fun forkSessionAt(messageId: String) = viewModelScope.launch { + val curId = _currentSessionId.value ?: return@launch + val ws = _currentWorkspace.value + if (ws.isBlank()) return@launch + val source = agentMessageDao.getMessagesBySessionOnce(curId) + val newSessionId = createAndUpsertSession(ws) // 复用既有创建逻辑(绑定默认模型) + val forked = buildForkedMessages(source, messageId, newSessionId) + if (forked.isEmpty()) { + sessionUseCase.deleteSession(newSessionId) // 锚点无效则回滚空会话 + return@launch + } + agentMessageDao.insertAll(forked) + // 沿用原会话标题,便于识别分叉来源 + val origTitle = sessionUseCase.getSessionById(curId)?.title + if (!origTitle.isNullOrBlank()) sessionUseCase.updateTitle(newSessionId, origTitle) + _currentSessionId.value = newSessionId +} +``` + +### 3. MessageActionsBottomSheet 新菜单项 + +在编辑、复制之间插入 `MessageActionItem(icon = FeatherIcons.GitBranch, title = chat_action_fork)`,回调 `onForkClick()`。AIChatPanel 调用处(AIChatPanel.kt:1390)传 `onForkClick = { viewModel.forkSessionAt(message.id) }` + Toast 成功提示。 + +## Correctness Properties + +- 只在新会话插入复制消息,原会话消息零改动(copy 新实体 + insertAll,无 UPDATE/DELETE)。 +- 锚点无效(找不到)→ 不落任何数据,回滚新建的空会话。 +- 空源会话(无消息)→ buildForkedMessages 空列表 → 同上回滚,不发生静默空会话。 +- 复制消息时间戳原样 → 新会话渲染顺序与源一致。 +- title 沿用仅在源有标题时执行。 + +## Error Handling + +| 场景 | 处理 | +|------|------| +| 无当前会话 / 无工作区 | forkSessionAt 直接 return | +| 锚点消息不存在 | 空列表 → 删除新建会话,无副作用 | +| insertAll 失败 | 异常记 FileLogger 并 newSession 空会话留在列表(罕见),不崩溃 | + +## Test Strategy + +`SessionForkTest`(纯 JVM,AgentMessageEntity 纯数据类): +- 复制到锚点闭区间,锚点之后排除 +- id 全部重生成且唯一,sessionId 替换为新值 +- content/role/timestamp/token/附件字段原样 +- 锚点找不到返回空列表 + +## References + +- `app/src/main/java/com/aicode/feature/agent/presentation/component/MessageActionsBottomSheet.kt:39` — 菜单组件 +- `app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt:1390` — 菜单触发接线 +- `app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt:1788` — newSession 模式 +- `app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt:2314` — createAndUpsertSession +- `app/src/main/java/com/aicode/feature/agent/data/local/dao/AgentMessageDao.kt:12` — insertAll \ No newline at end of file diff --git a/.monkeycode/specs/session-fork/requirements.md b/.monkeycode/specs/session-fork/requirements.md new file mode 100644 index 00000000..fff2d1a5 --- /dev/null +++ b/.monkeycode/specs/session-fork/requirements.md @@ -0,0 +1,40 @@ +# Requirements Document + +## Introduction + +长对话里想从某条消息换一条思路探索时,目前只能新建空会话重打上下文,浪费 token 且丢了上文。本功能在消息操作菜单增加「在此分叉新会话」:以该消息为锚点创建新会话,把锚点及之前的所有消息复制为新会话的初始上下文,用户可以接着继续问,原会话不受影响。 + +## Glossary + +- **分叉**: 从现有会话的某条消息处派生出新会话。 +- **锚点消息**: 用户长按选择的那条消息;新会话复制它及之前的所有消息。 +- **复制**: 原会话消息物理复制到新会话(改 id 与 sessionId,内容不变),原会话消息保持原样。 + +## Requirements + +### Requirement 1: 分叉入口 + +**User Story:** AS 开发者,I want 长按一条消息选「在此分叉新会话」,so that 从这条消息起开一条新思路而不用重打上文。 + +#### Acceptance Criteria + +1. WHEN 用户在任意消息上长按打开操作菜单,AiCode SHALL 提供「在此分叉新会话」菜单项。 +2. WHEN 用户点击该菜单项,AiCode SHALL 关闭菜单、创建新会话并切换当前会话到新会话。 + +### Requirement 2: 上下文复制 + +1. WHEN 分叉执行,新会话 SHALL 复制锚点消息及之前所有消息(含锚点),并按原顺序排布。 +2. 复制后的消息 SHALL 保持原内容、角色、附件、token 统计、工具调用记录与时间顺序。 +3. 锚点之后的消息 SHALL 不被复制。 +4. 原会话及其全部消息 SHALL 保持不变。 + +### Requirement 3: 新会话身份 + +1. 新会话 SHALL 绑定当前工作区与当前默认模型(沿用 createSession 既有逻辑)。 +2. 新会话标题 SHALL 沿用原会话标题,便于识别是从哪条对话分叉而来。 +3. 新会话 SHALL 作为独立会话出现在会话列表,不关联原会话的后续消息。 + +### Requirement 4: 反馈与文案 + +1. WHEN 分叉成功,AiCode SHALL 展示「已在此处分叉新会话」提示。 +2. 菜单文案 SHALL 走双语 strings.xml(`chat_action_fork`)。 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/.monkeycode/specs/voice-input/design.md b/.monkeycode/specs/voice-input/design.md new file mode 100644 index 00000000..e53f9616 --- /dev/null +++ b/.monkeycode/specs/voice-input/design.md @@ -0,0 +1,112 @@ +# 语音输入(Voice Input) + +Feature Name: voice-input +Updated: 2026-09-17 + +## Description + +聊天输入框工具栏新增麦克风按钮,内嵌系统 `SpeechRecognizer` 实现实时听写:点击开始、部分结果实时填入输入框、再点停止定稿并追加到基线文本之后。RECORD_AUDIO 运行时权限首用申请,拒绝或设备不支持时友好提示。 + +## Architecture + +SpeechRecognizer 生命周期与状态全部内聚在 `AIChatPanel`(与附件、picker 管理同级),`ChatInputBar` 只增加受控入参(`isListening` / `onToggleVoice`),保持其纯受控组件定位。部分结果只更新 AIChatPanel 本地 `inputText` 做预览,定稿时经 `onValueChange` 走既有草稿落盘链路,避免高频 SharedPreferences 写入。 + +```mermaid +flowchart LR + Mic["麦克风按钮 ChatInputBar"] -->|"onToggleVoice"| Panel["AIChatPanel"] + Panel -->|"无权限"| Perm["RequestPermission launcher"] + Perm -->|"granted"| Start["startListening()"] + Panel -->|"有权限"| Start + Start --> SR["SpeechRecognizer + RecognitionListener"] + SR -->|"onPartialResults"| Preview["inputText = base + partial(仅预览)"] + SR -->|"onResults"| Final["onValueChange(base + final) 走草稿落盘"] + SR -->|"onError"| Toast["chat_voice_error Toast"] +``` + +## Components and Interfaces + +### 1. Manifest 权限 + +```xml + +``` + +### 2. ChatInputBar 新增受控入参 + +```kotlin +isListening: Boolean = false, +onToggleVoice: () -> Unit = {}, +``` + +按钮插在 UploadIconButton(+) 与优化按钮之间:听写中显示 Stop 图标(primary 色)+ `chat_voice_listening` contentDescription,空闲显示 Mic 图标。 + +### 3. AIChatPanel 语音控制器 + +`remember` 持有: + +```kotlin +var isListening by remember { mutableStateOf(false) } +var voiceBaseText by remember { mutableStateOf("") } +val speechRecognizer = remember { + if (SpeechRecognizer.isRecognitionAvailable(context)) + SpeechRecognizer.createSpeechRecognizer(context) else null +} +DisposableEffect(Unit) { onDispose { speechRecognizer?.destroy() } } +``` + +- `startListening()`:记 `voiceBaseText = inputText`,构造 `RecognizerIntent.ACTION_RECOGNIZE_SPEECH`(`EXTRA_LANGUAGE_MODEL_FREE_FORM`、`EXTRA_PARTIAL_RESULTS = true`、语言跟随系统默认),setRecognitionListener: + - `onPartialResults`:`inputText = mergeDraft(voiceBaseText, partial)` + - `onResults`:`isListening = false`;`onValueChange(mergeDraft(voiceBaseText, final))`(合并函数确保追加时以空白分隔、去重 trailing 空白) + - `onError`:`isListening = false`;ERROR_NO_MATCH/ERROR_SPEECH_TIMEOUT 提示未听清,其余 `chat_voice_error` + - `onEndOfSpeech`:保持 listening 状态等 results +- `toggleVoice()`:listening 中 → `stopListening()`;否则查权限 → 无则 launcher.launch(RECORD_AUDIO),有则 `startListening()`。 +- 权限 launcher:granted → `startListening()`;denied → Toast `chat_voice_permission_denied`。 +- `speechRecognizer == null`(设备不支持)→ Toast `chat_voice_unavailable`。 + +### 4. mergeDraft 合并函数 + +```kotlin +private fun mergeDraft(base: String, recognized: String): String = + buildString { + append(base.trimEnd()) + if (isNotEmpty() && recognized.isNotBlank()) append(' ') + append(recognized.trim()) + } +``` + +追加以空白分隔,保留基线内容(需求 3.1)。 + +## Data Models + +| 名称 | 位置 | 说明 | +|------|------|------| +| `isListening` / `voiceBaseText` | AIChatPanel 本地 state | 听写状态与基线快照 | +| `SpeechRecognizer` | AIChatPanel remember + DisposableEffect | 系统识别器,随组合销毁 | + +## Correctness Properties + +- 部分结果仅改本地 `inputText`,`inputDraft` 在定稿前保持不变 → 无高频 SharedPreferences 写入。 +- `LaunchedEffect(inputDraft)` 仅在 draft 变化时覆写 `inputText`;听写中 draft 未变,预览安全。 +- recognizer 随组合销毁(DisposableEffect),语言切换 recreate 亦不泄漏。 +- 停止后 `voiceBaseText` 重新快照,连续听写可叠加。 + +## Error Handling + +| 场景 | 处理 | +|------|------| +| 权限拒绝 | Toast `chat_voice_permission_denied`,输入框不变 | +| 设备不支持 | Toast `chat_voice_unavailable`,按钮仍在(可重复尝试) | +| ERROR_NO_MATCH / SPEECH_TIMEOUT | Toast `chat_voice_no_match`(未听清),恢复空闲 | +| 其他 ERROR_* | Toast `chat_voice_error`,恢复空闲 | +| recognizer 已销毁后回调 | `isListening` 幂等置 false,UI 无副作用 | + +## Test Strategy + +- `mergeDraft` 纯函数单测(基线空/非空、识别空、首尾空白)。 +- 真机验证:点击→说话→实时出字→停止定稿追加;拒绝权限→提示;听写中离开页面→无泄漏崩溃。 + +## References + +- `app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt:414` — 工具栏按钮区,麦克风插在 + 之后 +- `app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt:337` — inputText 本地 state +- `app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt:96` — RequestPermission launcher 既有模式 diff --git a/.monkeycode/specs/voice-input/requirements.md b/.monkeycode/specs/voice-input/requirements.md new file mode 100644 index 00000000..b375e2cc --- /dev/null +++ b/.monkeycode/specs/voice-input/requirements.md @@ -0,0 +1,47 @@ +# Requirements Document + +## Introduction + +移动端单手、通勤、走路场景手写需求与代码体验差。本功能为聊天输入框增加系统语音听写(`SpeechRecognizer`)入口:点麦克风开始、原地实时出字、再点停止结束,识别文本追加进输入框待用户确认发送。 + +## Glossary + +- **听写**: SpeechRecognizer 连续识别用户语音并实时产出文本的过程。 +- **部分结果**: 识别过程中未定稿的中间文本(`onPartialResults`),用于实时预览。 +- **基线文本**: 开始听写时输入框已有的内容,识别结果追加在其后。 + +## Requirements + +### Requirement 1: 启动/停止听写 + +**User Story:** AS 开发者,I want 点一下麦克风开始说话再点一下结束,so that 手不动键盘也能把需求说进输入框。 + +#### Acceptance Criteria + +1. WHEN 用户点击麦克风按钮且已授予 RECORD_AUDIO 权限,AiCode SHALL 启动 SpeechRecognizer 连续听写。 +2. WHEN 听写进行中用户再次点击该按钮,AiCode SHALL 停止听写并把最终识别文本追加到基线文本之后。 +3. WHILE 听写进行中,AiCode SHALL 把部分结果实时追加在基线文本后展示于输入框,停止时以最终结果替换部分结果。 + +### Requirement 2: 权限与可用性 + +**User Story:** AS 开发者,I want 首次用语音时 App 自己处理权限,so that 流程不被打断。 + +#### Acceptance Criteria + +1. IF RECORD_AUDIO 未授予,WHEN 用户点击麦克风,AiCode SHALL 先发起运行时权限申请,授予后自动开始听写。 +2. IF 用户拒绝权限,AiCode SHALL 展示提示文案且保持输入框内容不变。 +3. IF 设备 SpeechRecognizer 不可用(`isRecognitionAvailable == false`),WHEN 用户点击麦克风,AiCode SHALL 展示不支持提示且不崩溃。 + +### Requirement 3: 文本合并与保留 + +#### Acceptance Criteria + +1. WHEN 听写结束产出文本,AiCode SHALL 以空白分隔把识别文本追加到基线文本末尾,保留用户已输入内容。 +2. AiCode SHALL 覆写前保留基线文本,听写期间用户手动编辑输入框的行为 SHALL 以基线文本 + 最新识别结果为准重新合并。 + +### Requirement 4: 生命周期与双语文案 + +#### Acceptance Criteria + +1. WHEN 离开聊天页或 Activity 销毁,AiCode SHALL 销毁 SpeechRecognizer 实例,杜绝泄漏。 +2. WHEN 展示语音相关提示,AiCode SHALL 从双语 `strings.xml` 引用 `chat_voice_` 前缀文案。 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..702275d6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + @@ -36,6 +38,12 @@ + + + + + + @@ -71,6 +79,11 @@ android:enabled="true" android:exported="false" android:foregroundServiceType="dataSync" /> + + diff --git a/app/src/main/assets/agents/docs-writer.md b/app/src/main/assets/agents/docs-writer.md new file mode 100644 index 00000000..a0ad96cc --- /dev/null +++ b/app/src/main/assets/agents/docs-writer.md @@ -0,0 +1,29 @@ +--- +name: Docs-writer +description: 编写或更新技术文档:读代码后写 API 说明、模块 README、使用指南。产出文档文件,面向读者而非作者。派它前在 prompt 里写清文档类型、目标读者与落盘路径。 +tools: [readFile, list, search, writeFile, editFile] +inject: [base, projectRules] +--- +你是技术文档工程师。你的产出是**文档文件**,讲清怎么做、会看到什么、出错怎么办,面向读者而非作者自己。 + +## 你的处境 + +你只有本会话第一条消息的指令。文档类型(API 说明/模块 README/使用指南)、目标读者(开发者/用户)、落盘路径,指令里写清;没写清的按最可能的来,并在文档头部注明假设。 + +## 怎么做 + +1. 先 `readFile`/`list`/`search` 读懂要文档化的代码:对外接口、数据流、依赖、约束。文档必须基于真实代码,不要凭文件名或记忆编造——拿不准的标「待核实」。 +2. 讲清三件事: + - **怎么做**:可照抄执行的步骤。 + - **会看到什么**:预期结果与输出。 + - **出错怎么办**:常见错误与解法。 + 变量名、错误码、内部实现路径属于代码注释,不写进面向用户的文档。 +3. 落盘路径与文件名跟项目既有文档约定一致(`search` 既有 `.md` 找规律)。用 `writeFile`/`editFile` 写。 +4. 用户可见中文文案相关的文档改动,提醒主代理同步双语 `strings.xml`(若涉及 UI 文案)。 + +## 产出 + +- 文档文件(已落盘)。 +- 一句话说明文档类型、目标读者、落盘路径。 +- 若文档涉及接口或行为变化,提醒主代理检查 `prompts/` 与既有文档是否需同步。 +- 开头直接给结论,不复述任务。 diff --git a/app/src/main/assets/agents/exec-conservative.md b/app/src/main/assets/agents/exec-conservative.md new file mode 100644 index 00000000..bd0abc0a --- /dev/null +++ b/app/src/main/assets/agents/exec-conservative.md @@ -0,0 +1,24 @@ +--- +name: exec-conservative +description: 保守风格执行部门:以最小改动、严格遵循现有代码风格与项目惯例的方式实现功能/修复 Bug。维护者可读性优先,不引入与现状风格不一致的新写法。God Mode 下由 CEO/总汇报员经 orchestrate 派发。派它前在 prompt 里写清任务目标、涉及文件与验收标准。 +tools: [readFile, list, search, writeFile, editFile, sendFile] +inject: [base, projectRules] +--- +你是保守风格执行部门。你的价值观:**改动最小、尊重现有惯例、让 review 挑不出「与项目不一致」的毛病**。 + +## 你的处境 + +你只看到本会话第一条消息的指令——那是 CEO/总汇报员派发的子任务。你看不到主对话历史、CEO 的推理,也看不到其他部门在做什么。 + +## 怎么执行 + +1. `readFile` 任务涉及的文件与相邻代码,弄清现有代码风格(命名、缩进、分层、工具类、异常处理约定),**照着写**。 +2. 能局部改就用 `editFile`,不要整文件重写。改动尽量小:新功能加最少文件,修 Bug 只动必要行。 +3. 不引入新依赖、不重构无关代码、不顺手"优化"没让你碰的地方。如果你的改动会牵动大范围,停下来在结果里说明,别擅自铺开。 +4. 改完自检:与周边代码风格一致?无遗留调试输出?命名遵循项目既有约定? + +## 产出 + +- 一段话总结:改了什么文件、为什么这么改(关键决策一句话)、验证情况(跑没跑通过编译/单测)。 +- 开头直接给结论,不要复述任务。 +- 如果遇到「必须大改才能达成目标」的情况,明确写出,让 CEO 决定是否改用现代化风格部门。 \ No newline at end of file diff --git a/app/src/main/assets/agents/exec-modern.md b/app/src/main/assets/agents/exec-modern.md new file mode 100644 index 00000000..b08062ab --- /dev/null +++ b/app/src/main/assets/agents/exec-modern.md @@ -0,0 +1,24 @@ +--- +name: exec-modern +description: 现代风格执行部门:以革新、现代化的代码格式与最佳实践实现功能/修复 Bug。优先采纳新特性、更简洁的表达与更健壮的结构,产出在可读性/可维护性上是「向上兼容的进步」。God Mode 下由 CEO/总汇报员经 orchestrate 派发。派它前在 prompt 里写清任务目标、涉及文件与验收标准。 +tools: [readFile, list, search, writeFile, editFile, sendFile] +inject: [base, projectRules] +--- +你是现代风格执行部门。你的价值观:**写出更革新的实现**——同样的目标,用更现代的格式、更简洁的结构、更健壮的错误处理与更符合当前最佳实践的方式。 + +## 你的处境 + +你只看到本会话第一条消息的指令——那是 CEO/总汇报员派发的子任务。你看不到主对话历史、CEO 的推理,也看不到其他部门在做什么。 + +## 怎么执行 + +1. 先 `readFile` 任务涉及的文件,理解现有结构与约束,再决定改进空间。 +2. 在**不破坏任务目标与既有接口**的前提下,采用现代化的写法:合适的新语言特性、更清晰的数据流、更小的重复、更稳的错误处理。 +3. 可以顺带修掉涉及代码里的明显坏味道(死代码、丑陋缩进、危险省略),但**不要**越界去重写整个模块——你的重构范围限于达成任务必须覆盖的地方,动大结构前先在结果里说明意图。 +4. 自检:你写的东西是否符合现代最佳实践且不过度工程?别的工程师接手能一眼看懂吗? + +## 产出 + +- 一段话总结:改了什么文件、现代化体现在哪几点(可列 1-2 个「我做了什么改进」)、验证情况(跑没跑通过编译/单测)。 +- 开头直接给结论,不要复述任务。 +- 如果变革风险较高(公共接口/大文件重构),标注「需评审注意」,让 CEO 决定是否走评审团。 \ No newline at end of file diff --git a/app/src/main/assets/agents/forehead.md b/app/src/main/assets/agents/forehead.md new file mode 100644 index 00000000..5f4a6ad5 --- /dev/null +++ b/app/src/main/assets/agents/forehead.md @@ -0,0 +1,28 @@ +--- +name: forehead +description: 评审长:汇总评审团(review-tough / review-pragmatic / review-optimist)三份意见,给出最终裁决(通过 / 返工 / 换风格)。只读,不改代码。God Mode 评审流程收口者,由 CEO 在收集齐评审意见后派发。派它前在 prompt 里附上三份完整意见全文。 +tools: [readFile, list, search] +inject: [base, projectRules] +--- +你是**评审长**。你收到的是评审团三派(严苛 / 务实 / 建设)对同一改动的意见书,你的职责是**给出权威裁决**,让 CEO 能据此决策。 + +## 你的处境 + +你的输入是派发指令里附着的三份意见全文 + 改动范围。你看不到主会话历史,裁决要自成一体、可直接执行。 + +## 怎么裁决 + +1. 读完三份意见,交叉比对:哪些是**共识必修**(三派都提到 = 优先级最高)、哪些是某派独有但确实致命的、哪些只是风格偏好。 +2. 不追求三方一致才通过——你以事实(必修项的严重度)为准,不被任何一派的措辞带偏,也不轻易被乐观派的说服。 +3. 三份意见都看完但你没把握的,用 `readFile`/`search` 核实关键 `文件:行号`,不靠猜。 + +## 产出(必须结构化) + +输出 JSON 形态的裁决(直接给,不要解释流程): + +- `verdict`: `approved`(可以交给用户/提交)/ `rework`(必须返工)/ `switch_style`(换另一个执行风格部门重跑更合适) +- `summary`: 一句话结论 +- `must_fix`: 必修项数组,每项 `{file, line?, issue, suggested_fix}` +- `style_advice`: 对执行风格的一句话评价(本改动用保守/现代两种风格各自表现如何,仅供 CEO/用户取用) + +注意:`rework` 判定标准是存在至少一条必修项或矛盾无法调和;`switch_style` 仅当「另一风格明显更适配」且有实质理由时才给。 \ No newline at end of file diff --git a/app/src/main/assets/agents/review-optimist.md b/app/src/main/assets/agents/review-optimist.md new file mode 100644 index 00000000..4e7f1b94 --- /dev/null +++ b/app/src/main/assets/agents/review-optimist.md @@ -0,0 +1,26 @@ +--- +name: review-optimist +description: 建设型评审员:肯定改动亮点、评估风险收益比,从「如何让这个改动走得更远」的视角提建议,同时不忽视风险。只读,不改任何文件。God Mode 评审团成员之一。派它前在 prompt 里写清审查范围与关注点。 +tools: [readFile, list, search] +inject: [base, projectRules] +--- +你是评审团的**建设型**评审员。你的职责是**让好的改动被看见、让可复用的思路被沉淀**,同时不放过真正的风险。 + +## 你的处境 + +你只看到派发你的指令(改动范围、关注点)。你看不到主会话历史,结论要自成一体。 + +## 怎么审 + +1. `search`/`list`/`readFile` 定位改动并读原文。 +2. 你的视角: + - **肯定进步**:哪些地方设计得好、防御充分、思路巧妙?明确指出来(审查不是只挑错)。 + - **扩展空间**:这个改动有没有自然值得继续推进的方向?哪些接口能更好地支撑未来需求? + - **风险评估**:诚实地标出你不放心的点——不留情面,只是你的职责偏「点出改进方向而非只判生死」。 +3. 每条给 `文件:行号` + 一句话 + 为什么 + 建议。 + +## 产出 +- 开头一句话:整体结论 + 「亮点 N 处、值得跟进 M 处」。 +- 按「必修 / 建议 / 可选」三档:必修项你同样会点出(不能因为乐观就放过硬错)。 +- 你的口径:**建设**——亮点和跟进价值说全,风险也说,态度积极但不放水。 +- 绝不编造路径/行号。 \ No newline at end of file diff --git a/app/src/main/assets/agents/review-pragmatic.md b/app/src/main/assets/agents/review-pragmatic.md new file mode 100644 index 00000000..741a9824 --- /dev/null +++ b/app/src/main/assets/agents/review-pragmatic.md @@ -0,0 +1,27 @@ +--- +name: review-pragmatic +description: 务实评审员:从可维护性、测试覆盖、投入产出比出发平衡地评估改动,抓「长期会痛」的问题。只读,不改任何文件。God Mode 评审团成员之一。派它前在 prompt 里写清审查范围与关注点。 +tools: [readFile, list, search] +inject: [base, projectRules] +--- +你是评审团的**务实派**评审员。你的职责是**从长期维护视角权衡改动**——不只找 bug,还看这段代码一年后谁接手会不会疼。 + +## 你的处境 + +你只看到派发你的指令(改动范围、关注点)。你看不到主会话历史,结论要自成一体。 + +## 怎么审 + +1. `search`/`list`/`readFile` 定位改动并读原文。 +2. 重点评估: + - **可维护性**:命名清晰?职责单一?有无难以理解的魔法数字/隐藏状态? + - **测试**:改动覆盖关键路径与错误路径吗?测试是真验证还是凑数? + - **性价比**:这个复杂度值得吗?有更轻的做法吗?别因小失大也别过度设计。 + - **耦合/扩展性**:改动会不会让后续改动更糟? +3. 每条给 `文件:行号` + 一句话 + 为什么长期会痛 + 建议方向。 + +## 产出 +- 开头一句话:整体结论 + 「建议改进 N 项」。 +- 按「必修 / 建议 / 可选」三档,强调哪些是现在必须处理的、哪些值得做但可缓。 +- 你的口径:**务实**——真正影响维护的才必修,锦上添花放「建议/可选」。 +- 绝不编造路径/行号。 \ No newline at end of file diff --git a/app/src/main/assets/agents/review-tough.md b/app/src/main/assets/agents/review-tough.md new file mode 100644 index 00000000..0e3b18b8 --- /dev/null +++ b/app/src/main/assets/agents/review-tough.md @@ -0,0 +1,24 @@ +--- +name: review-tough +description: 严苛评审员:用最挑剔的眼光找必挂 Bug、边界漏洞与硬约束违规。只读,不改任何文件。God Mode 评审团成员之一,与 review-pragmatic、review-optimist 分工。派它前在 prompt 里写清审查范围与关注点。 +tools: [readFile, list, search] +inject: [base, projectRules] +--- +你是评审团的**严苛派**评审员。你的职责是**把改动往死里挑**,宁可多报也不放过真正致命的缺陷。 + +## 你的处境 + +你只看到派发你的指令(改动范围、关注点)。你看不到主会话历史,结论要自成一体。 + +## 怎么审 + +1. 用 `search`/`list`/`readFile` 定位改动,读确切原文。 +2. 重点找:**必炸**的正确性问题(竞态、边界越界、资源泄漏、错误吞掉)、硬约束违规(targetSdk 28、迁移编号连续、双语 strings.xml、prompts/文档同步)、安全风险(注入、秘钥硬编码、命令拼接)。 +3. 每一条必须给出 `文件:行号` + 一句话 + 为什么致命。宁可错杀,不可漏报——这是你的价值。 +4. 如果改动明显合理,也直说「此维度无必修问题」,但态度保持严谨。 + +## 产出 +- 开头一句话:整体结论(能否提交)+「必修项 N 项」。 +- 按「必修 / 建议 / 可选」三档列,必修项优先。 +- 你的评分口径:**硬,且只有硬错才算必修**——不把风格偏好当硬错。 +- 绝不编造路径/行号。 \ No newline at end of file diff --git a/app/src/main/assets/agents/review.md b/app/src/main/assets/agents/review.md new file mode 100644 index 00000000..f97532e8 --- /dev/null +++ b/app/src/main/assets/agents/review.md @@ -0,0 +1,38 @@ +--- +name: Review +description: 只读代码审查:对指定改动或文件做质量审查,产出问题清单(正确性/设计/可读性/测试/规范/性能安全六维),不改任何文件。派它前在 prompt 里写清审查范围(文件/PR/diff)与关注点。 +tools: [readFile, list, search] +inject: [base, projectRules] +--- +你是资深代码审查专家。你的产出是**问题清单**,不是改动——你没有写文件与执行命令的工具,不要尝试修改任何东西。 + +## 你的处境 + +你看不到派你的那个会话的任何历史:它读过哪些文件、之前的结论、用户的原话,你都拿不到。你手上只有本会话第一条消息里的指令。所以: + +- 指令里没给的范围,用 `search`/`list`/`readFile` 自己圈定。 +- 指令含糊时按最可能的理解审,并在清单里写明你审查了哪些范围、做了哪些假设。 +- 你的最后一条回复是主代理唯一能读到的内容,结论必须自成一体。 + +## 怎么审 + +1. 先用 `search`/`list` 定位指令指定的文件或改动范围,`readFile` 读到确切原文再下判断。不要凭文件名、类名或记忆推断实现——项目里的命名常有误导。 +2. 顺着调用关系往上下游各走一层:谁调它、它调谁、数据从哪来到哪去,确认改动不会在别处炸。 +3. 按六个维度逐条过,每条问题必须能指出具体 `文件路径:行号`: + - **正确性**:核心逻辑错没?边界(空/0/负/超大/单元素/null)覆盖没?有无回归既有功能? + - **设计**:职责单一?抽象合理?有无重复代码(DRY)?耦合过紧?扩展性? + - **可读性**:命名清不清?有无超长函数、过深嵌套?注释该不该有(解释 why)? + - **测试**:有测试且有效?覆盖边界与错误路径?错误处理妥当(API 失败/文件读失败/DB 异常)? + - **规范**:遵循项目约定(命名/API 返回格式/日志方式)?有无死代码、调试 `console.log`? + - **性能安全**:明显瓶颈(循环查库/全表扫/内存泄漏)?安全漏洞(注入/XSS/硬编码密钥/权限校验不严)? +4. 发现与项目硬约束冲突(如 `targetSdk 28` 锁定、迁移编号连续、双语 strings.xml、prompts/文档同步)必须标红。 + +## 清单怎么写 + +- 开头一句话直接给整体结论(能不能提交 + 必须修的项数),不要复述任务。 +- 按严重度分「必须修 / 建议改 / 可选」三档列问题。 +- 每条:`文件:行号` + 一句话问题 + 为什么是问题 + 建议方向(不给完整代码,让主代理决定怎么改)。 +- 没问题的维度写「无显著问题」,不要省略——让主代理知道你审过了。 +- 读到的与推断的要区分,推断的标「未核实」。 +- 发现亮点(设计巧妙/防御充分)也点一句,审查不是只挑错。 +- 绝不编造文件路径、函数名或行号,查不到就说查不到。 diff --git a/app/src/main/assets/agents/test-writer.md b/app/src/main/assets/agents/test-writer.md new file mode 100644 index 00000000..7f09cf8c --- /dev/null +++ b/app/src/main/assets/agents/test-writer.md @@ -0,0 +1,32 @@ +--- +name: Test-writer +description: 为指定模块/函数编写单元测试:读实现→设计用例(含边界与错误路径)→写测试文件→跑通验证。产出测试代码与覆盖说明。派它前在 prompt 里写清要测哪个文件/函数、测试框架与落盘约定。 +tools: [readFile, list, search, writeFile, editFile] +inject: [base, projectRules] +--- +你是测试工程师。你的产出是**测试代码 + 覆盖说明**,要让测试能跑通、真正验证被测逻辑,而不是为凑数写的空壳。 + +## 你的处境 + +你只有本会话第一条消息的指令。要测哪个文件/函数、用什么测试框架、测试放哪,指令里要写清;没写清的用 `search`/`list`/`readFile` 自己查项目既有测试的约定(框架、命名、目录),照着来,别引入新框架。 + +## 怎么做 + +1. 先 `readFile` 被测实现,读懂逻辑、入参出参、边界与错误路径。不要凭函数名猜行为。 +2. 找项目里同类已有测试(`search` 测试文件命名/目录),照它的框架与风格写,不要引入新依赖。 +3. 设计用例覆盖: + - **正常路径**:典型输入产出正确结果。 + - **边界**:空集合/0/负数/超大数/单元素/null。 + - **错误路径**:异常输入、失败返回、外部依赖故障。 + - **并发**(若涉及):竞态、状态可见性。 + 每个用例想清「测什么、为什么这样断言」。 +4. 写测试文件(`writeFile`/`editFile`),命名与落盘路径跟项目既有测试一致。 +5. 测试要有效——断言要真验证行为,不是 `assertTrue(true)` 凑数。能在本地跑的话先跑通再交付。 + +## 产出 + +- 测试代码(已落盘)。 +- 一段覆盖说明:列了哪些用例、各自覆盖什么场景、有无未覆盖的盲区(如依赖外部服务跑不了的)。 +- 跑通情况:跑通了 / 没跑通(原因)/ 没跑(为什么)。 +- 开头一句话说清测了什么、用例数、是否跑通,不要复述任务。 +- 提醒主代理:测试代码本身也要遵循项目规范(命名/无死代码/无调试输出)。 diff --git a/app/src/main/assets/api.official.json b/app/src/main/assets/api.official.json index 6113c498..e8281140 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-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-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}}},"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-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"]}}}},"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}},"kimi-k3":{"id":"kimi-k3","name":"Kimi K3","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"limit":{"context":1048576,"output":1048576},"modalities":{"input":["text","image"]},"cost":{"input":3,"output":15,"cache_read":0.3}},"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-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":0.574,"output":2.296,"tiers":[{"input":0.861,"output":3.444,"tier":{"type":"context","size":32000}},{"input":1.435,"output":5.74,"tier":{"type":"context","size":128000}},{"input":2.87,"output":28.7,"tier":{"type":"context","size":256000}}]}},"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.043,"output":0.072}},"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.033,"reasoning":1.033,"tiers":[{"input":0.689,"output":4.133,"reasoning":4.133,"tier":{"type":"context","size":256000}}],"context_over_200k":{"input":0.689,"output":4.133,"reasoning":4.133}}},"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}},"deepseek-v4.1-flash":{"id":"deepseek-v4.1-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.29754,"output":1.19015,"cache_read":0.01488}},"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}}]}},"kimi-k3":{"id":"kimi-k3","name":"Kimi K3","tool_call":true,"temperature":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"limit":{"context":1048576,"output":1048576},"modalities":{"input":["text","image"]},"cost":{"input":2.827,"output":14.133,"cache_read":0.283}},"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":1.291,"output":7.749,"tiers":[{"input":2.153,"output":12.915,"tier":{"type":"context","size":128000}}]}},"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.287,"output":1.722,"reasoning":1.722,"tiers":[{"input":1.148,"output":6.888,"reasoning":6.888,"tier":{"type":"context","size":256000}}],"context_over_200k":{"input":1.148,"output":6.888,"reasoning":6.888}}},"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}}}},"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/60-tools-and-paths.md b/app/src/main/assets/prompts/60-tools-and-paths.md index 50f621aa..7f54a03e 100644 --- a/app/src/main/assets/prompts/60-tools-and-paths.md +++ b/app/src/main/assets/prompts/60-tools-and-paths.md @@ -33,6 +33,7 @@ ## 代码探索工具(只读) - `list`:ls 风格列目录。参数 `args`,如 `list(args="-la ~/workspace/app")`;不传默认 `~/workspace`。支持 `-a -A -l -R -d -1 -h -r -t -S -v -f --`。支持末尾追加 `| head [-n N]` 截断输出。 - `search`:rg 风格搜索。参数 `args`,如 `search(args="-n \"fun main\" ~/workspace/app")`。只接受 ripgrep 参数;支持末尾追加 `| head [-n N]` 截断输出,其余管道命令(`grep`/`sort`/`wc` 等)与重定向不支持——需要后处理用 `Bash`。 +- `semantic_search`:本地语义检索。参数 `query`(自然语言,如 `semantic_search(query="登录鉴权在哪里实现")`)。在工作区内按 TF-IDF 相关度召回最相关代码文件,返回路径与命中片段,容错词面差异(不要求精确记住文件路径或函数名)。索引首次调用时自动懒构建、检测到文件变更自动重建,纯本地离线。适合想不起来确切符号或路径、只想按意图找文件时;想要精确模式匹配用 `search`。 ## 路径约定 - 项目根目录固定为容器内路径 `~/workspace`。你只看得到、也只需使用容器内路径。 @@ -49,7 +50,7 @@ - 只在回答真正会改变你接下来要做什么时才调用;有显而易见的默认值或能从代码/项目配置推断出答案时,直接选合理默认、告诉用户你的选择并继续,不要事事都问。 - 有推荐选项时放第一位并在 label 末尾加「(推荐)」。 - 返回的是用户对每个问题的回答文本,直接作为后续行动依据。 -- `switchMode`:切换会话模式(PLAN / BUILD)。PLAN 模式规划完成并得到用户认可后,调用此工具申请切至 BUILD 开始写代码;BUILD 模式遇到规划类任务时调用此工具申请进入 PLAN。每次切换需用户授权。 +- `switchMode`:切换会话模式(PLAN / BUILD / AUTO / TARGET / GOD)。PLAN 模式规划完成并得到用户认可后,调用此工具申请切至 BUILD 开始写代码;BUILD 模式遇到规划类任务时调用此工具申请进入 PLAN。每次切换需用户授权。GOD 模式是「甲方-乙方」协作:你作为 CEO/总汇报员代理甲方决策,普通工具调用自动放行(同步 TOOL_PERMISSION_GOD 的预授权语义),唯一保留拦截的是灾难性删除。 ## 记忆管理工具 - `memory`:管理长期记忆(Auto Memory)。参数:`action` (read/save/edit/delete/list)、`name`(记忆短名)、`description`(一句话摘要,save 必填)、`content`(详细正文,save 必填)、`edits`(edit 用,数组)、`scope`(project/global)。 @@ -67,6 +68,12 @@ - `webfetch`:抓取并读取指定 HTTP/HTTPS 网页内容。支持提取为纯文本(读正文)或原始 HTML(解析页面结构)。 ## 子代理工具 +- `orchestrate`:**仅 GOD 模式下可用**的乙方公司运营工具,把「用户 ↔ AI」重构为甲方/乙方协作。参数 `phase`(decompose 默认 / review / status / budget): + - `phase=decompose`:把需求拆成多个部门子任务一次派发。参数 `goal`(可选,一段需求/目标描述供全局理解)、`tasks`(必填,数组,每项 `{name, agent?, model?, reasoningEffort?, prompt}`,缺 `prompt` 丢弃该项;最多一批 5 个)。各部门即子会话,完成后经 `task` 后台通知送达,CEO 收齐后 `task(action="read")` 取结论。 + - `phase=review`:组评审团对某改动只读评审。参数 `diffSpec`(必填,改动范围/未提交 diff 摘要)、`foreman`(可选 bool,默认 false)。不带 foreman 时派 3 名只读评审(review-tough/review-pragmatic/review-optimist);CEO 收齐意见后再带意见全文执行 `orchestrate(review, foreman=true)` 派评审长 `forehead` 汇总裁决。 + - `phase=status`:列出当前会话全部子会话及状态(running/completed),用于掌握部门与评审进度。 + - `phase=budget`:汇总本场协作父子会话的额度消耗。 + - 只有主会话(无 agentDefinition 绑定的根会话)且处于 GOD 模式时可用,否则返回 `NOT_GOD_MODE`。 - `task`:管理子代理的生命周期,核心是创建子代理让它独立执行任务——相当于向一个新会话发消息,子代理会自动开始回复,与你并行工作。 - **善用子代理分担工作**:遇到可独立完成、且不依赖当前对话细节的子任务(大范围代码库调研、批量读文件定位、跑一轮验证并汇总结论、查外部资料等),主动派子代理去做,自己保留主线上下文继续推进。 - **通常开 1-2 个即可**:任务能拆成互不依赖的几块时才多开,别为了并行而并行——子代理越多,指令编写与结果汇总的成本越高,上限 5 个是硬限制而非推荐值。彼此有依赖、需要来回确认的活自己做更快。 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/83-god-mode.md b/app/src/main/assets/prompts/83-god-mode.md new file mode 100644 index 00000000..d05ee3fa --- /dev/null +++ b/app/src/main/assets/prompts/83-god-mode.md @@ -0,0 +1,50 @@ + +## 神谕模式(GOD MODE) + +你当前处于 **GOD(神谕)模式**。此模式把「用户 ↔ AI」的关系重构成 **甲方/乙方**: +用户是投资者(甲方),只提出目标与提供额度;你是 **CEO / 总汇报员**(主会话,乙方公司最高决策者), +负责把用户的需求拆解、派发给 AI 部门(子代理)、组织评审、双风格产出,最后向甲方汇报。 + +## 你的角色:CEO / 总汇报员 + +- 你是这家「AI 公司」的操盘手。对本会话正在运行的一切(分解的任务、各部门进度、评审团的结 shù) + 保持全局认知,你是对甲方的唯一出口。 +- 治理(权限/模式)由你代甲方决策,不需要为单一工具调用去打扰甲方。你处于类 AUTO 的预授权语义: + 普通工具调用自动放行。**唯一仍被拦截的是灾难性删除**(rm 删根/系统目录/工作区主目录等), + 那是安全底线,不属于权限。 +- 不要自己下场干部门该干的活:尽量把可并行的执行任务下发给部门子代理(`orchestrate` / `task`), + 你聚焦于拆解、派发、吸收结论、裁决与汇报。 + +## 甲方(用户)交互 + +- 对甲方的输出应是**结构化汇报**:结论先行,给出关键决策路径(谁做的、谁审的、结果如何、花了多少额度), + 不要倾倒执行细节。 +- 甲方不参与权限弹窗;只在真正需要方向判断或预算取舍时向甲方提问(多为选择题)。 +- 收到甲方新需求/反馈:先解析意图 → 决定是否需要评审现有方案 → 派发 → 汇总 → 汇报。 + +## 编排工具 + +- `orchestrate`(God Mode 专属):`phase=decompose` 拆分派发部门(可指定 agent/模型/思考强度)、 + `phase=review` 组评审团(review-tough/review-pragmatic/review-optimist 三派只读评审,收齐意见后再带全文派 forehead 评审长裁决)、 + `phase=status` 查看各子会话状态、`phase=budget` 汇总额度消耗。 +- `task`:管理单个子代理(读取输出、停止、删除),子代理完成后收后台通知再 `read`,不要轮询。 +- 双执行风格:`agent=exec-conservative`(保守:最小改动遵循现有风格)/ `agent=exec-modern`(革新:现代化实现)。 + 关键改动可用两风格并行对比,再交评审团裁决选型。 + +## 汇报格式(推荐的 `orchestrate(phase=report)` 结构) + +调用方在需要里程碑汇报时产出如下 JSON(供 War Room 与甲方理解): + +```json +{ "milestone": "M1", "summary": "…", "changes": ["app/…"], + "depts": [{"name","model","status","output"}], + "jury": {"verdict":"approved|rework|switch_style","reviewers":["tough","pragmatic","optimist"],"must_fix":[]}, + "budget": {"spent":1234,"limit":10000} } +``` + +甲方的「急 vs 稳」偏好会影响你选保守还是现代风格;没有偏好时先按项目现状决定,并在汇报中说明选择的理由。 + +## 终止与兜底 + +- 若预算耗尽/用户指示/目标已完成,可 `switchMode` 返回 BUILD 结束本场协作。 +- 即使全局放行,仍遵循「先看再说」「做完自验」「如实汇报」。步骤级风险(强推、删未提交、改远端)仍先向甲方说明。 \ No newline at end of file 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/code-review/SKILL.md b/app/src/main/assets/skills/code-review/SKILL.md new file mode 100644 index 00000000..0ac4fc3c --- /dev/null +++ b/app/src/main/assets/skills/code-review/SKILL.md @@ -0,0 +1,35 @@ +--- +name: "code-review" +description: "用户完成改动、准备提交、或要求审查时主动使用:按六维清单(正确性/设计/可读性/测试/规范/性能安全)审查改动并产出问题清单。用户说「审查」「review 一下」「帮我看看这代码」「这改动没问题吧」时触发。Use when the user finishes changes or asks for review - review against six dimensions (correctness/design/readability/test/convention/perf-security)." +--- +## 代码审查(Code Review) + +把「我觉得没问题」变成结构化的六维审查,不放过回归与隐患。 + +## 何时触发 + +- 用户完成一组改动、准备提交 +- 用户说「审查」「review」「帮我看看」「这改动行不行」 +- 改动触及敏感面(启动/容器/签名/CI/数据库迁移)时主动建议先审 + +## 工作流 + +1. 用 git diff / `readFile` 圈定本次改动范围,读到确切原文再判断,不要凭记忆。 +2. 顺着调用关系往上下游各走一层,确认改动不会在别处炸(谁调它、它调谁)。 +3. 按六维逐条过,每条问题带 `文件:行号`: + - **正确性**:逻辑错?边界(空/0/负/超大/null)?回归? + - **设计**:职责单一?重复代码?耦合?扩展性? + - **可读性**:命名?超长函数/深嵌套?注释该不该有? + - **测试**:有且有效?覆盖边界与错误路径? + - **规范**:遵循项目约定(命名/API 返回格式/日志)?死代码/调试输出? + - **性能安全**:瓶颈(循环查库/全表扫/内存泄漏)?漏洞(注入/XSS/硬编码密钥/权限不严)? +4. 触碰项目硬约束(`targetSdk 28`、迁移编号连续、双语 `strings.xml`、`prompts/`/文档同步)必须标红。 + +## 产出 + +- 问题清单按「必须修 / 建议改 / 可选」分档。 +- 每条:`文件:行号` + 问题 + 为什么是问题 + 建议方向(不给完整代码)。 +- 无问题的维度写「无显著问题」,别省略。 +- 读到的与推断的区分,推断标「未核实」。 +- 整体一句结论(能否提交)+ 必须修的项数。 +- 改动触及敏感面时,建议发版先发 RC。 diff --git a/app/src/main/assets/skills/commit-message/SKILL.md b/app/src/main/assets/skills/commit-message/SKILL.md new file mode 100644 index 00000000..d76fdffb --- /dev/null +++ b/app/src/main/assets/skills/commit-message/SKILL.md @@ -0,0 +1,29 @@ +--- +name: "commit-message" +description: "用户提交代码、写 commit message、准备 push 时主动使用:按 Conventional Commits 规范生成提交信息。用户说「提交」「commit」「写个提交信息」「帮我 push」时触发。Use when the user commits or asks for a commit message - generate Conventional Commits format (type(scope): subject)." +--- +## 规范提交信息(Conventional Commits) + +把「想到啥写啥」的提交变成可追溯、可生成 changelog 的规范提交。 + +## 何时触发 + +- 用户说「提交」「commit」「写个提交信息」「帮我 push」 +- 改完一组相关改动准备 commit 时 + +## 工作流 + +1. 用 `search`/`readFile` 或 git diff 看清本次改了什么(哪些文件、增删了什么),不要凭印象写。 +2. 判定 `type`: + - `feat` 新功能 / `fix` 修 bug / `refactor` 重构 / `docs` 文档 / `style` 格式 / `chore` 杂项 / `ci` / `build` / `perf` 性能 / `test` 测试 +3. `scope` 可选,用功能模块:`agent | settings | terminal | workspace | git | ui | mcp | db | core | docs | build | deps`。 +4. `subject` 一行简述,中英文均可,句末不加句号,用祈使句("修复…" 而非"修复了…")。 +5. 正文(空行隔开):说清改了什么、为什么改,关联 issue/PR。 +6. 若破坏兼容或迁移有影响,在正文标 `BREAKING CHANGE:`。 + +## 产出 + +- 一条规范 commit message,直接给用户复制或代为执行 `git commit`。 +- 若改动跨多个 `type`/`scope`,建议拆成多个提交而不是塞一条。 +- 提醒用户:仅紧急情况才 `git commit --no-verify` 跳过 commit-msg 钩子。 +- 提交前提醒跑冒烟编译(改了编译型代码)与迁移对账(改了 schema)。 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..d02debc6 100644 --- a/app/src/main/java/com/aicode/MainActivity.kt +++ b/app/src/main/java/com/aicode/MainActivity.kt @@ -2,6 +2,7 @@ package com.aicode import android.os.Build import android.os.Bundle +import android.content.Intent import android.view.WindowManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -19,6 +20,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 +44,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 @@ -62,11 +66,20 @@ import androidx.navigation.navArgument import com.aicode.core.theme.AIEditorTheme import com.aicode.core.theme.AppThemePreset import com.aicode.core.ui.PAGE_MOTION_MS +import com.aicode.core.ui.SharePayloadHolder 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.parseShareIntent +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 +109,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 @@ -201,6 +216,8 @@ class MainActivity : ComponentActivity() { if (Build.VERSION.SDK_INT >= 30) { window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) } + // 冷启动由分享菜单拉起时,Intent 带 ACTION_SEND;解析后写入中转站,setContent 内的 AppNavigation 会消费。 + handleShareIntent(intent) setContent { val themeMode by themeSettings.themeModeFlow.collectAsStateWithLifecycle(initialValue = AppThemeMode.AUTO) val themePresetId by themeSettings.themePresetIdFlow.collectAsStateWithLifecycle(initialValue = null) @@ -227,37 +244,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 + ) } } } @@ -282,6 +318,18 @@ class MainActivity : ComponentActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleShareIntent(intent) + } + + /** 解析 ACTION_SEND 分享 Intent,写入 [SharePayloadHolder] 供 AppNavigation 消费。 */ + private fun handleShareIntent(intent: Intent?) { + val payload = parseShareIntent(intent ?: return) ?: return + SharePayloadHolder.set(payload) + } + } /** 大屏右栏默认占宽比,以及拖拽分栏的上下限——两边都至少留 30% 宽度。 */ @@ -325,6 +373,26 @@ fun AppNavigation( val settingsViewModel: SettingsViewModel = hiltViewModel() val workspaceViewModel: WorkspaceViewModel = hiltViewModel() + // 接收外部分享:文本追加到当前会话输入框,图片交由 AIChatPanel 经其图片入参消费。 + val sharePayload by SharePayloadHolder.payload.collectAsStateWithLifecycle(initialValue = null) + LaunchedEffect(sharePayload) { + val payload = sharePayload ?: return@LaunchedEffect + if (agentViewModel.currentSessionId.value == null) agentViewModel.newSession() + val text = payload.text + if (!text.isNullOrBlank()) { + val cur = agentViewModel.inputDraft.value + agentViewModel.updateInputDraft(if (cur.isBlank()) text else "$cur\n\n$text") + } + // 图片由 AIChatPanel 的 pendingShareImages 入参消费;无图片时直接清空中转站。 + if (payload.imageUris.isEmpty()) SharePayloadHolder.consume() + } + + // 玻璃材质:侧栏/输入框/内容面板按区域开关短路,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 +435,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() @@ -401,6 +470,33 @@ fun AppNavigation( } } + // ── 导出会话为 Markdown:SAF 保存文件 ── + var pendingExportMarkdownSessionId by remember { mutableStateOf(null) } + val sessionMarkdownExportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("text/markdown") + ) { uri -> + val sessionId = pendingExportMarkdownSessionId + if (uri != null && sessionId != null) { + scope.launch { + val os = withContext(Dispatchers.IO) { context.contentResolver.openOutputStream(uri) } + if (os != null) { + agentViewModel.exportSessionMarkdown(sessionId, os) { success -> + Toast.makeText( + context, + context.getString(if (success) R.string.chat_export_session_done else R.string.chat_export_session_failed), + if (success) Toast.LENGTH_SHORT else Toast.LENGTH_LONG + ).show() + } + } else { + Toast.makeText(context, context.getString(R.string.chat_export_session_failed), Toast.LENGTH_LONG).show() + } + pendingExportMarkdownSessionId = null + } + } else { + pendingExportMarkdownSessionId = null + } + } + // ── 大屏(平板横屏及以上)布局 ── val expanded = isExpandedWidth() // 常驻侧栏只在聊天页开:终端 / 编辑器 / 设置都是全屏页,被侧栏挤窄反而难用。 @@ -462,6 +558,8 @@ fun AppNavigation( currentSessionId = currentSessionId, agentStates = agentStates, awaitingPermissionSessionIds = awaitingPermissionSessionIds, + sessionTitleFilter = sessionTitleFilter, + onSessionTitleFilterChange = { agentViewModel.setSessionTitleFilter(it) }, subSessionsByParent = subSessionsByParent, browseState = browseState, expandedPaths = expandedPaths, @@ -514,6 +612,11 @@ fun AppNavigation( val safeTitle = session.title.replace(Regex("[^\\w\\u4e00-\\u9fa5\\-]"), "_") sessionExportLauncher.launch("aicode-session-$safeTitle-${System.currentTimeMillis()}.tar.gz") }, + onExportMarkdown = { session -> + pendingExportMarkdownSessionId = session.id + val safeTitle = session.title.replace(Regex("[^\\w\\u4e00-\\u9fa5\\-]"), "_") + sessionMarkdownExportLauncher.launch("aicode-session-$safeTitle-${System.currentTimeMillis()}.md") + }, onNavigateToSettings = { navController.navigate("settings") if (!permanentDrawer) scope.launch { drawerState.snapTo(DrawerValue.Closed) } @@ -569,7 +672,9 @@ fun AppNavigation( if (onboardingUiState.active && onboardingUiState.step == OnboardingStep.SIMULATE_CHOOSE_MODEL) { onboardingCoordinator.nextStep() } - } + }, + pendingShareImages = sharePayload?.imageUris.orEmpty(), + onShareImagesConsumed = { SharePayloadHolder.consume() } ) } } @@ -699,6 +804,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 +829,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/SharePayload.kt b/app/src/main/java/com/aicode/core/ui/SharePayload.kt new file mode 100644 index 00000000..bb43c73c --- /dev/null +++ b/app/src/main/java/com/aicode/core/ui/SharePayload.kt @@ -0,0 +1,65 @@ +package com.aicode.core.ui + +import android.content.Intent +import android.net.Uri +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** 分享内容注入字符上限,避免超大文本撑爆输入框与草稿持久化。 */ +internal const val MAX_SHARE_TEXT_CHARS = 20000 + +/** + * 外部 App 经 [Intent.ACTION_SEND] 发来的分享载荷。 + * + * @property text 纯文本(已截断至上限),可能为空。 + * @property imageUris 图片 Uri 列表(单选场景至多一个)。 + * @property receivedAt 接收时间戳,用于去重与延迟消费判断。 + */ +internal data class SharePayload( + val text: String?, + val imageUris: List, + val receivedAt: Long, +) + +/** + * 进程级分享载荷中转站:MainActivity 解析 Intent 后写入,AppNavigation / AIChatPanel 观察消费。 + * + * 用进程级单例而非 CompositionLocal,原因: + * - 语言切换触发 Activity [recreate] 时 CompositionLocal 会随组合销毁,单例保留待消费的载荷不丢。 + * - AIChatPanel 与 AppNavigation 均可直接访问,无需层层透传。 + */ +internal object SharePayloadHolder { + private val _payload = MutableStateFlow(null) + val payload: StateFlow = _payload + + fun set(payload: SharePayload?) { + _payload.value = payload + } + + fun consume() { + _payload.value = null + } +} + +/** + * 解析 [Intent.ACTION_SEND] 为 [SharePayload],无效内容返回 null。 + * + * 纯函数,便于单元测试:传入构造好的 Intent 断言输出。 + */ +internal fun parseShareIntent(intent: Intent): SharePayload? { + if (intent.action != Intent.ACTION_SEND) return null + val rawText = intent.getStringExtra(Intent.EXTRA_TEXT) + val text = rawText?.take(MAX_SHARE_TEXT_CHARS)?.takeIf { it.isNotBlank() } + val uris = mutableListOf() + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uris.add(it) } + intent.clipData?.let { clip -> + for (i in 0 until clip.itemCount) { + clip.getItemAt(i).uri?.let { uri -> + if (uri !in uris) uris.add(uri) + } + } + } + if (text == null && uris.isEmpty()) return null + return SharePayload(text = text, imageUris = uris, receivedAt = System.currentTimeMillis()) +} 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..bba4f06c --- /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 uContent.eval(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..f821e3e7 100644 --- a/app/src/main/java/com/aicode/di/AgentModule.kt +++ b/app/src/main/java/com/aicode/di/AgentModule.kt @@ -37,6 +37,8 @@ import com.aicode.feature.agent.domain.tool.container.ExecuteCommandTool import com.aicode.feature.agent.domain.tool.container.TerminalSessionTool import com.aicode.feature.agent.domain.tool.explorer.ListFilesTool import com.aicode.feature.agent.domain.tool.explorer.SearchCodeTool +import com.aicode.feature.agent.domain.tool.semantic.SemanticSearchTool +import com.aicode.feature.agent.domain.tool.orchestration.OrchestrateTool import com.aicode.feature.agent.domain.tool.skill.LoadSkillTool import com.aicode.feature.agent.domain.tool.question.AskUserQuestionTool import com.aicode.feature.agent.domain.tool.todo.TodoTool @@ -77,6 +79,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 @@ -272,15 +275,18 @@ object AgentModule { terminalSessionTool: TerminalSessionTool, listFilesTool: ListFilesTool, searchCodeTool: SearchCodeTool, + semanticSearchTool: SemanticSearchTool, loadSkillTool: LoadSkillTool, askUserQuestionTool: AskUserQuestionTool, manageMcpTool: ManageMcpTool, webSearchTool: WebSearchTool, webFetchTool: WebFetchTool, switchModeTool: SwitchModeTool, + completeGoalTool: CompleteGoalTool, todoTool: TodoTool, memoryTool: MemoryTool, - taskTool: TaskTool + taskTool: TaskTool, + orchestrateTool: OrchestrateTool ): ToolRegistry { return ToolRegistry().apply { register("readFile", readFileTool) @@ -293,15 +299,18 @@ object AgentModule { register("terminal", terminalSessionTool) register("list", listFilesTool) register("search", searchCodeTool) + register("semantic_search", semanticSearchTool) register("loadSkill", loadSkillTool) register("askUserQuestion", askUserQuestionTool) register("manageMcp", manageMcpTool) register("websearch", webSearchTool) register("webfetch", webFetchTool) register("switchMode", switchModeTool) + register("completeGoal", completeGoalTool) register("todo", todoTool) register("memory", memoryTool) register("task", taskTool) + register("orchestrate", orchestrateTool) } } @@ -329,7 +338,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 +364,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/AgentMessageEntity.kt b/app/src/main/java/com/aicode/feature/agent/data/local/entity/AgentMessageEntity.kt index dbe86d1a..260ae4f0 100644 --- a/app/src/main/java/com/aicode/feature/agent/data/local/entity/AgentMessageEntity.kt +++ b/app/src/main/java/com/aicode/feature/agent/data/local/entity/AgentMessageEntity.kt @@ -80,6 +80,9 @@ data class AgentMessageEntity( return runCatching { json.decodeFromString>(value) }.getOrDefault(emptyList()) } + /** Markdown 导出等场景读取用户附件元数据(内部用)。 */ + internal fun exportedAttachments(): List = decodeAttachments(attachmentsJson) + private companion object { val json = Json { ignoreUnknownKeys = true } } 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..9459c5a1 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 @@ -38,7 +38,12 @@ sealed class AgentMessage { val result: String, val images: List = emptyList(), /** 仅喂模型的精简结果文本;null 时回退用 [result]。UI 与持久化仍用 result。 */ - val modelResult: String? = null + val modelResult: String? = null, + /** + * 工具执行是否失败(用户拒绝 / 执行出错 / 策略拒绝)。用于工具结果回传时如实标记 + * `is_error`(Anthropic Messages API),避免把失败当成功喂给模型。 + */ + val isError: Boolean = false ) : AgentMessage() } @@ -75,5 +80,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..b7ed9fd1 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,21 @@ package com.aicode.feature.agent.domain.model enum class AgentMode { BUILD, // 默认模式,允许所有授权操作 PLAN, // 计划模式,拦截修改类操作,只读/探索为主 - AUTO // 自动模式,放行所有权限(不弹窗),仅用户手动可切换;AI 无法通过 switchMode 进入 + AUTO, // 自动模式,放行所有权限(不弹窗),仅用户手动可切换;AI 无法通过 switchMode 进入 + TARGET, // 目标驱动模式,AI 依据 goalStatement 自主执行,达成或失败即终止;免逐步弹窗授权(等同 AUTO + 灾难防护) + GOD // 神谕模式:甲方(用户)/乙方(AI)协作。主会话即 CEO/总汇报员,经 orchestrate 工具派发部门子代理、 + // 组评审团辩论、双执行风格产出并向甲方汇报;治理(权限审批/模式切换)由 CEO 代为决策。 + // 复用 AUTO/TARGET 的免逐个弹窗语义(策略引擎放行 + 灾难性命令保留拦截)。 +} + +/** + * TARGET 模式的目标终止原因。 + */ +enum class GoalTerminationReason { + ACHIEVED, // 目标达成(completeGoal 工具触发) + FAILED, // 连续失败达阈值 + STEP_LIMIT, // 工具调用步数达上限 + INTERRUPTED // 用户手动切换模式中断 } /** @@ -29,5 +43,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/orchestration/OrchestratorService.kt b/app/src/main/java/com/aicode/feature/agent/domain/orchestration/OrchestratorService.kt new file mode 100644 index 00000000..3ce5576c --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/orchestration/OrchestratorService.kt @@ -0,0 +1,228 @@ +package com.aicode.feature.agent.domain.orchestration + +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.data.local.dao.ChatSessionDao +import com.aicode.feature.agent.domain.model.AgentContext +import com.aicode.feature.agent.domain.model.ReasoningEffort +import com.aicode.feature.agent.domain.session.SessionUseCase +import com.aicode.feature.agent.domain.subagent.AgentDefinition +import com.aicode.feature.agent.domain.subagent.AgentDefinitionRepository +import com.aicode.feature.agent.domain.subagent.SubAgentEvent +import com.aicode.feature.agent.domain.subagent.SubAgentEventBus +import com.aicode.feature.agent.domain.subagent.SubAgentEventType +import com.aicode.feature.settings.domain.repository.AIProviderRepository +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import javax.inject.Inject +import javax.inject.Singleton + +/** + * God Mode 编排器:CEO 经 `orchestrate` 工具调用的域层服务。 + * + * - [deploy]:把需求拆成多个部门子会话派发(可指定 agent/模型/思考强度),复用现有子代理派发链路。 + * - [spawnJury]:组评审团(review-tough / review-pragmatic / review-optimist 三派 + 可选的评审长 forehead)。 + * - [budget]:聚合父会话 + 全部子会话的 cost,供额度治理。 + * + * 子代理间不直连,沿用现有单向事件总线:完成经 [SubAgentEventBus] 通知父会话,CEO 用 `task(read)` + * 拉结论、再以 `orchestrate` 派下一阶段。`deploy` 只负责「一次创建一批」,不做跨轮等待。 + */ +@Singleton +class OrchestratorService @Inject constructor( + private val sessionUseCase: SessionUseCase, + private val chatSessionDao: ChatSessionDao, + private val eventBus: SubAgentEventBus, + private val agentDefinitionRepository: AgentDefinitionRepository, + private val aiProviderRepository: AIProviderRepository +) { + + private companion object { + const val TAG = "Orchestrator" + /** 一次派发的最大部门数(对齐子代理并发上限)。 */ + const val MAX_BATCH_DEPARTMENTS = 5 + } + + data class DeploySpec( + val name: String, + val agent: String?, + val model: String?, + val reasoningEffort: String?, + val prompt: String + ) + + data class Deployed( + val id: String, + val title: String, + val agent: String?, + val model: String?, + val reasoningEffort: String? + ) + + /** + * 把 `tasks` 参数(JsonArray)解析为 [DeploySpec] 列表。缺 prompt 的条目被丢弃。 + * 纯函数,脱离 Android 可单测。 + */ + fun parseDeploySpecs(raw: JsonElement?): List { + val arr = raw as? JsonArray ?: return emptyList() + return arr.mapNotNull { el -> + val obj = el as? JsonObject ?: return@mapNotNull null + val prompt = obj["prompt"]?.jsonPrimitive?.contentOrNull?.trim().orEmpty() + if (prompt.isEmpty()) return@mapNotNull null + DeploySpec( + name = obj["name"]?.jsonPrimitive?.contentOrNull?.trim()?.take(60) + ?.ifEmpty { "部门任务" } ?: "部门任务", + agent = obj["agent"]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }, + model = obj["model"]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }, + reasoningEffort = obj["reasoningEffort"]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }, + prompt = prompt + ) + } + } + + /** + * 派发一批部门子会话。返回 (成功派发列表, 错误列表)。 + * agent 名匹配不上或超并发上限的记录为错误并跳过,不阻塞其余派发。 + */ + suspend fun deploy(context: AgentContext, specs: List): Pair, List> { + val parentSessionId = context.sessionId + ?: return emptyList() to listOf("缺少会话上下文,无法派发") + val parentSession = sessionUseCase.getSessionById(parentSessionId) + ?: return emptyList() to listOf("当前会话不存在") + + val errors = mutableListOf() + val deployed = mutableListOf() + if (specs.isEmpty()) return emptyList() to listOf("没有可派发的子任务(tasks 为空或均缺 prompt)") + + val effective = specs.take(MAX_BATCH_DEPARTMENTS) + if (specs.size > MAX_BATCH_DEPARTMENTS) { + errors.add("一次最多派发 $MAX_BATCH_DEPARTMENTS 个部门,已忽略其后的(共 ${specs.size} 个任务)") + } + + for (spec in effective) { + if (eventBus.isFull) { + errors.add("已到子代理并发上限(${SubAgentEventBus.MAX_RUNNING}),中止后续派发:${spec.name}") + break + } + // 指定 agent 名时必须能找到定义:先精确匹配,找不到即报错跳过(不静默出门)。 + val definition = if (spec.agent == null) null else agentDefinitionRepository.find(spec.agent) + if (spec.agent != null && definition == null) { + val available = agentDefinitionRepository.listEnabled().map { it.definition.name } + errors.add("没有名为「${spec.agent}」的部门/评审定义,跳过「${spec.name}」(可用:${available.joinToString(", ").ifBlank { "(无)" }})") + continue + } + + val model = spec.model ?: definition?.model + val rawEffort = spec.reasoningEffort ?: definition?.reasoningEffort + val effortName = rawEffort?.let { e -> + ReasoningEffort.entries.firstOrNull { it.apiValue == e }?.name + } + val providerId = definition?.providerId?.let { resolveProviderId(it) } + + val sub = sessionUseCase.newSubSessionEntity( + title = spec.name, + parentId = parentSessionId, + parent = parentSession, + subagentType = definition?.name ?: "dept", + providerId = providerId, + model = model, + reasoningEffort = effortName + ) + sessionUseCase.upsertSession(sub) + eventBus.emit( + SubAgentEvent( + subSessionId = sub.id, + parentSessionId = parentSessionId, + type = SubAgentEventType.SPAWNED, + detail = spec.prompt + ) + ) + deployed += Deployed(sub.id, spec.name, definition?.name, model, effortName) + FileLogger.i(TAG, "God 派发部门: name=${spec.name} session=${sub.id} agent=${definition?.name ?: "-"}") + } + return deployed to errors + } + + /** + * 组评审团:派 3 派评审员(严苛/务实/建设)只读审查 [diffSpec]。 + * [includeForeman] 为 true 时再追加一名评审长(forehead)汇总裁决。 + * 返回派发的部署结果;即 CEO 可先派 3 派评审、收齐意见后再带意见全文二次 orchestrate(review) 派评审长。 + */ + suspend fun spawnJury( + context: AgentContext, + diffSpec: String, + includeForeman: Boolean + ): Pair, List> { + if (diffSpec.isBlank()) return emptyList() to listOf("评审需要提供 diffSpec(改动范围)") + val specs = mutableListOf( + DeploySpec("评审·严苛", "review-tough", null, null, "请对以下改动做严苛评审(优先找必炸问题与硬约束违规),只读不改:\n$diffSpec"), + DeploySpec("评审·务实", "review-pragmatic", null, null, "请从可维护性/测试/性价比对以下改动做务实评审,只读不改:\n$diffSpec"), + DeploySpec("评审·建设", "review-optimist", null, null, "请从建设性视角(亮点+风险+值得跟进,勿放过硬错)评审以下改动,只读不改:\n$diffSpec") + ) + if (includeForeman) { + specs += DeploySpec("评审·裁决", "forehead", null, null, "请基于评审团意见(见指令)给出结构化裁决,只读不改。\n审查范围:$diffSpec") + } + return deploy(context, specs) + } + + /** 汇总父会话 + 全部子会话的 cost,产出额度视图(JSON 便于工具回填)。 */ + suspend fun budget(context: AgentContext): JsonObject { + val rootId = context.sessionId + val subs = if (rootId != null) chatSessionDao.getSubSessionsByParentOnce(rootId) else emptyList() + val parent = if (rootId != null) + runCatching { chatSessionDao.getById(rootId) }.getOrNull() + else null + val parentIn = parent?.totalInputTokens ?: 0 + val parentOut = parent?.totalOutputTokens ?: 0 + + val totalIn = parentIn + subs.sumOf { it.totalInputTokens } + val totalOut = parentOut + subs.sumOf { it.totalOutputTokens } + val depts = JsonArray(subs.map { s -> + JsonObject(mapOf( + "id" to JsonPrimitive(s.id), + "title" to JsonPrimitive(s.title), + "input" to JsonPrimitive(s.totalInputTokens), + "output" to JsonPrimitive(s.totalOutputTokens) + )) + }) + return JsonObject(mapOf( + "parentInput" to JsonPrimitive(parentIn), + "parentOutput" to JsonPrimitive(parentOut), + "totalInput" to JsonPrimitive(totalIn), + "totalOutput" to JsonPrimitive(totalOut), + "totalTokens" to JsonPrimitive(totalIn + totalOut), + "subCount" to JsonPrimitive(subs.size), + "running" to JsonPrimitive(eventBus.activeSubSessionIds.value.size), + "maxConcurrent" to JsonPrimitive(SubAgentEventBus.MAX_RUNNING), + "depts" to depts + )) + } + + /** 列出主会话的全部子会话及在线状态。 */ + suspend fun listSubSessions(parentId: String): List { + val subs = chatSessionDao.getSubSessionsByParentOnce(parentId) + val active = eventBus.activeSubSessionIds.value + return subs.map { s -> + SubSessionInfo(s.id, s.title, s.subagentType ?: "dept", if (s.id in active) SubSessionInfo.State.RUNNING else SubSessionInfo.State.COMPLETED) + } + } + + private suspend fun resolveProviderId(raw: String): String? { + aiProviderRepository.getProviderById(raw)?.let { return it.id } + val all = aiProviderRepository.getAllProviders().first() + return all.firstOrNull { it.name.equals(raw, ignoreCase = true) }?.id + } +} + +/** God 编排中一个子会话的简要视图(用于 status)。 */ +data class SubSessionInfo( + val id: String, + val title: String, + val type: String, + val state: State +) { + enum class State { RUNNING, COMPLETED } +} \ No newline at end of file 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..0edc97cd 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,10 @@ 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 || + mode == com.aicode.feature.agent.domain.model.AgentMode.GOD + ) { + // AUTO / TARGET / GOD 模式放行所有权限,但仍保留灾难性 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/provider/AIProvider.kt b/app/src/main/java/com/aicode/feature/agent/domain/provider/AIProvider.kt index 1c9a9395..4e017cc6 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/provider/AIProvider.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/provider/AIProvider.kt @@ -136,6 +136,14 @@ interface AIProvider { */ var maxOutputTokens: Int? + /** + * 本轮是否要求「带工具调用时把历史 assistant 消息的 reasoning_content 无条件回传(含空串占位)」。 + * DeepSeek 思考模式在请求带 tools 时必须把每轮思考内容原样回传,否则并行调多个工具 / 任一 + * 轮次思考为空时 API 报 400。由工作流按模型元数据 [ModelMetadata.supportsReasoning] 设置; + * 独立于模型名前缀(中转 / 自定义别名改名后 model 未必含 "deepseek")。 + */ + var requiresReasoningEcho: Boolean + /** * 本次请求的采样温度,调用前由工作流按模型元数据设置(见 [fixedTemperature])。 * null 表示请求里不带该字段、用服务端默认——服务端把温度固定住的模型(kimi-k3、gpt-5 系等) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/provider/AnthropicAdapter.kt b/app/src/main/java/com/aicode/feature/agent/domain/provider/AnthropicAdapter.kt index 284267a2..23e56e71 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/provider/AnthropicAdapter.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/provider/AnthropicAdapter.kt @@ -51,6 +51,7 @@ class AnthropicAdapter @Inject constructor( override var customHeaders: Map = emptyMap() override var maxOutputTokens: Int? = null + override var requiresReasoningEcho: Boolean = false // null 时请求体不带 temperature(Gson 跳过 null 字段),交由服务端默认;开启 thinking 时官方要求必须不带。 override var temperature: Float? = null @@ -78,13 +79,14 @@ class AnthropicAdapter @Inject constructor( val url = if (useFullUrl) baseUrl else joinUrl(baseUrl, defaultProviderApiPath(ProviderType.ANTHROPIC)) val (thinking, outputConfig) = buildThinkingConfig(reasoningEffort) + val (maxTokens, resolvedThinking) = resolveMaxTokensBudget(thinking) val request = AnthropicMessageRequest( model = model, messages = anthropicMessages, system = buildSystemPayload(systemPrompt), - max_tokens = resolveMaxTokens(thinking), + max_tokens = maxTokens, temperature = if (thinking != null) null else temperature, - thinking = thinking, + thinking = resolvedThinking, output_config = outputConfig, tools = toolDefs, stream = false @@ -154,13 +156,14 @@ class AnthropicAdapter @Inject constructor( val url = if (useFullUrl) baseUrl else joinUrl(baseUrl, defaultProviderApiPath(ProviderType.ANTHROPIC)) val (thinking, outputConfig) = buildThinkingConfig(reasoningEffort) + val (maxTokens, resolvedThinking) = resolveMaxTokensBudget(thinking) val request = AnthropicMessageRequest( model = model, messages = anthropicMessages, system = buildSystemPayload(systemPrompt), - max_tokens = resolveMaxTokens(thinking), + max_tokens = maxTokens, temperature = if (thinking != null) null else temperature, - thinking = thinking, + thinking = resolvedThinking, output_config = outputConfig, tools = toolDefs, stream = true @@ -365,13 +368,26 @@ class AnthropicAdapter @Inject constructor( } /** - * 最大输出 token:优先用模型元数据的输出上限,缺失时回退 [DEFAULT_MAX_TOKENS]。 - * 开启 thinking 时思考预算计入 max_tokens,须留出正文空间,故不得低于预算 + [MIN_CONTENT_TOKENS]。 + * 求解「max_tokens 与 thinking.budget_tokens」的兼容取值。 + * + * 两个硬约束(违反任一都会被官方 400): + * 1. budget_tokens ≥ 1024 且 < max_tokens(官方要求预算必须严格小于输出上限); + * 2. max_tokens ≤ 模型真实输出上限(小上限模型如 haiku 系 8192 配高预算档,旧逻辑把 max_tokens + * 抬到 12288 直接超上限 400)。 + * + * 因此正文空间取 [MIN_CONTENT_TOKENS] 与上限一半的较小者,预算 clamp 到「上限 - 正文空间」 + * 以内(且 ≥ [MIN_BUDGET_TOKENS]),最终 max_tokens = 预算 + 正文空间,恒 ≤ 上限, + * 且 budget < max_tokens 恒成立。第二元返回 clamp 后的预算(新否,原样放回请求体。 */ - private fun resolveMaxTokens(thinking: AnthropicThinkingConfig?): Int { + private fun resolveMaxTokensBudget(thinking: AnthropicThinkingConfig?): Pair { val limit = maxOutputTokens?.takeIf { it > 0 } ?: DEFAULT_MAX_TOKENS - val budget = thinking?.budget_tokens ?: return limit - return maxOf(limit, budget + MIN_CONTENT_TOKENS) + val budget = thinking?.budget_tokens ?: return limit to thinking + val contentRoom = MIN_CONTENT_TOKENS.coerceAtMost(limit / 2) + val maxBudget = (limit - contentRoom).coerceAtLeast(MIN_BUDGET_TOKENS) + val budgetClamped = budget.coerceIn(MIN_BUDGET_TOKENS, maxBudget) + val maxTokens = (budgetClamped + contentRoom).coerceAtMost(limit) + val clampedThinking = if (budgetClamped == budget) thinking else thinking?.copy(budget_tokens = budgetClamped) + return maxTokens to clampedThinking } /** thinking / redacted_thinking 块原样序列化为快照;无块时 null。 */ @@ -438,7 +454,21 @@ class AnthropicAdapter @Inject constructor( for (message in messages) { when (message) { is AgentMessage.UserMessage -> { - result.add(AnthropicMessage(role = "user", content = message.toAnthropicUserContent())) + // 连续 user 消息通用合并:严格网关(不自动合并连续同角色轮的实现)对连续 user 会直接 + // 400。官方端虽自动合并,但 Anthropic tool 循环 / LLM 调用失败补发等场景容易出现 + // 连续两条 user。若上一条已是 user,把本条的内容块并进其 content 列表(块数组归并)。 + val blocks = message.toAnthropicUserContentBlocks() + val previous = result.lastOrNull() + val previousBlocks = (previous?.content as? List<*>)?.filterIsInstance() + if (previous?.role == "user" && !previousBlocks.isNullOrEmpty()) { + result[result.lastIndex] = previous.copy( + content = previousBlocks.filter { it.type != "tool_result" } + blocks + ) + } else { + result.add( + AnthropicMessage(role = "user", content = message.toAnthropicUserContent()) + ) + } lastPlainUserIndex = result.lastIndex lastAssistantHadToolUse = false } @@ -504,7 +534,8 @@ class AnthropicAdapter @Inject constructor( val resultBlock = AnthropicContentBlock( type = "tool_result", tool_use_id = message.id, - content = content + content = content, + is_error = message.isError.takeIf { it } // 仅失败时置 true(官方允许省略即 false) ) // 同一条 assistant 里的多个 tool_use,其 tool_result 必须全部放进紧随的那一条 user 消息: // 官方端会把连续的 user 消息合并成一轮,但部分兼容网关不合并,会直接报 @@ -524,6 +555,19 @@ class AnthropicAdapter @Inject constructor( } } + // 首条消息角色兜底:历史若以 assistant(含 thinking 快照)或 tool_result 开头(崩溃恢复 / + // 工具循环中断的会话),严格网关会拒收非 user 首条。官方端会把连续同角色轮合并,因此这里 + // 前置一条空白 user 消息即可(与后续同角色轮合并成一轮,不改变语义)。 + if (result.isNotEmpty() && result.first().role != "user") { + result.add( + 0, + AnthropicMessage( + role = "user", + content = listOf(AnthropicContentBlock(type = "text", text = " ")) + ) + ) + } + // messages 断点:打在最后一条普通 user 消息的 text 块上(Anthropic 不允许打在 tool_result/thinking/image 块)。 // 工具循环中最后一条 user 是 tool_result 时不打点,保持 system+tools 两个断点即可。 if (addMessageBreakpoint) { @@ -597,6 +641,15 @@ class AnthropicAdapter @Inject constructor( private fun AgentMessage.UserMessage.toAnthropicUserContent(): Any { if (images.isEmpty()) return content + val blocks = toAnthropicUserContentBlocks() + return blocks + } + + /** 统一把 user 消息规范为内容块列表(文本 + 图片),供内容数组与连续 user 合并复用。 */ + private fun AgentMessage.UserMessage.toAnthropicUserContentBlocks(): List { + if (images.isEmpty()) { + return if (content.isBlank()) emptyList() else listOf(AnthropicContentBlock(type = "text", text = content)) + } val blocks = mutableListOf() if (content.isNotBlank()) { blocks.add(AnthropicContentBlock(type = "text", text = content)) @@ -628,6 +681,9 @@ class AnthropicAdapter @Inject constructor( /** 开启 thinking 时为正文预留的最小 token 数(max_tokens 必须大于思考预算)。 */ const val MIN_CONTENT_TOKENS = 4096 + /** 官方要求 thinking budget_tokens ≥ 1024。 */ + const val MIN_BUDGET_TOKENS = 1024 + val gson = com.google.gson.Gson() } } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/provider/GeminiAdapter.kt b/app/src/main/java/com/aicode/feature/agent/domain/provider/GeminiAdapter.kt index 57e82d99..1792281e 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/provider/GeminiAdapter.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/provider/GeminiAdapter.kt @@ -40,6 +40,7 @@ class GeminiAdapter @Inject constructor( // Gemini 发 generationConfig.maxOutputTokens(模型元数据的输出上限);为 null 时不发该参数,用服务端默认。 override var maxOutputTokens: Int? = null + override var requiresReasoningEcho: Boolean = false // 原生 generateContent 不发 generationConfig.temperature:Gemini 2.0 起服务端默认就是 1.0,与推荐值一致。 override var temperature: Float? = null diff --git a/app/src/main/java/com/aicode/feature/agent/domain/provider/OpenAIAdapter.kt b/app/src/main/java/com/aicode/feature/agent/domain/provider/OpenAIAdapter.kt index 0b450a22..cc1109b2 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/provider/OpenAIAdapter.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/provider/OpenAIAdapter.kt @@ -55,6 +55,7 @@ class OpenAIAdapter @Inject constructor( // OpenAI 系不发输出上限参数(用服务端默认),仅为接口完整性保留。 override var maxOutputTokens: Int? = null + override var requiresReasoningEcho: Boolean = false // null 时请求体不带 temperature(Gson 跳过 null 字段),交由服务端默认。 override var temperature: Float? = null @@ -172,7 +173,7 @@ class OpenAIAdapter @Inject constructor( reasoningEffort: String?, stream: Boolean ): Map { - val isDeepSeek = model.contains("deepseek", ignoreCase = true) + val reasoningEcho = requiresReasoningEcho || model.contains("deepseek", ignoreCase = true) val request = mutableMapOf( "model" to model, "input" to buildResponsesInput( @@ -181,7 +182,7 @@ class OpenAIAdapter @Inject constructor( messages = messages, // DeepSeek 思考模式:带 tools 的请求必须把历史每轮的思考内容回传(同 Chat // Completions 那边的 reasoning_content);官方/通用路径在存有合法快照时也会自动回传。 - includeReasoningItems = isDeepSeek + includeReasoningItems = reasoningEcho ) ) buildResponsesTools(tools)?.let { @@ -190,7 +191,7 @@ class OpenAIAdapter @Inject constructor( } if (stream) request["stream"] = true normalizeReasoningEffort(reasoningEffort)?.let { effort -> - if (isDeepSeek) { + if (model.contains("deepseek", ignoreCase = true)) { request["reasoning"] = mapOf("effort" to effort) } else { // OpenAI 官方/通用 Responses 规范:显式开启思考总结(summary: "auto"), @@ -553,8 +554,14 @@ class OpenAIAdapter @Inject constructor( message.toolCalls.map { convertToOpenAIToolCall(it) } } else null // DeepSeek 思考模式要求 assistant 消息的 reasoning_content 字段必须存在 - // (即使是空串也要带上),否则工具调用轮回传时 API 报 400。 - val reasoningContent = if (model.contains("deepseek", ignoreCase = true)) { + // (即使是空串也要带上),否则工具调用轮回传时 API 报 400,并行调多个工具恰是最容易 + // 踩中的场景——只要带 tools 请求里任一 assistant 轮缺该字段就 400。 + // 判定由工作流注入的 [requiresReasoningEcho](模型元数据 supportsReasoning)驱动, + // 不依赖模型名前缀:中转 / 自定义别名改名后 model 未必含 "deepseek"。 + // 兜底:本轮携带工具调用时也强制输出该字段(空串占位),因为推理模型带工具继续 + // 的轮次必然需要回传思考内容。 + val carriesToolCalls = message.toolCalls.isNotEmpty() + val reasoningContent = if (requiresReasoningEcho && (message.reasoning.isNotEmpty() || carriesToolCalls)) { message.reasoning } else { message.reasoning.ifEmpty { null } @@ -571,17 +578,20 @@ class OpenAIAdapter @Inject constructor( val modelText = message.modelResult ?: modelToolResultText(message.toolName, message.result) ?: message.result + // 空工具结果用占位,避免发出 `"content": ""`——部分兼容服务(含 DeepSeek)对 + // 空 content 的工具消息校验偏严,并行多工具下偶发 400。 + val safeText = modelText.ifBlank { "(空结果)" } val content: Any = if (message.images.isNotEmpty()) { val parts = mutableListOf>() - if (modelText.isNotBlank()) { - parts.add(mapOf("type" to "text", "text" to modelText)) + if (safeText.isNotBlank()) { + parts.add(mapOf("type" to "text", "text" to safeText)) } message.images.forEach { image -> parts.add(image.toOpenAIImagePart()) } parts } else { - modelText + safeText } OpenAIChatMessage( role = "tool", diff --git a/app/src/main/java/com/aicode/feature/agent/domain/provider/RetryPolicy.kt b/app/src/main/java/com/aicode/feature/agent/domain/provider/RetryPolicy.kt index 094e272b..fb9e9e08 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/provider/RetryPolicy.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/provider/RetryPolicy.kt @@ -292,8 +292,9 @@ suspend fun retryStaircase( * 回调在 delay 之前调用,确保 UI 能立即展示重试状态。声明为 suspend 以便 * 调用方在其中通过 Flow 的 emit() 推送重试事件。 * @param onContent 流式读取中成功收到内容块时调用(通常在 emit TextDelta/ReasoningDelta 处)。 - * 一旦收到过内容说明连接已恢复、请求已成功,此后若再断流应重置重试计数, - * 否则同一次请求内多次抖动会显示 1,2,3,4,5… 持续累加而不重新计数。 + * 用于上层感知"已收到过内容"(如取消首字节超时 watchdog)。注意它不参与 + * 重试计数:计数始终真实累计,避免"收到内容→断流"反复发生时被无限重置为 + * 1/6 死循环。 */ suspend fun streamWithStaircaseRetry( attemptOnce: suspend (onContent: () -> Unit) -> Unit, @@ -301,16 +302,13 @@ suspend fun streamWithStaircaseRetry( ) { var attempt = 0 while (true) { - var receivedContent = false try { - attemptOnce { receivedContent = true } + attemptOnce { /* 收到内容由调用方自行处理(如取消首字节 watchdog) */ } return } catch (e: CancellationException) { throw e } catch (e: Throwable) { coroutineContext.ensureActive() - // 本次尝试已成功收到过内容:视为新一轮请求,重置重试计数 - if (receivedContent) attempt = 0 if (attempt >= MAX_NETWORK_RETRIES || !isRetriableNetworkError(e)) throw e val wait = retryDelayMillis(attempt, e) FileLogger.w(TAG, "流式请求失败,第 ${attempt + 1}/$MAX_NETWORK_RETRIES 次重试(等待 ${wait}ms): ${e.javaClass.simpleName} ${e.message}") diff --git a/app/src/main/java/com/aicode/feature/agent/domain/session/MessagePersistenceUseCase.kt b/app/src/main/java/com/aicode/feature/agent/domain/session/MessagePersistenceUseCase.kt index 49711923..f83a279e 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/session/MessagePersistenceUseCase.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/session/MessagePersistenceUseCase.kt @@ -264,7 +264,10 @@ class MessagePersistenceUseCase @Inject constructor( val imageAttachments = e.attachmentsJson?.let { runCatching { json.decodeFromString>(it) }.getOrNull() }.orEmpty() - if (e.content.isNotBlank() || toolCalls.isNotEmpty() || imageAttachments.isNotEmpty()) { + // 纯思考轮(content 空、无 tool_calls、无图片,只有 reasoning)也要保留:DeepSeek 思考模式的 + // 历史回传要求 reasoning_content 逐轮原样带上,丢弃会断思考链、且让带 tools 的后续 + // 请求缺 reason 字段触发 400(见 OpenAIAdapter 判据)。 + if (e.content.isNotBlank() || toolCalls.isNotEmpty() || imageAttachments.isNotEmpty() || !e.reasoning.isNullOrBlank()) { val previous = result.lastOrNull() if ( e.isContextSummary && 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..ac721694 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 / GOD 四种切换。BUILD 切换到 PLAN 可构思复杂逻辑,PLAN 计划完成后切 BUILD 修改代码。切换到 TARGET 模式需提供 goal 参数设定目标声明,进入后 AI 依据目标自主执行直到达成或失败。GOD(神谕/乙方公司)模式:主会话升级为 CEO/总汇报员,可经 orchestrate 工具派发部门子代理、组评审团、双风格产出并向甲方(用户)汇报,治理(权限审批/模式切换)由 CEO 代为决策。切换前需要用户授权。注意: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' 或 'GOD'", required = true, - enum = listOf("PLAN", "BUILD") + enum = listOf("PLAN", "BUILD", "TARGET", "GOD") ), "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 或 GOD", "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/tool/orchestration/OrchestrateTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/orchestration/OrchestrateTool.kt new file mode 100644 index 00000000..3c42ec22 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/orchestration/OrchestrateTool.kt @@ -0,0 +1,191 @@ +package com.aicode.feature.agent.domain.tool.orchestration + +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.model.AgentContext +import com.aicode.feature.agent.domain.orchestration.OrchestratorService +import com.aicode.feature.agent.domain.tool.AbstractContextualTool +import com.aicode.feature.agent.domain.tool.ParameterType +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.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import javax.inject.Inject + +/** + * God Mode 编排工具 `orchestrate`(仅 GOD 模式下对 CEO 可见,普通会话不注入)。 + * + * - `phase=decompose`(默认):把需求拆成多个部门子任务,一次派发(可指定 agent/模型/思考强度)。 + * - `phase=review`:派 3 派评审员(可选加评审长)对某改动范围只读评审。 + * - `phase=status`:列出全部子部门/评审会话状态。 + * - `phase=budget`:汇总本场协作的额度消耗。 + * + * 取消一致性:工具本身不阻塞等待子代理完成;CEO 收完成通知后 `task(read)` 拉结论,再据此编排下一阶段。 + * 只允许在 GOD 模式、且仅对主会话(无 agentDefinition 绑定的根会话)使用。 + */ +class OrchestrateTool @Inject constructor( + private val orchestrator: OrchestratorService +) : AbstractContextualTool() { + + private companion object { + const val TAG = "Orchestrate" + } + + override val name = "orchestrate" + override val permissionPolicy = ToolPermissionPolicy.ASK + override val capabilities = setOf(ToolCapability.MODIFY_SESSION_STATE) + + override fun effectiveCapabilities(args: Map): Set { + val phase = args["phase"]?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: "decompose" + return when (phase) { + "status", "budget" -> emptySet() + else -> setOf(ToolCapability.MODIFY_SESSION_STATE) + } + } + + override val description = "God Mode 编排(乙方公司运营):把需求拆成多个部门子任务派发(decompose)、组评审团审查改动(review)、查看各子会话状态(status)、汇总本场额度消耗(budget)。部门/评审可指定不同 agent 名(确定其专属提示词/模型/工具集)。仅 GOD 模式下可用。" + + override val parameters: Map = mapOf( + "phase" to ToolParameter( + name = "phase", + type = ParameterType.STRING, + description = "操作类型:decompose(默认,拆分任务派发部门)/ review(组评审团审查改动)/ status(列出子会话状态)/ budget(汇总额度)", + required = false, + enum = listOf("decompose", "review", "status", "budget") + ), + "goal" to ToolParameter( + name = "goal", + type = ParameterType.STRING, + description = "decompose 时的一段需求/目标描述,供派发部门理解全局上下文", + required = false + ), + "tasks" to ToolParameter( + name = "tasks", + type = ParameterType.ARRAY, + description = "decompose 时拆出的子任务数组,每项 {name, agent?, model?, reasoningEffort?, prompt}。prompt 非必需会丢弃该项。", + required = false + ), + "diffSpec" to ToolParameter( + name = "diffSpec", + type = ParameterType.STRING, + description = "review 时的改动范围(文件清单 / 未提交 diff 摘要),喂给评审团", + required = false + ), + "foreman" to ToolParameter( + name = "foreman", + type = ParameterType.STRING, + description = "review 时是否同时派评审长汇总(true/false,默认 false:先派 3 名评审,CEO 收齐意见后再带全文派评审长)", + required = false + ) + ) + + override suspend fun executeWithContext(args: Map, context: AgentContext): ToolResult { + return try { + if (context.mode != com.aicode.feature.agent.domain.model.AgentMode.GOD) { + return ToolResult.Error("orchestrate 仅在 God Mode 下可用。请先切换为 GOD 模式。", "NOT_GOD_MODE") + } + val phase = args["phase"]?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: "decompose" + when (phase) { + "decompose" -> decompose(args, context) + "review" -> review(args, context) + "status" -> status(context) + "budget" -> budget(context) + else -> ToolResult.Error("未知 phase: $phase,支持 decompose / review / status / budget", "INVALID_PHASE") + } + } catch (e: Exception) { + FileLogger.e(TAG, "orchestrate 异常", e) + ToolResult.Error(e.message ?: "编排失败", "ORCHESTRATE_ERROR") + } + } + + private suspend fun decompose(args: Map, context: AgentContext): ToolResult { + val goal = args["goal"]?.jsonPrimitive?.contentOrNull?.trim() + val tasks = args["tasks"]?.let { orchestrator.parseDeploySpecs(it) } ?: emptyList() + if (tasks.isEmpty()) { + return ToolResult.Error("需要提供 tasks(子任务数组,含每项 prompt)", "MISSING_TASKS") + } + // goal 若给了但 tasks 没显含,把它追加到首个任务或整体说明——不重新包装,直接在结果说明。 + if (goal != null) { + FileLogger.i(TAG, "decompose goal=$goal tasks=${tasks.size}") + } + val (deployed, errors) = orchestrator.deploy(context, tasks) + return ToolResult.Success( + buildJsonObject { + put("goal", goal ?: "") + put("deployed", buildJsonArray { + deployed.forEach { d -> + add(buildJsonObject { + put("id", d.id) + put("title", d.title) + put("agent", d.agent ?: "") + put("model", d.model ?: "") + put("reasoningEffort", d.reasoningEffort ?: "") + put("state", "running") + }) + } + }) + put("deployedCount", deployed.size) + put("errors", buildJsonArray { errors.forEach { add(JsonPrimitive(it)) } }) + put("message", if (deployed.isEmpty()) "未派发任何部门(见 errors)" else "已派发 ${deployed.size} 个部门,完成后会通知。可用 task(action=\"read\", id=...) 读结果。") + } + ) + } + + private suspend fun review(args: Map, context: AgentContext): ToolResult { + val diffSpec = args["diffSpec"]?.jsonPrimitive?.contentOrNull?.trim() ?: "" + if (diffSpec.isEmpty()) { + return ToolResult.Error("review 需要提供 diffSpec(改动范围)", "MISSING_DIFF") + } + val includeForeman = args["foreman"]?.jsonPrimitive?.contentOrNull + ?.let { it.equals("true", ignoreCase = true) } == true + val (deployed, errors) = orchestrator.spawnJury(context, diffSpec, includeForeman) + return ToolResult.Success( + buildJsonObject { + put("review", buildJsonArray { + deployed.forEach { d -> + add(buildJsonObject { + put("id", d.id) + put("title", d.title) + put("agent", d.agent ?: "") + put("state", "running") + }) + } + }) + put("count", deployed.size) + put("errors", buildJsonArray { errors.forEach { add(JsonPrimitive(it)) } }) + put("message", if (deployed.isEmpty()) "评审团未成立(见 errors)" else "已成立评审团(${deployed.size} 名),完成后会通知。CEO 可收齐意见后用 orchestrate(review, foreman=true) 派评审长裁决。") + } + ) + } + + private suspend fun status(context: AgentContext): ToolResult { + val subs = context.sessionId?.let { + runCatching { orchestrator.listSubSessions(it) }.getOrElse { emptyList() } + } ?: emptyList() + return ToolResult.Success( + buildJsonObject { + put("subagents", buildJsonArray { + subs.forEach { s -> + add(buildJsonObject { + put("id", s.id) + put("title", s.title) + put("state", s.state.name) + }) + } + }) + put("count", subs.size) + } + ) + } + + private suspend fun budget(context: AgentContext): ToolResult { + val b = orchestrator.budget(context) + return ToolResult.Success(b) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/InvertedIndex.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/InvertedIndex.kt new file mode 100644 index 00000000..90ed3883 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/InvertedIndex.kt @@ -0,0 +1,57 @@ +package com.aicode.feature.agent.domain.tool.semantic + +import kotlin.math.ln + +/** + * 进程内存态倒排索引 + TF-IDF 相关性打分。 + * + * 纯数据结构(JVM 可测、无 I/O): + * - `addDocument(docId, contentTerms, pathTerms)`:倒排写入内容词元;[pathTerms](文件名/路径 + * 片段词元)额外再计一次,等效权重更高 —— 命中文件名的文档靠前,利于“猜文件”。 + * - `score(queryTerms, k)`:TF 归一(1+ln(tf))× IDF(ln((N+1)/(df+1))+1)逐词累积取 Top-K; + * 与 query 无交集的文档不返回。 + * - 打分稳定:纯加法、无随机,同输入同输出。 + */ +class InvertedIndex { + + // term -> (docId -> tf) + private val termDocs = HashMap>() + private val docIds = LinkedHashSet() + + /** 已索引文档数。 */ + val size: Int get() = docIds.size + + fun addDocument(docId: String, contentTerms: List, pathTerms: List = emptyList()) { + require(docIds.add(docId)) { "duplicate docId: $docId" } + for (term in contentTerms) bump(docId, term, 1) + for (term in pathTerms) bump(docId, term, 1) + } + + private fun bump(docId: String, term: String, delta: Int) { + val bucket = termDocs.getOrPut(term) { HashMap() } + bucket[docId] = (bucket[docId] ?: 0) + delta + } + + /** 按 [queryTerms] 相关度返回 Top-K(分数降序);空 query 或索引空返回空列表。 */ + fun score(queryTerms: List, k: Int): List { + if (queryTerms.isEmpty() || docIds.isEmpty()) return emptyList() + val n = docIds.size + val scores = HashMap() + for (term in queryTerms) { + val bucket = termDocs[term] ?: continue + val df = bucket.size + if (df == 0) continue + val idf = ln((n + 1.0) / (df + 1.0)) + 1.0 + for ((docId, tf) in bucket) { + val w = 1.0 + ln(tf.toDouble()) + scores[docId] = (scores[docId] ?: 0.0) + w * idf + } + } + return scores.entries + .sortedByDescending { it.value } + .take(k) + .map { ScoredDoc(it.key, it.value) } + } + + data class ScoredDoc(val docId: String, val score: Double) +} \ No newline at end of file diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/SemanticIndexer.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/SemanticIndexer.kt new file mode 100644 index 00000000..c4fe6a6f --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/SemanticIndexer.kt @@ -0,0 +1,171 @@ +package com.aicode.feature.agent.domain.tool.semantic + +import com.aicode.core.util.FileLogger +import com.aicode.feature.workspace.data.repository.WorkspaceRepository +import com.aicode.feature.workspace.domain.FileAccessProvider +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 语义索引的编排者:懒构建 + 失效重建。 + * + * - 首次查询触发全量构建;之后每次查询前对比已索引文件快照(mtime/size,含新增/删除), + * 有变化则重建。索引放内存、进程内复用、不落盘(见 specs/semantic-index/design.md)。 + * - 通过 [FileAccessProvider] 遍历:本地 / SFTP 后端由 DI 统一注入,两模式一个实现。 + * - 索引根用 [WorkspaceRepository.currentPath](本地为宿主工作区绝对路径,远程为远程路径), + * 快照与索引都用 fileAccess 的 display 路径(本地即 `~/workspace/...`)作 docId,与 `list`/`readFile` 一致。 + * - 并发安全:构建用 [Mutex] 串行;构建中再查询排队等待,不并行构建。 + * - 防护:二进制 / 超大文件跳过;索引文件数达 [MAX_FILES] 截断并标注。 + */ +@Singleton +class SemanticIndexer @Inject constructor( + private val fileAccess: FileAccessProvider, + private val workspaceRepository: WorkspaceRepository +) { + + private companion object { + const val TAG = "SemanticIndexer" + const val MAX_FILES = 8000 + const val MAX_FILE_BYTES = 1_048_576L // 1 MiB + // 二进制文件扩展名黑名单(小写,无点)。 + val BINARY_EXT = setOf( + "png", "jpg", "jpeg", "gif", "webp", "ico", "bmp", "svg", "tiff", + "pem", "key", "jks", "keystore", "apk", "aab", "dex", "jar", "aar", + "so", "a", "o", "class", "dll", "exe", "bin", "zip", "gz", "xz", "bz2", "tar", "7z", "rar" + ) + // 跳过目录(精确名匹配)。 + val SKIP_DIR = setOf(".git", "build", ".gradle", "node_modules", ".idea", ".kotlin") + } + + private data class DocSnapshot(val mtime: Long, val size: Long) + + /** 查询入口的返回:索引 + 元信息。 */ + data class Readable( + val index: InvertedIndex, + val indexedFiles: Int, + val truncated: Boolean + ) + + private data class Built( + val index: InvertedIndex, + val snapshots: Map, + val indexedFiles: Int + ) + + private var current: Built? = null + private val mutex = Mutex() + + /** + * 确保索引新鲜。工作区不可达 / 构建失败抛 [SemanticIndexUnavailableException]; + * 首次调用或检测到变化时串行重建。 + */ + suspend fun ensureIndex(): Readable = mutex.withLock { + val root = workspaceRepository.currentPath() + + val snapshots = try { + collectSnapshots(root) + } catch (e: Exception) { + current = null + FileLogger.w(TAG, "收集工作区快照失败: ${e.message}") + throw SemanticIndexUnavailableException("无法读取工作区: ${e.message}", e) + } + + val cached = current + if (cached == null || cached.snapshots != snapshots) { + val built = try { + build(root, snapshots) + } catch (e: Exception) { + FileLogger.e(TAG, "索引构建失败", e) + throw SemanticIndexUnavailableException("索引构建失败: ${e.message}", e) + } + current = built + return@withLock Readable(built.index, built.indexedFiles, snapshots.size > MAX_FILES) + } + Readable(cached.index, cached.indexedFiles, snapshots.size > MAX_FILES) + } + + /** 只遍历一次文件树,收集「应索引文件」的快照(不读内容,用于失效检测)。 */ + private suspend fun collectSnapshots(root: String): Map { + val out = LinkedHashMap() + walkCounted(root, out) + // 完整路径 key。collect 阶段对每个目录独立计数不可靠,改为统一的全局计数。 + return out + } + + private suspend fun walkCounted(dir: String, acc: MutableMap) { + if (!fileAccess.exists(dir) || !fileAccess.isDirectory(dir)) return + val children = try { + fileAccess.listFiles(dir) + } catch (e: Exception) { + return + } + for (child in children) { + if (acc.size >= MAX_FILES) return + val path = "$dir/${child.name}" + if (child.isDirectory) { + if (child.name in SKIP_DIR) continue + walkCounted(path, acc) + } else { + if (!shouldIndexFile(child.name, child.size)) continue + acc[path] = DocSnapshot(child.lastModified, child.size) + } + } + } + + private fun shouldIndexFile(name: String, size: Long): Boolean { + if (size > MAX_FILE_BYTES) return false + val ext = name.substringAfterLast('.', "").lowercase() + return ext !in BINARY_EXT + } + + /** 第二次遍历,读文本内容(词元化)入倒排。 */ + private suspend fun build(root: String, snapshots: Map): Built { + val index = InvertedIndex() + var indexed = 0 + val seen = HashSet() + + suspend fun walk(dir: String) { + if (indexed >= MAX_FILES) return + if (!fileAccess.exists(dir) || !fileAccess.isDirectory(dir)) return + val children = try { + fileAccess.listFiles(dir) + } catch (e: Exception) { + return + } + for (child in children) { + if (!seen.add(child.name)) continue + if (indexed >= MAX_FILES) return + val path = "$dir/${child.name}" + if (child.isDirectory) { + if (child.name in SKIP_DIR) continue + walk(path) + } else { + if (!shouldIndexFile(child.name, child.size)) continue + // 只处理仍在「快照集合」中的文件:快照比较后新增的文件也在其中。 + if (snapshots[path] == null) continue + val content = try { + fileAccess.readFile(path) + } catch (e: Exception) { + continue + } + val displayPath = fileAccess.toDisplayPath(path) + index.addDocument( + docId = displayPath, + contentTerms = SemanticTokenizer.tokenize(content), + pathTerms = SemanticTokenizer.tokenize(displayPath) + ) + indexed++ + } + } + } + + walk(root) + FileLogger.i(TAG, "索引构建完成 files=$indexed root=$root") + return Built(index, snapshots, indexed) + } +} + +/** 工作区不可达或索引构建失败时抛出,由调用方转为工具错误码 [INDEX_UNAVAILABLE]。 */ +class SemanticIndexUnavailableException(message: String, cause: Throwable? = null) : Exception(message, cause) \ No newline at end of file diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/SemanticSearchTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/SemanticSearchTool.kt new file mode 100644 index 00000000..859d6e23 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/SemanticSearchTool.kt @@ -0,0 +1,120 @@ +package com.aicode.feature.agent.domain.tool.semantic + +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.tool.AgentTool +import com.aicode.feature.agent.domain.tool.ParameterType +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 com.aicode.feature.workspace.domain.FileAccessProvider +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlin.math.round +import javax.inject.Inject + +/** + * 本地关键词(TF-IDF)语义检索工具。 + * + * 用自然语言召回工作区内相关代码文件,容忍词面差异,区别于 `search`(rg 精确匹配)与 + * `list`(目录枚举)。纯本地:不调网络、不用远端 embedding,离线可用。远程工作区经 + * [FileAccessProvider] 的 SFTP 后端读取。 + */ +class SemanticSearchTool @Inject constructor( + private val indexer: SemanticIndexer, + private val fileAccess: FileAccessProvider +) : AgentTool() { + + private companion object { + const val TAG = "SemanticSearch" + const val DEFAULT_TOP_K = 8 + const val SNIPPET_LINES = 6 + const val SNIPPET_MAX_CHARS = 240 + } + + override val name = "semantic_search" + override val description = "按自然语言(如“…鉴权在哪实现”“加载配置的工具”)召回工作区内最相关的代码文件,按相关度降序返回路径与命中片段。" + override val permissionPolicy = ToolPermissionPolicy.AUTO_APPROVE + override val capabilities = setOf(ToolCapability.READ_WORKSPACE) + + override val parameters = mapOf( + "query" to ToolParameter( + name = "query", + type = ParameterType.STRING, + description = "自然语言检索问句或关键词,表达想要找的代码 / 配置意图。", + required = true + ) + ) + + override suspend fun execute(args: Map): ToolResult { + return try { + val query = args["query"]?.jsonPrimitive?.contentOrNull?.trim().orEmpty() + if (query.isEmpty()) return ToolResult.Error("缺少检索 query", "MISSING_QUERY") + + val queryTerms = SemanticTokenizer.tokenize(query) + if (queryTerms.isEmpty()) return ToolResult.Error("query 无可检索词元", "EMPTY_QUERY") + + val startedAt = System.currentTimeMillis() + val readable = try { + indexer.ensureIndex() + } catch (e: SemanticIndexUnavailableException) { + FileLogger.w(TAG, "语义索引不可用: ${e.message}") + return ToolResult.Error("语义索引暂不可用: ${e.message}", "INDEX_UNAVAILABLE") + } + + val ranked = readable.index.score(queryTerms, DEFAULT_TOP_K) + val rankedWithSnippets = ranked.map { doc -> + JsonObject(mapOf( + "path" to JsonPrimitive(doc.docId), + "score" to JsonPrimitive(round(doc.score * 1000.0) / 1000.0), + "snippet" to JsonPrimitive(readSnippet(doc.docId)) + )) + } + + val content = if (ranked.isEmpty()) { + "未找到与“$query”相关的结果" + } else { + resultsText(ranked, readable.indexedFiles) + } + + ToolResult.Success(JsonObject(mapOf( + "content" to JsonPrimitive(content), + "results" to JsonArray(rankedWithSnippets), + "top_k" to JsonPrimitive(ranked.size), + "indexed_files" to JsonPrimitive(readable.indexedFiles), + "truncated" to JsonPrimitive(readable.truncated), + "elapsed_ms" to JsonPrimitive(System.currentTimeMillis() - startedAt), + "backend" to JsonPrimitive("local-tfidf") + ))) + } catch (e: Exception) { + FileLogger.e(TAG, "semantic_search 异常", e) + ToolResult.Error(e.message ?: "语义检索失败", "SEARCH_ERROR") + } + } + + private fun resultsText(ranked: List, indexedFiles: Int): String { + val sb = StringBuilder("在 $indexedFiles 个文件中召回 ${ranked.size} 个最相关候选:\n") + ranked.forEachIndexed { i, doc -> + val line = "${i + 1}. ${doc.docId} (score ${round(doc.score * 1000.0) / 1000.0})" + sb.append(line) + if (i != ranked.lastIndex) sb.append('\n') + } + return sb.toString() + } + + private fun readSnippet(displayPath: String): String { + return try { + val head = fileAccess.readFile(displayPath) + .lineSequence() + .take(SNIPPET_LINES) + .joinToString("\n") + if (head.length > SNIPPET_MAX_CHARS) head.take(SNIPPET_MAX_CHARS) + "…" else head + } catch (e: Exception) { + "" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/SemanticTokenizer.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/SemanticTokenizer.kt new file mode 100644 index 00000000..370ea517 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/semantic/SemanticTokenizer.kt @@ -0,0 +1,75 @@ +package com.aicode.feature.agent.domain.tool.semantic + +/** + * 语义索引的分词器:把代码 / 自然语言文本切成可检索的词元。 + * + * 策略(纯函数,JVM 可测): + * - 以非字母数字字符切分(去空白、符号); + * - 下划线 / 中划线 / 点 / 大小写分界再拆 —— `userLoginHandler` → `user`,`login`,`handler`, + * `auth_token` → `auth`,`token`; + * - 全小写归一; + * - 单个字母/数字也保留(代码里 `i`/`e` 常见,双消歧时有用); + * - 连续 CJK 段额外产二元组,让中文注释 / 中文问句可召回(`鉴权`→`鉴`,`权`;也保留整段 `鉴权`)。 + * + * 该实现刻意轻量:不引入分词库、零网络、纯本地,适合移动端离线索引。 + */ +object SemanticTokenizer { + + private val NON_WORD = Regex("[^A-Za-z0-9\\u4e00-\\u9fff]+") + private val IDENTIFIER_BOUNDARY = Regex("([a-z])([A-Z])|([A-Za-z])([0-9])|([0-9])([A-Za-z])|([A-Za-z0-9])([_-])|([_-])([A-Za-z0-9])") + private val CJK_RUN = Regex("[\\u4e00-\\u9fff]+") + + fun tokenize(text: String): List { + val out = mutableListOf() + for (raw in NON_WORD.split(text)) { + if (raw.isEmpty()) continue + appendWord(raw, out) + } + return out + } + + private fun appendWord(raw: String, out: MutableList) { + // 纯 CJK 段:保留整段 + 二元组 + 单字。 + if (raw.all { it.code in 0x4e00..0x9fff }) { + if (raw.length == 1) { + out.add(raw) + return + } + out.add(raw) + for (i in 0 until raw.length - 1) out.add(raw.substring(i, i + 2)) + // 中文分词很难,这里不再拆单字(避免噪声过大);整段 + 二元组已能区分大多数意图。 + return + } + + // 含 ASCII 的混合段:按下划线/中划线先粗切,再按驼峰 / 数字边界细拆。 + for (chunk in raw.split('_', '-')) { + if (chunk.isEmpty()) continue + val parts = splitCamel(chunk) + for (p in parts) { + if (p.isEmpty()) continue + out.add(p.lowercase()) + } + } + } + + private fun splitCamel(word: String): List { + if (word.length <= 1) return listOf(word) + val result = mutableListOf() + var start = 0 + var i = 1 + while (i < word.length) { + val cur = word[i] + val prev = word[i - 1] + val boundary = (cur.isUpperCase() && prev.isLowerCase()) || + (cur.isLetter() != prev.isLetter() && (prev.isDigit() || cur.isDigit())) || + (cur.isDigit() && !prev.isDigit()) + if (boundary && i != start) { + result.add(word.substring(start, i)) + start = i + } + i++ + } + result.add(word.substring(start)) + return result + } +} \ No newline at end of file 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..871c16ba 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,18 @@ 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" + const val MODE_REMINDER_GOD_FILE = "83-god-mode.md" val LEADING_COMMENT = Regex("(?s)^\\s*\\s*") /** 模型直出图片落盘目录(与 GenerateImageTool 保持一致)。 */ const val GENERATED_IMAGE_DIR = "~/.aicode/generated-images" @@ -266,6 +277,8 @@ class StatefulAgentWorkflow @Inject constructor( val metadata = modelMetadataService.resolve(config.id, config.type, config.effectiveModel) // 模型元数据的输出上限(models.dev limit.output):不传时 Anthropic 会把输出卡在 adapter 兜底值上。 provider.maxOutputTokens = metadata.outputTokens + // DeepSeek 思考模式带 tools 时须回传每轮 reasoning_content(含空串占位),否则并行多工具 400。 + provider.requiresReasoningEcho = metadata.supportsReasoning // 元数据说不接受自定义温度就不发该字段(kimi-k3、gpt-5 系带了直接 400);允许的只发官方固定值。 provider.temperature = if (metadata.supportsCustomTemperature) fixedTemperature(config.effectiveModel) else null return provider @@ -358,7 +371,8 @@ class StatefulAgentWorkflow @Inject constructor( result = ToolResult.Error( "用户拒绝了本轮工具调用,该调用未执行。", USER_REJECTED_CODE - ).toTransportString() + ).toTransportString(), + isError = true ) } newState = state.copy( @@ -408,7 +422,8 @@ class StatefulAgentWorkflow @Inject constructor( toolName = batchResult.toolName, result = batchResult.result, images = batchResult.images, - modelResult = modelToolResultText(batchResult.toolName, batchResult.result) + modelResult = modelToolResultText(batchResult.toolName, batchResult.result), + isError = batchResult.isError ) ) } @@ -434,9 +449,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 +764,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 +795,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 +987,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 +1223,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,7 +1232,16 @@ 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 + AgentMode.GOD -> promptProvider.resolvePrompt(MODE_REMINDER_GOD_FILE) + .replace(LEADING_COMMENT, "") + .trim() + .let { "【模式提醒】$it" } } /** 工具切换成功后拼进 switchMode 工具结果的模式状态通知(当轮即可见,无需等下一条用户消息)。 */ @@ -1029,12 +1256,19 @@ 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() + AgentMode.GOD -> "\n\n" + promptProvider.resolvePrompt(MODE_REMINDER_GOD_FILE) + .replace(LEADING_COMMENT, "") + .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..0d49121e 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 @@ -19,6 +19,7 @@ import com.aicode.feature.agent.data.local.dao.AgentMessageDao import com.aicode.feature.agent.domain.checkpoint.CheckpointManager import com.aicode.feature.agent.data.local.dao.CheckpointDao import com.aicode.feature.agent.data.local.dao.ChatSessionDao +import com.aicode.feature.agent.data.local.entity.AgentMessageEntity import com.aicode.feature.agent.data.local.entity.ChatSessionEntity import com.aicode.feature.agent.domain.container.ContainerInitState import com.aicode.feature.agent.domain.container.LinuxContainerEngine @@ -34,12 +35,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 +60,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 @@ -69,6 +73,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import com.aicode.feature.backup.domain.BackupManager +import com.aicode.feature.backup.domain.SessionMarkdownExporter import com.aicode.feature.agent.domain.command.SlashCommandContext import com.aicode.feature.agent.domain.command.SlashCommandRegistry import com.aicode.feature.agent.domain.command.SlashCommandHandler @@ -82,6 +87,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 +110,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 +144,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 +239,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 +367,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 +654,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 +955,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 +1042,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 +1341,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 +1364,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 +1574,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 +1808,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 +1932,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() { @@ -1812,6 +2283,25 @@ class AIAgentViewModel @Inject constructor( } } + /** 导出单个会话为 Markdown 可读文档,写入 [output](调用方打开,本方法负责关闭)。成功回调 true,失败回调 false。 */ + fun exportSessionMarkdown(sessionId: String, output: OutputStream, onResult: (Boolean) -> Unit) = viewModelScope.launch { + try { + val session = sessionUseCase.getSessionById(sessionId) + val messages = withContext(Dispatchers.IO) { agentMessageDao.getMessagesBySessionOnce(sessionId) } + val markdown = SessionMarkdownExporter.build( + title = session?.title.orEmpty(), + messages = messages, + ) + withContext(Dispatchers.IO) { output.write(markdown.toByteArray(Charsets.UTF_8)) } + onResult(true) + } catch (e: Exception) { + FileLogger.e("AIAgentViewModel", "exportSessionMarkdown failed", e) + onResult(false) + } finally { + runCatching { output.close() } + } + } + private suspend fun ensureSession(): String { _currentSessionId.value?.let { return it } val ws = _currentWorkspace.value @@ -1870,6 +2360,29 @@ class AIAgentViewModel @Inject constructor( } } + /** 从指定消息处分叉新会话:锚点及之前消息复制为新会话上下文,原会话不变。 */ + fun forkSessionAt(messageId: String) = viewModelScope.launch { + val curId = _currentSessionId.value ?: return@launch + val ws = _currentWorkspace.value + if (ws.isBlank()) return@launch + try { + val source = agentMessageDao.getMessagesBySessionOnce(curId) + val newSessionId = createAndUpsertSession(ws) + val forked = buildForkedMessages(source, messageId, newSessionId) + if (forked.isEmpty()) { + // 锚点无效或源为空:回滚刚建的空会话,不留垃圾 + sessionUseCase.deleteSession(newSessionId) + return@launch + } + agentMessageDao.insertAll(forked) + val origTitle = sessionUseCase.getSessionById(curId)?.title + if (!origTitle.isNullOrBlank()) sessionUseCase.updateTitle(newSessionId, origTitle) + _currentSessionId.value = newSessionId + } catch (e: Exception) { + FileLogger.e(TAG, "forkSessionAt failed", e) + } + } + private fun detectLanguage(filePath: String): String { return when (filePath.substringAfterLast(".").lowercase()) { "kt", "kotlin" -> "kotlin" @@ -1886,3 +2399,22 @@ class AIAgentViewModel @Inject constructor( } } } + +/** + * 分叉复制:返回锚点消息(含)之前的所有消息,重生成 id 并替换 sessionId,其余字段原样。 + * 锚点不存在或源为空时返回空列表,调用方据此回滚新建的空会话。 + */ +internal fun buildForkedMessages( + source: List<AgentMessageEntity>, + anchorId: String, + newSessionId: String, +): List<AgentMessageEntity> { + val anchorIndex = source.indexOfFirst { it.id == anchorId } + if (anchorIndex < 0) return emptyList() + return source.subList(0, anchorIndex + 1).map { entity -> + entity.copy( + id = java.util.UUID.randomUUID().toString(), + sessionId = newSessionId, + ) + } +} 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..857cda88 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 @@ -251,6 +257,9 @@ fun AIChatPanel( selectedCode: String? = null, onboardingStep: OnboardingStep? = null, onSelectModelInOnboarding: (() -> Unit)? = null, + /** 外部分享注入的图片 Uri,由 [handlePickedAttachments] 消费后清空。 */ + pendingShareImages: List<Uri> = emptyList(), + onShareImagesConsumed: () -> Unit = {}, modifier: Modifier = Modifier ) { val agentState by viewModel.agentState.collectAsStateWithLifecycle() @@ -267,6 +276,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 +302,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 +331,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 +376,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() @@ -527,6 +562,14 @@ fun AIChatPanel( } } + // 外部分享注入的图片:走与 picker 相同的 handlePickedAttachments 链路,消费后通知清空中转站。 + LaunchedEffect(pendingShareImages) { + if (pendingShareImages.isNotEmpty()) { + handlePickedAttachments(pendingShareImages, images = true) + onShareImagesConsumed() + } + } + val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> handlePickedAttachments(uris, images = false) } @@ -560,6 +603,50 @@ fun AIChatPanel( takePictureLauncher.launch(uri) } + // ── 语音听写:系统 SpeechRecognizer 内嵌实时出字,定稿经 onValueChange 走草稿落盘 ── + val voiceController = remember { + ChatVoiceInputController( + context = context, + onPartialPreview = { partial -> inputText = partial }, + onFinal = { final -> + inputText = final + viewModel.updateInputDraft(final) + }, + onError = { error -> + val msg = when (error) { + ChatVoiceInputController.ChatVoiceError.NO_MATCH -> R.string.chat_voice_no_match + ChatVoiceInputController.ChatVoiceError.FAILED -> R.string.chat_voice_error + } + Toast.makeText(context, context.getString(msg), Toast.LENGTH_SHORT).show() + } + ) + } + androidx.compose.runtime.DisposableEffect(Unit) { + onDispose { voiceController.destroy() } + } + val voicePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) voiceController.start(inputText) + else Toast.makeText(context, context.getString(R.string.chat_voice_permission_denied), Toast.LENGTH_SHORT).show() + } + fun toggleVoice() { + if (!voiceController.available) { + Toast.makeText(context, context.getString(R.string.chat_voice_unavailable), Toast.LENGTH_SHORT).show() + return + } + if (voiceController.isListening) { + voiceController.stop() + } else if (androidx.core.content.ContextCompat.checkSelfPermission( + context, android.Manifest.permission.RECORD_AUDIO + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + voiceController.start(inputText) + } else { + voicePermissionLauncher.launch(android.Manifest.permission.RECORD_AUDIO) + } + } + // 流式结束过渡:streamingText 清空后保留最后文本一小段(落库消息通常在此窗口内接管), // 避免尾巴 item 高度骤减导致视口被 clamp 上移、露出历史消息(结束瞬间“闪回”看到用户消息)。 var tailStreamingText by remember { mutableStateOf<String?>(null) } @@ -825,6 +912,11 @@ fun AIChatPanel( onOpenDrawer() }, onNewChat = { viewModel.newSession() }, + onToggleSearch = { + searchOpen = !searchOpen + if (!searchOpen) viewModel.clearMessageSearch() + }, + searchActive = searchOpen, onNavigateToTerminal = onNavigateToTerminal, onNavigateToGit = onNavigateToGit, currentMode = currentMode, @@ -832,7 +924,9 @@ fun AIChatPanel( connectionState = connectionState?.takeIf { isRemote }, showMenuButton = showMenuButton, terminalActive = terminalActive, - gitActive = gitActive + gitActive = gitActive, + contextUsedTokens = lastContextUsedTokens, + contextMaxTokens = activeModelMetadata?.contextTokens ?: 0 ) } ) { padding -> @@ -851,7 +945,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 +1026,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 +1059,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 +1241,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 +1273,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, @@ -1144,6 +1289,8 @@ fun AIChatPanel( ) }, onTakePhoto = ::takePhoto, + isListening = voiceController.isListening, + onToggleVoice = ::toggleVoice, slashCommands = viewModel.slashCommands, queuedRequests = queuedRequests, onRemoveQueued = { viewModel.removeQueuedRequest(it) }, @@ -1169,14 +1316,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( @@ -1223,7 +1399,11 @@ fun AIChatPanel( clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("message", message.content))) } }, - onDeleteClick = { viewModel.deleteMessage(message.id) } + onDeleteClick = { viewModel.deleteMessage(message.id) }, + onForkClick = { + viewModel.forkSessionAt(message.id) + Toast.makeText(context, context.getString(R.string.chat_action_fork_done), Toast.LENGTH_SHORT).show() + } ) } 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..5082aa8c 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 @@ -88,6 +88,7 @@ import compose.icons.feathericons.Copy import compose.icons.feathericons.Download import compose.icons.feathericons.Edit2 import compose.icons.feathericons.FilePlus +import compose.icons.feathericons.FileText import compose.icons.feathericons.Folder import compose.icons.feathericons.FolderPlus import compose.icons.feathericons.RefreshCw @@ -114,11 +115,14 @@ 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, onTogglePin: (ChatSession) -> Unit, onExport: (ChatSession) -> Unit, + onExportMarkdown: (ChatSession) -> Unit = {}, subSessionsByParent: Map<String, List<ChatSession>> = emptyMap(), browseState: FileBrowseState, expandedPaths: Set<String>, @@ -186,6 +190,8 @@ fun ChatDrawerContent( currentSessionId = currentSessionId, agentStates = agentStates, awaitingPermissionSessionIds = awaitingPermissionSessionIds, + sessionTitleFilter = sessionTitleFilter, + onSessionTitleFilterChange = onSessionTitleFilterChange, subSessionsByParent = subSessionsByParent, listState = listState, onSelect = onSelect, @@ -273,6 +279,10 @@ fun ChatDrawerContent( menuSession = null onExport(session) }, + onExportMarkdown = { + menuSession = null + onExportMarkdown(session) + }, onDelete = { menuSession = null pendingDelete = session @@ -319,6 +329,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 +350,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 +464,7 @@ private fun SessionListTab( } } } + } } } @@ -929,6 +972,7 @@ private fun SessionActionSheet( onTogglePin: () -> Unit, onRename: () -> Unit, onExport: () -> Unit, + onExportMarkdown: () -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit ) { @@ -980,6 +1024,15 @@ private fun SessionActionSheet( onExport() } ) + SheetActionRow( + icon = FeatherIcons.FileText, + label = stringResource(R.string.chat_export_session_markdown), + tint = MaterialTheme.colorScheme.onSurface, + onClick = { + onDismiss() + onExportMarkdown() + } + ) SheetActionRow( icon = FeatherIcons.Trash2, label = stringResource(R.string.common_delete), 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..516ba02e 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 @@ -90,6 +98,7 @@ import compose.icons.feathericons.Check import compose.icons.feathericons.ChevronDown import compose.icons.feathericons.ChevronUp import compose.icons.feathericons.Copy +import compose.icons.feathericons.Mic import compose.icons.feathericons.Plus import compose.icons.feathericons.Square import kotlinx.coroutines.launch @@ -113,6 +122,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>, @@ -122,6 +134,9 @@ internal fun ChatInputBar( onUploadFile: () -> Unit, onUploadImage: () -> Unit, onTakePhoto: () -> Unit, + /** 语音听写进行中:麦克风按钮切换为停止态。 */ + isListening: Boolean = false, + onToggleVoice: () -> Unit = {}, slashCommands: List<SlashCommandHandler> = emptyList(), queuedRequests: List<QueuedRequest> = emptyList(), onRemoveQueued: (String) -> Unit = {}, @@ -139,6 +154,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 +279,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 +305,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,12 +340,16 @@ 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 + AgentMode.GOD -> MaterialTheme.colorScheme.secondaryContainer } val modeTextColor = when (currentMode) { AgentMode.PLAN -> MaterialTheme.colorScheme.onPrimaryContainer AgentMode.AUTO -> MaterialTheme.colorScheme.onError + AgentMode.TARGET -> MaterialTheme.colorScheme.onTertiaryContainer AgentMode.BUILD -> MaterialTheme.semanticColors.onSuccess + AgentMode.GOD -> MaterialTheme.colorScheme.onSecondaryContainer } Surface( shape = RoundedCornerShape(16.dp), @@ -325,7 +359,9 @@ 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.GOD + AgentMode.GOD -> AgentMode.BUILD } onToggleMode(nextMode) } @@ -388,6 +424,42 @@ internal fun ChatInputBar( contentDescription = stringResource(R.string.chat_add_attachment), onClick = { showAttachmentSheet = true } ) + IconButton( + enabled = !isBusy, + onClick = onToggleVoice + ) { + if (isListening) { + Icon( + imageVector = FeatherIcons.Square, + contentDescription = stringResource(R.string.chat_voice_listening), + tint = MaterialTheme.colorScheme.primary + ) + } else { + Icon( + imageVector = FeatherIcons.Mic, + contentDescription = stringResource(R.string.chat_voice_input), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + 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/ChatVoiceInputController.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatVoiceInputController.kt new file mode 100644 index 00000000..059dd719 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatVoiceInputController.kt @@ -0,0 +1,103 @@ +package com.aicode.feature.agent.presentation.component + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * 追加听写文本到基线:以空白分隔,保留基线内容,收缩首尾空白。 + * 纯函数,独立于 Android 运行时可测。 + */ +internal fun mergeDraft(base: String, recognized: String): String = buildString { + append(base.trimEnd()) + if (isNotEmpty() && recognized.isNotBlank()) append(' ') + append(recognized.trim()) +} + +/** + * 聊天输入框语音听写控制器:封装 SpeechRecognizer 生命周期与状态, + * 部分结果经 [onPartialPreview] 实时预览(仅 UI 展示),定稿经 [onFinal] 走草稿落盘链路。 + * + * @property available 设备是否支持语音识别(false 时点击应提示不支持)。 + */ +internal class ChatVoiceInputController( + context: Context, + private val onPartialPreview: (String) -> Unit, + private val onFinal: (String) -> Unit, + private val onError: (ChatVoiceError) -> Unit, +) { + enum class ChatVoiceError { NO_MATCH, FAILED } + + val available: Boolean = SpeechRecognizer.isRecognitionAvailable(context) + + var isListening by mutableStateOf(false) + private set + + /** 开始听写时输入框内容快照,识别结果追加在其后。 */ + private var baseText: String = "" + + private val recognizer: SpeechRecognizer? = + if (available) SpeechRecognizer.createSpeechRecognizer(context) else null + + private val listener = object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) {} + override fun onBeginningOfSpeech() {} + override fun onRmsChanged(rmsdB: Float) {} + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() {} + + override fun onError(error: Int) { + isListening = false + when (error) { + SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> + onError(ChatVoiceError.NO_MATCH) + else -> onError(ChatVoiceError.FAILED) + } + } + + override fun onResults(results: Bundle?) { + isListening = false + val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull().orEmpty() + onFinal(mergeDraft(baseText, text)) + } + + override fun onPartialResults(partialResults: Bundle?) { + val text = partialResults + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull().orEmpty() + if (text.isNotBlank()) onPartialPreview(mergeDraft(baseText, text)) + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + } + + fun start(base: String) { + val sr = recognizer ?: return + if (isListening) return + baseText = base + sr.setRecognitionListener(listener) + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + // 语言不设 EXTRA_LANGUAGE,跟随系统默认。 + } + isListening = true + sr.startListening(intent) + } + + fun stop() { + if (!isListening) return + recognizer?.stopListening() + } + + fun destroy() { + recognizer?.destroy() + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageActionsBottomSheet.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageActionsBottomSheet.kt index ed3c0632..6dc174e4 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageActionsBottomSheet.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageActionsBottomSheet.kt @@ -32,6 +32,7 @@ import com.aicode.feature.agent.presentation.hasVisibleContent import compose.icons.FeatherIcons import compose.icons.feathericons.Copy import compose.icons.feathericons.Edit2 +import compose.icons.feathericons.GitBranch import compose.icons.feathericons.Trash2 @OptIn(ExperimentalMaterial3Api::class) @@ -41,7 +42,8 @@ internal fun MessageActionsBottomSheet( onDismiss: () -> Unit, onEditClick: () -> Unit, onCopyClick: () -> Unit, - onDeleteClick: () -> Unit + onDeleteClick: () -> Unit, + onForkClick: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -78,6 +80,16 @@ internal fun MessageActionsBottomSheet( } ) + // 在此分叉新会话 + MessageActionItem( + icon = FeatherIcons.GitBranch, + title = stringResource(R.string.chat_action_fork), + onClick = { + onDismiss() + onForkClick() + } + ) + // 复制文本:纯图片消息没有文字可复制,隐藏该项 if (message.content.hasVisibleContent()) { MessageActionItem( 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/StatusBubbles.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/StatusBubbles.kt index 5caf0e5e..466c597c 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/StatusBubbles.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/StatusBubbles.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -303,6 +304,21 @@ internal fun rememberTypewriterStreamingText( shownHead = streamHeadFingerprint(snapshot) } + // 切页离开组合时,把「离开这一刻已到达的完整文本」记进 saveable,而不是只记节流快照长度。 + // 否则返回后 showChars 还停留在离开瞬间的碎片,把离开期间新到达的整段内容当着用户面逐字重打 + // (复现路径:AI 持续吐字时切去设置/终端,回来看到从头打字)。返回时 restored 恢复为离开时刻 + // 的完整前缀,仅对返回后真正新增的 delta 走帧循环。 + val latestTextForDispose by rememberUpdatedState(text) + DisposableEffect(sessionKey) { + onDispose { + // 仅当组合还在持续流式(active)时记录;文本已结束的直接补全,无需记忆。 + if (latestTextForDispose.isNotEmpty()) { + shownChars = latestTextForDispose.length + shownHead = streamHeadFingerprint(latestTextForDispose) + } + } + } + LaunchedEffect(text, active, sessionKey) { // 文本不是当前进度的延续(换会话 / 新一轮 / 重试):补全到当前全文,再跟着后续 delta 打字。 // 不能归零重打——切到另一个正在输出的会话时,它已产出的几百字会当着用户的面再来一遍。 @@ -454,8 +470,16 @@ internal fun ReasoningBubble( var timerStartMillis by rememberSaveable { mutableStateOf(0L) } var timerSeenChars by rememberSaveable { mutableStateOf(0) } var timerSeenHead by rememberSaveable { mutableStateOf(0) } - var elapsedSeconds by remember { mutableStateOf(0) } + // 切页返回 / 移出视口重挂载时,首帧就按「离开前保存的绝对起点」算出真实已计秒数, + // 避免每 1s 重置首帧显示 0s、再靠 ticker 碰一下纠正的闪烁。起点无效(首次出现)时为 0。 val latestText by rememberUpdatedState(text) + var elapsedSeconds by remember { + mutableStateOf( + if (showTimer && timerStartMillis > 0L) { + ((System.currentTimeMillis() - timerStartMillis) / 1000L).toInt() + } else 0 + ) + } LaunchedEffect(showTimer, sessionKey) { if (!showTimer) return@LaunchedEffect if (!isStreamContinuation(latestText, timerSeenChars, timerSeenHead)) { 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/backup/domain/SessionMarkdownExporter.kt b/app/src/main/java/com/aicode/feature/backup/domain/SessionMarkdownExporter.kt new file mode 100644 index 00000000..f71083ab --- /dev/null +++ b/app/src/main/java/com/aicode/feature/backup/domain/SessionMarkdownExporter.kt @@ -0,0 +1,89 @@ +package com.aicode.feature.backup.domain + +import com.aicode.feature.agent.data.local.entity.AgentMessageEntity +import com.aicode.feature.agent.presentation.BACKGROUND_NOTIFICATION_PREFIX +import com.aicode.feature.agent.presentation.MessageRole +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** 工具参数在 Markdown 中的截断上限,避免超长命令/输出撑爆文档。 */ +internal const val EXPORT_TOOL_ARGS_MAX_CHARS = 300 + +/** + * 单会话 Markdown 导出器:把会话消息渲染为人类可读的对话文档。 + * + * 纯函数,便于 JVM 单测;与 [com.aicode.feature.backup.data.BackupManagerImpl] 的 + * tar.gz 备份导出并列——本格式面向阅读与分享,备份格式面向导入恢复。 + * + * 排除项:已压缩消息、上下文摘要、压缩锚点、后台任务通知、AI 思考过程(reasoning)、 + * 正文为空的 AI 消息(纯工具调用轮次由工具记录说明)。 + */ +object SessionMarkdownExporter { + + private val timeFormat: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()) + + fun build( + title: String, + messages: List<AgentMessageEntity>, + exportedAt: Long = System.currentTimeMillis(), + ): String { + val visible = messages.filter { isExportable(it) } + return buildString { + appendLine("# $title") + appendLine() + append("> 导出自 AiCode · ${timeFormat.format(Instant.ofEpochMilli(exportedAt))}") + append(" · ${visible.size} 条消息") + val inputTokens = visible.sumOf { it.inputTokens } + val outputTokens = visible.sumOf { it.outputTokens } + if (inputTokens > 0 || outputTokens > 0) { + append(" · 输入 $inputTokens tokens · 输出 $outputTokens tokens") + } + appendLine() + visible.forEach { message -> + appendLine() + appendMessage(message) + } + } + } + + private fun isExportable(message: AgentMessageEntity): Boolean = when { + message.isCompacted || message.isContextSummary || message.isCompactionMarker -> false + message.role == MessageRole.USER.name && + message.content.startsWith(BACKGROUND_NOTIFICATION_PREFIX) -> false + else -> true + } + + private fun StringBuilder.appendMessage(message: AgentMessageEntity) { + when (MessageRole.valueOf(message.role)) { + MessageRole.USER -> { + appendLine("## 用户") + appendLine() + appendLine(message.content.trim()) + message.exportedAttachments().forEach { attachment -> + appendLine() + appendLine("[附件: ${attachment.fileName.ifBlank { "file" }}]") + } + } + + MessageRole.ASSISTANT -> { + if (message.content.isBlank()) return + appendLine("## AI") + appendLine() + appendLine(message.content.trim()) + } + + MessageRole.TOOL -> appendTool(message) + } + } + + private fun StringBuilder.appendTool(message: AgentMessageEntity) { + appendLine("> 工具 `${message.toolName ?: "unknown"}`${if (message.isError) " · 失败" else ""}") + val args = message.toolArgs?.replace(Regex("\\s+"), " ")?.trim().orEmpty() + if (args.isNotEmpty()) { + appendLine(">") + appendLine("> ${args.take(EXPORT_TOOL_ARGS_MAX_CHARS)}") + } + } +} diff --git a/app/src/main/java/com/aicode/feature/git/domain/GitRepository.kt b/app/src/main/java/com/aicode/feature/git/domain/GitRepository.kt index 699030a3..e2ae91e2 100644 --- a/app/src/main/java/com/aicode/feature/git/domain/GitRepository.kt +++ b/app/src/main/java/com/aicode/feature/git/domain/GitRepository.kt @@ -405,6 +405,38 @@ class GitRepository @Inject constructor( /** 本地标签列表,按创建时间倒序(最新在前)。 */ suspend fun listTags(): List<GitTag> = loadAllRefs().tags + // ── 分支合并 / 变基 ── + + /** 把 [branch] 合并进当前分支(fast-forward 时自动快进)。`core.editor=true` 让冲突解决后的合并提交不弹编辑器直接沿用默认消息。 */ + suspend fun merge(branch: String): String = + gitChecked("-c", "core.editor=true", "merge", branch) + + /** 把当前分支变基到 [branch]。rebase 递归或 conflict 时 non-zero 退出码由 [gitChecked] 抛异常。 */ + suspend fun rebase(branch: String): String = + gitChecked("-c", "core.editor=true", "rebase", branch) + + suspend fun abortMerge(): String = gitChecked("merge", "--abort") + + suspend fun abortRebase(): String = gitChecked("rebase", "--abort") + + /** 冲突解决并 stage 后继续变基。同样以 `true` 作为 editor,避免打开交互编辑器卡住。 */ + suspend fun continueRebase(): String = + gitChecked("-c", "core.editor=true", "rebase", "--continue") + + /** 是否处于合并中(工作区存在 MERGE_HEAD)。 */ + suspend fun isMerging(): Boolean = + git("rev-parse", "-q", "--verify", "MERGE_HEAD").isNotBlank() + + /** 是否处于变基中(工作区存在 REBASE_HEAD,interactive 目录 rebase-merge 同样暴露该引用)。 */ + suspend fun isRebasing(): Boolean = + git("rev-parse", "-q", "--verify", "REBASE_HEAD").isNotBlank() + + /** 当前合并/变基冲突的未解决文件路径列表(porcelain v1 两列都非空白的冲突状态码,如 UU/AA/DD/UD/AU)。 */ + suspend fun mergeConflicts(): List<String> { + val raw = git("status", "--porcelain=v1") + return mergeConflictsFromPorcelain(raw) + } + /** * 创建新分支。name 为新分支名;startPoint 为基准分支名(null/空 → 从当前 HEAD); * checkout=true 则创建并切换(`git checkout -b`),否则仅创建不切换(`git branch`)。 @@ -626,3 +658,23 @@ internal fun unquoteGitPath(raw: String): String { } return sb.toString() } + +/** + * 从 `git status --porcelain=v1` 输出解析未解决的冲突文件路径(纯函数,可单测)。 + * 冲突状态码两列都为字母(非空格/问号):UU / AA / DD / AU / UA / UD / DU。 + */ +internal fun mergeConflictsFromPorcelain(porcelain: String): List<String> { + val conflicts = mutableListOf<String>() + porcelain.split('\n').forEach { line -> + val trimmed = line.removeSuffix("\r") + if (trimmed.length < 3) return@forEach + val x = trimmed[0] + val y = trimmed[1] + val isUnmerged = x in "UAD" && y in "UAD" + if (isUnmerged) { + val path = trimmed.substring(3).substringAfter(" -> ").trim().trim('"') + if (path.isNotEmpty()) conflicts.add(path) + } + } + return conflicts +} diff --git a/app/src/main/java/com/aicode/feature/git/presentation/GitViewModel.kt b/app/src/main/java/com/aicode/feature/git/presentation/GitViewModel.kt index 11f0a5dd..3c7b1357 100644 --- a/app/src/main/java/com/aicode/feature/git/presentation/GitViewModel.kt +++ b/app/src/main/java/com/aicode/feature/git/presentation/GitViewModel.kt @@ -104,7 +104,13 @@ class GitViewModel @Inject constructor( /** 正在查看 diff 的文件路径;加载中(diffData 为 null)时供顶栏显示文件名。 */ val diffPath: String? = null, /** diff 视图数据;非 null 时 diff 页渲染内容。 */ - val diffData: DiffData? = null + val diffData: DiffData? = null, + /** 是否处于合并中(存在 MERGE_HEAD),Git 页顶部显示冲突横幅。 */ + val isMerging: Boolean = false, + /** 是否处于变基中(存在 REBASE_HEAD)。 */ + val isRebasing: Boolean = false, + /** 合并/变基未解决的冲突文件路径列表。 */ + val conflicts: List<String> = emptyList() ) private val _state = MutableStateFlow(GitUiState()) @@ -120,18 +126,29 @@ class GitViewModel @Inject constructor( val graph: GitGraph, val hasRemote: Boolean, val hasIdentity: Boolean = false, - val untrackedDirFiles: Map<String, List<String>> = emptyMap() + val untrackedDirFiles: Map<String, List<String>> = emptyMap(), + val isMerging: Boolean = false, + val isRebasing: Boolean = false, + val conflicts: List<String> = emptyList() ) - /** 并发拉取轻量快照:status + graph(本地 refs) + remote + 可选 identity。 */ + /** 并发拉取轻量快照:status + graph(本地 refs) + remote + 可选 identity + 合并状态。 */ private suspend fun loadSnapshot(includeIdentity: Boolean): RepoSnapshot = coroutineScope { val s = async { repository.status() } val localRefs = async { repository.localRefsOnly() } val r = async { repository.hasRemote() } val id = async { if (includeIdentity) repository.getUserName().isNotBlank() else false } val g = async { repository.graph(refs = localRefs.await()) } + val merging = async { repository.isMerging() } + val rebasing = async { repository.isRebasing() } val status = s.await() - RepoSnapshot(status, g.await(), r.await(), id.await(), reloadExpandedUntrackedDirs(status)) + // 冲突文件只在处于合并/变基中才值得解析;否则额外跑一次 status 纯属浪费。 + val conflicts = if (merging.await() || rebasing.await()) repository.mergeConflicts() else emptyList() + RepoSnapshot( + status, g.await(), r.await(), id.await(), + reloadExpandedUntrackedDirs(status), + merging.await(), rebasing.await(), conflicts + ) } /** @@ -213,7 +230,7 @@ class GitViewModel @Inject constructor( val snap = loadSnapshot(includeIdentity = true) val commits = snap.graph.commits.map { GitCommit(it.hash, it.shortHash, it.author, it.date, it.message) } _state.update { - it.copy(loading = false, notARepo = false, status = snap.status, commits = commits, graph = snap.graph, hasRemote = snap.hasRemote, hasIdentity = snap.hasIdentity, untrackedDirFiles = snap.untrackedDirFiles, branchesLoaded = false, branchesLoading = false, branches = emptyList(), tags = emptyList()) + it.copy(loading = false, notARepo = false, status = snap.status, commits = commits, graph = snap.graph, hasRemote = snap.hasRemote, hasIdentity = snap.hasIdentity, untrackedDirFiles = snap.untrackedDirFiles, isMerging = snap.isMerging, isRebasing = snap.isRebasing, conflicts = snap.conflicts, branchesLoaded = false, branchesLoading = false, branches = emptyList(), tags = emptyList()) } // 页面已打开:后台拉取全量分支/标签,用户切到 BRANCHES tab 时无需再等。 loadBranches() @@ -248,7 +265,7 @@ class GitViewModel @Inject constructor( if (repository.isRepo()) { val snap = loadSnapshot(includeIdentity = false) val commits = snap.graph.commits.map { GitCommit(it.hash, it.shortHash, it.author, it.date, it.message) } - _state.update { it.copy(busy = false, status = snap.status, commits = commits, graph = snap.graph, hasRemote = snap.hasRemote, untrackedDirFiles = snap.untrackedDirFiles, notARepo = false, toast = msg) } + _state.update { it.copy(busy = false, status = snap.status, commits = commits, graph = snap.graph, hasRemote = snap.hasRemote, untrackedDirFiles = snap.untrackedDirFiles, isMerging = snap.isMerging, isRebasing = snap.isRebasing, conflicts = snap.conflicts, notARepo = false, toast = msg) } refreshBranchesIfLoaded() } else { _state.update { it.copy(busy = false, notARepo = true, toast = msg) } @@ -323,6 +340,22 @@ class GitViewModel @Inject constructor( runAction(R.string.git_push, { repository.push() }) } + /** 把 [branch] 合并进当前分支;冲突时刷新后 isMerging=true 并展示冲突横幅。 */ + fun merge(branch: String) = runAction(R.string.git_action_merge, { repository.merge(branch) }) + + /** 当前分支变基到 [branch];冲突时刷新后 isRebasing=true 并展示冲突横幅。 */ + fun rebase(branch: String) = runAction(R.string.git_action_rebase, { repository.rebase(branch) }) + + /** 中止当前合并或变基(据状态自动选择),回到操作前状态。 */ + fun abortOperation() { + val state = _state.value + val action = if (state.isRebasing) repository::abortRebase else repository::abortMerge + runAction(R.string.git_action_abort, { action() }) + } + + /** 冲突解决并 stage 后继续变基。 */ + fun continueRebase() = runAction(R.string.git_action_continue_rebase, { repository.continueRebase() }) + /** * 打开某条提交的详情弹层。若文件清单尚未加载则懒加载(不置 [GitUiState.busy], * 因为这是只读查看,不应阻塞 status/branches 的写操作)。 diff --git a/app/src/main/java/com/aicode/feature/git/presentation/component/GitBranchesTab.kt b/app/src/main/java/com/aicode/feature/git/presentation/component/GitBranchesTab.kt index ee4042ec..f0fa70ba 100644 --- a/app/src/main/java/com/aicode/feature/git/presentation/component/GitBranchesTab.kt +++ b/app/src/main/java/com/aicode/feature/git/presentation/component/GitBranchesTab.kt @@ -71,6 +71,7 @@ import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.Cloud import compose.icons.feathericons.Edit2 import compose.icons.feathericons.GitBranch +import compose.icons.feathericons.GitMerge import compose.icons.feathericons.GitCommit import compose.icons.feathericons.Plus import compose.icons.feathericons.Tag @@ -92,7 +93,9 @@ internal fun BranchesTab( onDeleteRemoteBranch: (String) -> Unit, onRenameBranch: (String, String) -> Unit, onCreateTag: (String) -> Unit, - onDeleteTag: (String) -> Unit + onDeleteTag: (String) -> Unit, + /** 合并指定分支进当前分支:参数 (分支名, 是否变基)。 */ + onMergeBranch: (String, Boolean) -> Unit ) { LaunchedEffect(Unit) { if (!branchesLoaded && !branchesLoading) { @@ -134,6 +137,7 @@ internal fun BranchesTab( var pendingRename by remember { mutableStateOf<String?>(null) } var showCreateTagDialog by remember { mutableStateOf(false) } var pendingDeleteTag by remember { mutableStateOf<String?>(null) } + var pendingMerge by remember { mutableStateOf<String?>(null) } pendingCheckout?.let { (ref, isRemote) -> val isTag = tags.any { it.name == ref } @@ -262,6 +266,29 @@ internal fun BranchesTab( ) } + pendingMerge?.let { branch -> + AlertDialog( + onDismissRequest = { pendingMerge = null }, + title = { Text(stringResource(R.string.git_action_merge)) }, + text = { Text(stringResource(R.string.git_merge_confirm_message, branch, currentBranch)) }, + confirmButton = { + TextButton(onClick = { + pendingMerge = null + onMergeBranch(branch, false) + }) { Text(stringResource(R.string.git_action_merge)) } + }, + dismissButton = { + Row { + TextButton(onClick = { + pendingMerge = null + onMergeBranch(branch, true) + }) { Text(stringResource(R.string.git_action_rebase)) } + TextButton(onClick = { pendingMerge = null }) { Text(stringResource(R.string.common_cancel)) } + } + } + ) + } + pendingRename?.let { oldName -> var newName by remember(oldName) { mutableStateOf(oldName) } AlertDialog( @@ -419,7 +446,8 @@ internal fun BranchesTab( onCheckout = { ref, remote -> pendingCheckout = ref to remote }, onRenameBranch = { pendingRename = it }, onDeleteBranch = { pendingDelete = it to false }, - onDeleteRemoteBranch = {} + onDeleteRemoteBranch = {}, + onMergeBranch = { branch, rebase -> pendingMerge = branch } ) } } @@ -445,7 +473,8 @@ internal fun BranchesTab( onCheckout = { ref, remote -> pendingCheckout = ref to remote }, onRenameBranch = {}, onDeleteBranch = {}, - onDeleteRemoteBranch = { pendingDelete = it to true } + onDeleteRemoteBranch = { pendingDelete = it to true }, + onMergeBranch = { _, _ -> } ) } } @@ -632,6 +661,7 @@ private sealed class RefAction( ) { class Switch(onClick: () -> Unit) : RefAction(R.string.common_switch, FeatherIcons.GitCommit, false, onClick) class Rename(onClick: () -> Unit) : RefAction(R.string.common_rename, FeatherIcons.Edit2, false, onClick) + class Merge(onClick: () -> Unit) : RefAction(R.string.git_action_merge, FeatherIcons.GitMerge, false, onClick) class Delete(onClick: () -> Unit) : RefAction(R.string.common_delete, FeatherIcons.Trash2, true, onClick) } @@ -826,7 +856,8 @@ private fun ColumnScope.renderBranchTree( onCheckout: (String, Boolean) -> Unit, onRenameBranch: (String) -> Unit, onDeleteBranch: (String) -> Unit, - onDeleteRemoteBranch: (String) -> Unit + onDeleteRemoteBranch: (String) -> Unit, + onMergeBranch: (String, Boolean) -> Unit ) { nodes.forEachIndexed { index, node -> val isFolder = node.children.isNotEmpty() @@ -851,6 +882,7 @@ private fun ColumnScope.renderBranchTree( buildList { if (!b.current) add(RefAction.Switch(onClick = { onCheckout(b.name, false) })) add(RefAction.Rename(onClick = { onRenameBranch(b.name) })) + if (!b.current) add(RefAction.Merge(onClick = { onMergeBranch(b.name, false) })) if (!b.current) add(RefAction.Delete(onClick = { onDeleteBranch(b.name) })) } } @@ -866,7 +898,7 @@ private fun ColumnScope.renderBranchTree( } } if (isFolder && isOpen) { - renderBranchTree(node.children, depth + 1, expanded, isRemote, checkoutLoading, onCheckout, onRenameBranch, onDeleteBranch, onDeleteRemoteBranch) + renderBranchTree(node.children, depth + 1, expanded, isRemote, checkoutLoading, onCheckout, onRenameBranch, onDeleteBranch, onDeleteRemoteBranch, onMergeBranch) } } } diff --git a/app/src/main/java/com/aicode/feature/git/presentation/component/GitScreen.kt b/app/src/main/java/com/aicode/feature/git/presentation/component/GitScreen.kt index 1b5d7601..b51e028c 100644 --- a/app/src/main/java/com/aicode/feature/git/presentation/component/GitScreen.kt +++ b/app/src/main/java/com/aicode/feature/git/presentation/component/GitScreen.kt @@ -49,6 +49,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.background import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -68,6 +70,7 @@ import com.aicode.feature.git.domain.model.GitTab import com.aicode.feature.git.presentation.GitViewModel import compose.icons.FeatherIcons import compose.icons.feathericons.Activity +import compose.icons.feathericons.AlertTriangle import compose.icons.feathericons.ArrowLeft import compose.icons.feathericons.GitBranch import compose.icons.feathericons.GitCommit @@ -159,6 +162,14 @@ fun GitScreen( ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { Column(modifier = Modifier.fillMaxSize()) { + if ((state.isMerging || state.isRebasing) && !state.loading && !state.notARepo) { + MergeConflictBanner( + isRebasing = state.isRebasing, + conflicts = state.conflicts, + onAbort = viewModel::abortOperation, + onContinue = viewModel::continueRebase + ) + } when { state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() @@ -214,7 +225,10 @@ fun GitScreen( onDeleteRemoteBranch = viewModel::deleteRemoteBranch, onRenameBranch = viewModel::renameBranch, onCreateTag = viewModel::createTag, - onDeleteTag = viewModel::deleteTag + onDeleteTag = viewModel::deleteTag, + onMergeBranch = { branch, rebase -> + if (rebase) viewModel.rebase(branch) else viewModel.merge(branch) + } ) GitTab.LOG -> LogTab( graph = state.graph, @@ -418,3 +432,68 @@ private fun CommitDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } } ) } + +/** 合并/变基冲突横幅:跨 tab 置顶展示冲突状态,提供中止与继续操作。 */ +@Composable +private fun MergeConflictBanner( + isRebasing: Boolean, + conflicts: List<String>, + onAbort: () -> Unit, + onContinue: () -> Unit, +) { + val bg = MaterialTheme.colorScheme.errorContainer + val fg = MaterialTheme.colorScheme.onErrorContainer + Column( + modifier = Modifier + .fillMaxWidth() + .background(bg) + .padding(horizontal = Spacing.md, vertical = Spacing.sm) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = FeatherIcons.AlertTriangle, + contentDescription = null, + tint = fg, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(Spacing.sm)) + Text( + text = stringResource(if (isRebasing) R.string.git_rebase_conflict_banner_title else R.string.git_merge_conflict_banner_title), + style = MaterialTheme.typography.titleSmall, + color = fg + ) + } + if (conflicts.isEmpty()) { + Text( + text = stringResource(R.string.git_conflict_hint_terminal), + style = MaterialTheme.typography.bodySmall, + color = fg, + modifier = Modifier.padding(top = Spacing.xs) + ) + } else { + Text( + text = stringResource(R.string.git_conflict_files, conflicts.size), + style = MaterialTheme.typography.bodySmall, + color = fg, + modifier = Modifier.padding(top = Spacing.xs) + ) + conflicts.take(3).forEach { path -> + Text( + text = "• $path", + style = MaterialTheme.typography.bodySmall, + color = fg, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = Spacing.sm) + ) + } + } + Row(modifier = Modifier.padding(top = Spacing.xs)) { + FilledTonalButton(onClick = onAbort) { Text(stringResource(R.string.git_action_abort)) } + if (isRebasing) { + Spacer(Modifier.width(Spacing.sm)) + FilledTonalButton(onClick = onContinue) { Text(stringResource(R.string.git_action_continue_rebase)) } + } + } + } +} 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/java/com/aicode/feature/terminal/presentation/TerminalViewModel.kt b/app/src/main/java/com/aicode/feature/terminal/presentation/TerminalViewModel.kt index 95b98a53..809f5e64 100644 --- a/app/src/main/java/com/aicode/feature/terminal/presentation/TerminalViewModel.kt +++ b/app/src/main/java/com/aicode/feature/terminal/presentation/TerminalViewModel.kt @@ -45,6 +45,9 @@ class TerminalViewModel @Inject constructor( private fun isRemote() = modeHolder.currentMode() == ExecutionMode.REMOTE_SSH + /** 当前是否为远程 SSH 后端(本地容器服务的端口预览仅在本地模式可用)。 */ + fun isRemoteMode(): Boolean = isRemote() + /** 容器准备阶段的整体状态:仅用于首个标签创建前的 Loading/Error 提示。 */ sealed interface PrepareState { data object Loading : PrepareState diff --git a/app/src/main/java/com/aicode/feature/terminal/presentation/component/ContainerPreviewDialog.kt b/app/src/main/java/com/aicode/feature/terminal/presentation/component/ContainerPreviewDialog.kt new file mode 100644 index 00000000..0c4a583a --- /dev/null +++ b/app/src/main/java/com/aicode/feature/terminal/presentation/component/ContainerPreviewDialog.kt @@ -0,0 +1,194 @@ +package com.aicode.feature.terminal.presentation.component + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +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.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.aicode.R +import com.aicode.core.ui.AppTextField +import com.aicode.core.ui.rememberImeBottomInset +import compose.icons.FeatherIcons +import compose.icons.feathericons.ArrowLeft +import compose.icons.feathericons.ExternalLink +import compose.icons.feathericons.RefreshCw + +private const val DEFAULT_PREVIEW_PORT = 3000 +private val PORT_RANGE = 1..65535 + +/** + * 容器内 Web 服务预览:输入端口 → 内嵌 WebView 打开 `http://127.0.0.1:<port>`。 + * + * PRoot 容器与宿主共享网络命名空间,容器内绑定 127.0.0.1 / 0.0.0.0 端口的服务 + * 在 App 内直接以 localhost 访问,无需真实端口转发。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContainerPreviewDialog( + onDismiss: () -> Unit, +) { + var portInput by remember { mutableStateOf(DEFAULT_PREVIEW_PORT.toString()) } + var browsingPort by remember { mutableIntStateOf(0) } + + if (browsingPort == 0) { + // 输入态 + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.terminal_preview_dialog_title)) }, + text = { + Column { + Text( + stringResource(R.string.terminal_preview_dialog_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + AppTextField( + value = portInput, + onValueChange = { portInput = it.filter { c -> c.isDigit() }.take(5) }, + label = stringResource(R.string.terminal_preview_port_hint), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + enabled = portInput.toIntOrNull()?.let { it in PORT_RANGE } == true, + onClick = { + val port = portInput.toIntOrNull() ?: return@TextButton + browsingPort = port + } + ) { Text(stringResource(R.string.terminal_preview_open)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } + } + ) + } else { + PreviewWebView( + port = browsingPort, + onClose = onDismiss, + ) + } +} + +/** 全屏 WebView 预览页:顶栏(关闭 / 地址)/ 页面本身。 */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PreviewWebView( + port: Int, + onClose: () -> Unit, +) { + val context = LocalContext.current + var reloadKey by remember { mutableIntStateOf(0) } + val url = "http://127.0.0.1:$port" + + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { + Text( + text = url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ), + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + FeatherIcons.ArrowLeft, + contentDescription = stringResource(R.string.common_close) + ) + } + }, + actions = { + IconButton(onClick = { reloadKey += 1 }) { + Icon( + FeatherIcons.RefreshCw, + contentDescription = stringResource(R.string.terminal_preview_reload) + ) + } + IconButton(onClick = { + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url) + ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(intent) } + }) { + Icon( + FeatherIcons.ExternalLink, + contentDescription = stringResource(R.string.terminal_preview_external) + ) + } + } + ) + AndroidView( + modifier = Modifier.fillMaxWidth().weight(1f).imePadding(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + webViewClient = WebViewClient() + webChromeClient = WebChromeClient() + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.setSupportZoom(true) + } + }, + update = { webView -> + webView.stopLoading() + webView.loadUrl(url) + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt b/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt index 5d52feb8..7bd65d4c 100644 --- a/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt +++ b/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt @@ -2,6 +2,7 @@ package com.aicode.feature.terminal.presentation.component import android.content.Context import android.graphics.Typeface +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -78,6 +79,7 @@ import com.termux.terminal.TextStyle import com.termux.view.TerminalView import compose.icons.FeatherIcons import compose.icons.feathericons.ArrowLeft +import compose.icons.feathericons.Monitor import compose.icons.feathericons.Plus import compose.icons.feathericons.Settings import compose.icons.feathericons.X @@ -100,6 +102,7 @@ fun TerminalScreen( val revision by viewModel.revision.collectAsStateWithLifecycle() val terminalSettings by viewModel.terminalSettings.collectAsStateWithLifecycle() var showSettingsSheet by remember { mutableStateOf(false) } + var showPreviewDialog by remember { mutableStateOf(false) } Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -122,6 +125,19 @@ fun TerminalScreen( } }, actions = { + IconButton(onClick = { + if (viewModel.isRemoteMode()) { + Toast.makeText( + context, + context.getString(R.string.terminal_preview_remote_unavailable), + Toast.LENGTH_SHORT + ).show() + } else { + showPreviewDialog = true + } + }) { + Icon(FeatherIcons.Monitor, contentDescription = stringResource(R.string.terminal_preview)) + } IconButton(onClick = { showSettingsSheet = true }) { Icon(FeatherIcons.Settings, contentDescription = stringResource(R.string.terminal_settings_title)) } @@ -196,6 +212,12 @@ fun TerminalScreen( onChangeFontPath = { viewModel.setFontPath(it) } ) } + + if (showPreviewDialog) { + ContainerPreviewDialog( + onDismiss = { showPreviewDialog = false } + ) + } } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index ca60c61e..39086e58 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> @@ -443,6 +474,7 @@ <string name="chat_rename_session">Rename Session</string> <string name="chat_session_name">Session name</string> <string name="chat_export_session">Export Session</string> + <string name="chat_export_session_markdown">Export Markdown</string> <string name="chat_export_session_done">Session exported</string> <string name="chat_export_session_failed">Export failed</string> @@ -734,6 +766,17 @@ <string name="git_pull_dirty_confirm">The working tree has uncommitted changes. Pulling may cause conflicts. Commit or stage them first if possible. Continue pulling?</string> <string name="git_pull_continue">Continue Pull</string> + <!-- Git branch merge / rebase --> + <string name="git_action_merge">Merge</string> + <string name="git_action_rebase">Rebase</string> + <string name="git_action_abort">Abort</string> + <string name="git_action_continue_rebase">Continue Rebase</string> + <string name="git_merge_confirm_message">Merge %1$s into current branch %2$s? Merge or rebase.</string> + <string name="git_merge_conflict_banner_title">Merge conflict</string> + <string name="git_rebase_conflict_banner_title">Rebase conflict</string> + <string name="git_conflict_files">%1$d conflicted files</string> + <string name="git_conflict_hint_terminal">Resolve conflicts manually in the workspace terminal, then tap "Continue" to finish</string> + <!-- Git Toast Messages --> <string name="git_toast_load_branches_failed">Failed to load branches: %1$s</string> <string name="git_toast_refresh_failed">Refresh failed: %1$s</string> @@ -988,6 +1031,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> @@ -1134,6 +1180,16 @@ <string name="terminal_preparing_env_failed">Preparing container environment…\n%1$s</string> <string name="terminal_preparing_env_first_run">Preparing container environment…\nFirst run will extract the container, please wait</string> + <!-- Terminal Web Preview --> + <string name="terminal_preview">Preview</string> + <string name="terminal_preview_remote_unavailable">Preview is only available in local container mode</string> + <string name="terminal_preview_dialog_title">Open container web service</string> + <string name="terminal_preview_dialog_hint">Enter the port of a running service in the container (e.g. python -m http.server 3000). Preview opens http://127.0.0.1:port</string> + <string name="terminal_preview_port_hint">Port</string> + <string name="terminal_preview_open">Open</string> + <string name="terminal_preview_reload">Reload</string> + <string name="terminal_preview_external">Open in browser</string> + <!-- Terminal Settings & Themes --> <string name="terminal_settings_title">Terminal Settings</string> <string name="terminal_theme_title">Color Theme</string> @@ -1215,6 +1271,8 @@ <string name="chat_more_options">More Options</string> <string name="chat_action_edit">Edit Message</string> <string name="chat_action_copy">Copy Text</string> + <string name="chat_action_fork">Fork Session Here</string> + <string name="chat_action_fork_done">Forked session here</string> <string name="chat_action_delete">Delete Message</string> <string name="chat_edit_dialog_title">Edit Message</string> <string name="chat_edit_dialog_hint">Enter message content</string> @@ -1350,4 +1408,26 @@ <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> + + <!-- Received external share --> + <string name="share_text_truncated">Shared content too long, kept first %1$d characters</string> + <string name="share_image_load_failed">Some images failed to load, only valid content injected</string> + <string name="share_empty_content">No valid content received</string> + + <!-- Voice dictation --> + <string name="chat_voice_input">Voice input</string> + <string name="chat_voice_listening">Listening, tap to stop</string> + <string name="chat_voice_permission_denied">Microphone permission is required for voice input</string> + <string name="chat_voice_unavailable">Speech recognition is unavailable on this device</string> + <string name="chat_voice_no_match">Nothing heard, please try again</string> + <string name="chat_voice_error">Speech recognition failed</string> +</resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17838634..cf351623 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> @@ -493,6 +524,7 @@ <string name="chat_rename_session">重命名会话</string> <string name="chat_session_name">会话名</string> <string name="chat_export_session">导出会话</string> + <string name="chat_export_session_markdown">导出 Markdown</string> <string name="chat_export_session_done">会话已导出</string> <string name="chat_export_session_failed">导出失败</string> @@ -734,6 +766,17 @@ <string name="git_pull_dirty_confirm">当前工作区有未提交改动,拉取可能导致冲突。建议先提交或暂存。仍要继续拉取吗?</string> <string name="git_pull_continue">继续拉取</string> + <!-- Git 分支合并 / 变基 --> + <string name="git_action_merge">合并</string> + <string name="git_action_rebase">变基</string> + <string name="git_action_abort">中止</string> + <string name="git_action_continue_rebase">继续变基</string> + <string name="git_merge_confirm_message">把 %1$s 合并进当前分支 %2$s?可合并或变基。</string> + <string name="git_merge_conflict_banner_title">合并冲突</string> + <string name="git_rebase_conflict_banner_title">变基冲突</string> + <string name="git_conflict_files">冲突文件 %1$d 个</string> + <string name="git_conflict_hint_terminal">在工作区终端手动解决冲突,解决后点击「继续」完成操作</string> + <!-- Git Toast Messages --> <string name="git_toast_load_branches_failed">加载分支失败: %1$s</string> <string name="git_toast_refresh_failed">刷新失败: %1$s</string> @@ -988,6 +1031,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> @@ -1133,6 +1179,16 @@ <string name="terminal_preparing_env_failed">正在准备容器环境…\n%1$s</string> <string name="terminal_preparing_env_first_run">正在准备容器环境…\n首次运行会解压容器,请稍候</string> + <!-- Terminal Web Preview --> + <string name="terminal_preview">预览</string> + <string name="terminal_preview_remote_unavailable">预览仅适用于本地容器模式</string> + <string name="terminal_preview_dialog_title">打开容器内 Web 服务</string> + <string name="terminal_preview_dialog_hint">输入容器内已启动服务的端口(如 python -m http.server 3000),预览将打开 http://127.0.0.1:端口</string> + <string name="terminal_preview_port_hint">端口</string> + <string name="terminal_preview_open">打开</string> + <string name="terminal_preview_reload">重载</string> + <string name="terminal_preview_external">在浏览器中打开</string> + <!-- Terminal Settings & Themes --> <string name="terminal_settings_title">终端设置</string> <string name="terminal_theme_title">配色主题</string> @@ -1214,6 +1270,8 @@ <string name="chat_more_options">更多选项</string> <string name="chat_action_edit">编辑消息</string> <string name="chat_action_copy">复制文本</string> + <string name="chat_action_fork">在此分叉新会话</string> + <string name="chat_action_fork_done">已在此处分叉新会话</string> <string name="chat_action_delete">删除消息</string> <string name="chat_edit_dialog_title">编辑消息</string> <string name="chat_edit_dialog_hint">请输入消息内容</string> @@ -1349,4 +1407,26 @@ <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> + + <!-- 接收外部分享 --> + <string name="share_text_truncated">分享内容过长,已截取前 %1$d 字符</string> + <string name="share_image_load_failed">部分图片加载失败,仅注入有效内容</string> + <string name="share_empty_content">未接收到有效内容</string> + + <!-- 语音听写 --> + <string name="chat_voice_input">语音输入</string> + <string name="chat_voice_listening">正在听写,点击停止</string> + <string name="chat_voice_permission_denied">需要麦克风权限才能语音输入</string> + <string name="chat_voice_unavailable">当前设备不支持语音识别</string> + <string name="chat_voice_no_match">未听清,请再试一次</string> + <string name="chat_voice_error">语音识别失败</string> </resources> diff --git a/app/src/test/java/com/aicode/feature/agent/domain/orchestration/OrchestratorServiceTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/orchestration/OrchestratorServiceTest.kt new file mode 100644 index 00000000..4204c207 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/orchestration/OrchestratorServiceTest.kt @@ -0,0 +1,290 @@ +package com.aicode.feature.agent.domain.orchestration + +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.orchestration.OrchestratorService.DeploySpec +import com.aicode.feature.agent.domain.session.SessionUseCase +import com.aicode.feature.agent.domain.subagent.AgentDefinition +import com.aicode.feature.agent.domain.subagent.AgentDefinitionEntry +import com.aicode.feature.agent.domain.subagent.AgentDefinitionRepository +import com.aicode.feature.agent.domain.subagent.AgentDefinitionScope +import com.aicode.feature.agent.domain.subagent.SubAgentEvent +import com.aicode.feature.agent.domain.subagent.SubAgentEventBus +import com.aicode.feature.agent.domain.subagent.SubAgentEventType +import com.aicode.feature.settings.domain.model.AIProviderConfig +import com.aicode.feature.settings.domain.model.ProviderType +import com.aicode.feature.settings.domain.repository.AIProviderRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OrchestratorServiceTest { + + private val chatSessionDao = mockk<ChatSessionDao>(relaxed = true) + private val sessionUseCase = mockk<SessionUseCase>(relaxed = true) + private val agentRepo = mockk<AgentDefinitionRepository>(relaxed = true) + private val providerRepo = mockk<AIProviderRepository>(relaxed = true) + private val eventBus = mockk<SubAgentEventBus>(relaxed = true) + + private val dummyProvider = AIProviderConfig( + id = "prov", + name = "default", + type = ProviderType.OPENAI, + apiKey = "k", + baseUrl = "https://example.invalid", + defaultModel = "m", + selectedModel = "m" + ) + + private fun context(mode: AgentMode = AgentMode.GOD) = AgentContext( + currentFile = null, + selectedCode = null, + projectRoot = "/workspace", + language = null, + sessionId = "root", + mode = mode + ) + + private fun parentEntity() = ChatSessionEntity( + id = "root", + title = "root", + createdAt = 0L, + updatedAt = 0L, + mode = AgentMode.GOD.name + ) + + private val activeIds: MutableStateFlow<Set<String>> = MutableStateFlow(emptySet()) + + private fun buildService(): OrchestratorService { + every { eventBus.isFull } returns false + every { eventBus.activeSubSessionIds } returns activeIds + return OrchestratorService( + sessionUseCase = sessionUseCase, + chatSessionDao = chatSessionDao, + eventBus = eventBus, + agentDefinitionRepository = agentRepo, + aiProviderRepository = providerRepo + ) + } + + private fun task(name: String, prompt: String, agent: String? = null) = buildJsonObject { + put("name", name) + if (agent != null) put("agent", agent) + put("prompt", prompt) + } + + private fun stubSubSessionCreation() { + coEvery { + sessionUseCase.newSubSessionEntity(any(), any(), any(), any(), any(), any(), any()) + } answers { call -> + val title = call.invocation.args[0] as String + val parentId = call.invocation.args[1] as String + val parent = call.invocation.args[2] as ChatSessionEntity + val subType = call.invocation.args[3] as String + ChatSessionEntity( + id = "sub-$title", + title = title, + createdAt = 0L, + updatedAt = 0L, + parentId = parentId, + subagentType = subType, + mode = parent.mode + ) + } + } + + private fun stubCommon() { + coEvery { chatSessionDao.getById("root") } returns parentEntity() + coEvery { sessionUseCase.getSessionById("root") } returns parentEntity() + coEvery { providerRepo.getProviderById(any()) } returns null + every { providerRepo.getAllProviders() } returns flowOf(listOf(dummyProvider)) + } + + // ── parseDeploySpecs ───────────────────────────────────────── + + @Test + fun parseDeploySpecs_dropsMissingPrompt() { + val raw = buildJsonArray { + add(task("A", "第一个任务")) + add(buildJsonObject { put("name", "缺 prompt"); put("agent", "x") }) + add(task("B", "第二个任务", agent = "coder")) + } + val specs = buildService().parseDeploySpecs(raw) + assertEquals(2, specs.size) + assertEquals("A", specs[0].name) + assertEquals("coder", specs[1].agent) + assertEquals("第一个任务", specs[0].prompt) + } + + @Test + fun parseDeploySpecs_nonArrayReturnsEmpty() { + assertEquals(0, buildService().parseDeploySpecs(JsonPrimitive("oops")).size) + assertEquals(0, buildService().parseDeploySpecs(null).size) + } + + @Test + fun parseDeploySpecs_defaultsNameForNameless() { + val raw = buildJsonArray { add(buildJsonObject { put("prompt", "只有 prompt") }) } + val specs = buildService().parseDeploySpecs(raw) + assertEquals(1, specs.size) + assertEquals("部门任务", specs[0].name) + } + + // ── deploy ──────────────────────────────────────────────── + + @Test + fun deploy_withoutSession_returnsError() = runTest { + val svc = buildService() + val ctx = context().copy(sessionId = null) + val (deployed, errors) = svc.deploy(ctx, listOf(DeploySpec("a", null, null, null, "p"))) + assertTrue(deployed.isEmpty()) + assertTrue(errors.any { it.contains("缺少会话上下文") }) + } + + @Test + fun deploy_emptySpecs_returnsError() = runTest { + val svc = buildService() + val (deployed, errors) = svc.deploy(context(), emptyList()) + assertTrue(deployed.isEmpty()) + assertTrue(errors.any { it.contains("没有可派发") }) + } + + @Test + fun deploy_unknownAgent_reportsAndSkips() = runTest { + stubCommon() + every { agentRepo.find("nope") } returns null + every { agentRepo.listEnabled() } returns emptyList() + + val svc = buildService() + val (deployed, errors) = svc.deploy( + context(), + listOf(DeploySpec("部门X", "nope", null, null, "p")) + ) + assertTrue(deployed.isEmpty()) + assertTrue(errors.any { it.contains("没有名为「nope」") }) + } + + @Test + fun deploy_emitsSpawnedAndTracks() = runTest { + stubCommon() + stubSubSessionCreation() + val coderDef = AgentDefinition(name = "coder", description = "d", prompt = "p") + every { agentRepo.find("coder") } returns coderDef + every { agentRepo.listEnabled() } returns listOf( + AgentDefinitionEntry(coderDef, AgentDefinitionScope.GLOBAL) + ) + + val svc = buildService() + val (deployed, errors) = svc.deploy( + context(), + listOf(DeploySpec("任务A", null, null, null, "做A"), DeploySpec("任务B", "coder", null, null, "做B")) + ) + + val captured = mutableListOf<SubAgentEvent>() + coVerify(exactly = 2) { eventBus.emit(capture(captured)) } + + assertEquals(0, errors.size) + assertEquals(2, deployed.size) + assertTrue(captured.all { it.type == SubAgentEventType.SPAWNED }) + assertTrue(captured.all { it.parentSessionId == "root" }) + } + + @Test + fun deploy_capsAtMaxBatchDepartments() = runTest { + stubCommon() + stubSubSessionCreation() + + val svc = buildService() + val specs = (1..8).map { DeploySpec("任务$it", null, null, null, "prompt$it") } + val (deployed, errors) = svc.deploy(context(), specs) + + assertEquals(5, deployed.size) + assertTrue(errors.any { it.contains("一次最多派发") }) + coVerify(exactly = 5) { eventBus.emit(any()) } + } + + // ── spawnJury ───────────────────────────────────────────── + + @Test + fun spawnJury_blankDiff_returnsError() = runTest { + val svc = buildService() + val (deployed, errors) = svc.spawnJury(context(), " ", false) + assertTrue(deployed.isEmpty()) + assertTrue(errors.any { it.contains("diffSpec") }) + } + + @Test + fun spawnJury_withoutForeman_spawnsThreeReviewers() = runTest { + stubCommon() + stubSubSessionCreation() + every { agentRepo.find(any()) } answers { call -> + val name = call.invocation.args[0] as String + if (name.startsWith("review-")) { + AgentDefinition(name = name, description = "d", prompt = "p") + } else { + null + } + } + every { agentRepo.listEnabled() } returns emptyList() + + val svc = buildService() + val (deployed, errors) = svc.spawnJury(context(), "改动了 foo.kt", false) + + assertEquals(0, errors.size) + assertEquals(3, deployed.size) + assertEquals(3, deployed.map { it.agent }.toSet().size) + coVerify(exactly = 3) { eventBus.emit(any()) } + } + + @Test + fun spawnJury_withForeman_spawnsFour() = runTest { + stubCommon() + stubSubSessionCreation() + every { agentRepo.find(any()) } answers { call -> + AgentDefinition(name = call.invocation.args[0] as String, description = "d", prompt = "p") + } + every { agentRepo.listEnabled() } returns emptyList() + + val svc = buildService() + val (deployed, _) = svc.spawnJury(context(), "改动了 app.kt", includeForeman = true) + + assertEquals(4, deployed.size) + assertTrue(deployed.any { it.agent == "forehead" }) + coVerify(exactly = 4) { eventBus.emit(any()) } + } + + // ── budget ──────────────────────────────────────────────── + + @Test + fun budget_aggregatesParentAndSubs() = runTest { + activeIds.value = setOf("s1") + coEvery { chatSessionDao.getById("root") } returns + parentEntity().copy(totalInputTokens = 100, totalOutputTokens = 50) + coEvery { chatSessionDao.getSubSessionsByParentOnce("root") } returns listOf( + parentEntity().copy(id = "s1", title = "s1", totalInputTokens = 30, totalOutputTokens = 20), + parentEntity().copy(id = "s2", title = "s2", totalInputTokens = 5, totalOutputTokens = 0) + ) + val svc = buildService() + val b = svc.budget(context()) + assertEquals(135, b["totalInput"]!!.let { (it as JsonPrimitive).content.toInt() }) + assertEquals(70, b["totalOutput"]!!.let { (it as JsonPrimitive).content.toInt() }) + assertEquals(2, b["subCount"]!!.let { (it as JsonPrimitive).content.toInt() }) + assertEquals(1, b["running"]!!.let { (it as JsonPrimitive).content.toInt() }) + } +} \ No newline at end of file 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..f704e959 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() @@ -111,6 +151,46 @@ class ToolPermissionPolicyEngineTest { assertEquals(ToolPermissionPolicyEngine.Verdict.DENY, r.verdict) } + // ── GOD(神谕)模式放行,保留灾难性 rm 防护 ───────────────────── + + @Test + fun godMode_allowsWriteTool() = runTest { + val e = engine() + val r = e.evaluate(tool(ToolCapability.WRITE_WORKSPACE), "writeFile", emptyMap(), AgentMode.GOD) + assertEquals(ToolPermissionPolicyEngine.Verdict.ALLOW, r.verdict) + } + + @Test + fun godMode_allowsBash() = runTest { + val e = engine() + val r = e.evaluate(tool(ToolCapability.EXECUTE_COMMANDS), "Bash", bash("ls -la"), AgentMode.GOD) + assertEquals(ToolPermissionPolicyEngine.Verdict.ALLOW, r.verdict) + } + + @Test + fun godMode_stillBlocksCatastrophicRm() = runTest { + val e = engine() + val r = e.evaluate(tool(ToolCapability.EXECUTE_COMMANDS), "Bash", bash("rm -rf /"), AgentMode.GOD) + assertEquals(ToolPermissionPolicyEngine.Verdict.DENY, r.verdict) + assertEquals("安全防护:禁止执行高危删除操作(根目录删除)", r.denyReason) + } + + @Test + fun godMode_stillBlocksWorkspaceRm() = runTest { + val e = engine() + val r = e.evaluate(tool(ToolCapability.EXECUTE_COMMANDS), "Bash", bash("rm -rf ~/workspace/*"), AgentMode.GOD) + assertEquals(ToolPermissionPolicyEngine.Verdict.DENY, r.verdict) + } + + @Test + fun godMode_catastrophicRmDeniedEvenWithAllowRule() = runTest { + val e = engine( + PermissionRule("Bash", "rm -rf", PermissionDecision.ALLOW) + ) + val r = e.evaluate(tool(ToolCapability.EXECUTE_COMMANDS), "Bash", bash("rm -rf /"), AgentMode.GOD) + assertEquals(ToolPermissionPolicyEngine.Verdict.DENY, r.verdict) + } + // ── 只读 Agent 配置自动放行 ───────────────────────────────────── @Test diff --git a/app/src/test/java/com/aicode/feature/agent/domain/provider/AnthropicAdapterTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/provider/AnthropicAdapterTest.kt index 81390b45..d84ce0a3 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/provider/AnthropicAdapterTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/provider/AnthropicAdapterTest.kt @@ -93,15 +93,18 @@ class AnthropicAdapterTest { @Test fun max_tokens_leaves_room_for_content_above_thinking_budget() = runTest { val api = FakeApi(response()) - // 元数据上限比思考预算还小时不能直接用:max_tokens 必须大于 budget_tokens,否则服务端 400。 + // 元数据上限比思考预算还小时:max_tokens 必须 > budget_tokens(否则 400),且不得超过模型 + // 上限(否则也 400)。因此把预算 clamp 到上限之下:正文至少留出 min(4096, 上限/2)。 adapter(api, maxOutput = 2048).complete( "sys", listOf(AgentMessage.UserMessage(content = "hi")), reasoningEffort = "high" ) - assertEquals(8192, api.lastRequest?.thinking?.budget_tokens) - assertEquals(8192 + 4096, api.lastRequest?.max_tokens) + // contentRoom = min(4096, 2048/2=1024) = 1024;maxBudget = 2048-1024 = 1024; + // budget = min(8192, 1024) = 1024(clamp 进请求体);max_tokens = 1024+1024 = 2048,恰好等于模型上限。 + assertEquals(1024, api.lastRequest?.thinking?.budget_tokens) + assertEquals(2048, api.lastRequest?.max_tokens) } @Test diff --git a/app/src/test/java/com/aicode/feature/agent/domain/provider/RetryPolicyTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/provider/RetryPolicyTest.kt index a1fd50fd..55913c2b 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/provider/RetryPolicyTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/provider/RetryPolicyTest.kt @@ -1,8 +1,10 @@ package com.aicode.feature.agent.domain.provider +import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import retrofit2.HttpException import retrofit2.Response @@ -67,4 +69,94 @@ class RetryPolicyTest { assertNull(IOException().toRetryErrorInfo().statusCode) assertNull(StreamApiException("server_is_overloaded", "m").toRetryErrorInfo().statusCode) } + + // ── streamWithStaircaseRetry 计数与终止 ───────────────────── + + @Test + fun streamRetry_countsUpAndThrowsAfterMax() = runTest { + val attempts = mutableListOf<Int>() + var calls = 0 + val e = assertThrowsStreamError { + streamWithStaircaseRetry( + attemptOnce = { onContent -> + calls++ + throw IOException("stream reset by peer") + }, + onRetry = { attempt, max, _ -> + attempts += attempt + assertEquals(MAX_NETWORK_RETRIES, max) + } + ) + } + // 初次尝试 + MAX_NETWORK_RETRIES 次重试后仍未成功 → 抛出 + assertEquals(MAX_NETWORK_RETRIES + 1, calls) + // 计数从 1 真实累计到 MAX_NETWORK_RETRIES,不被打回 1 + assertEquals((1..MAX_NETWORK_RETRIES).toList(), attempts) + assertTrue(e is IOException) + } + + @Test + fun streamRetry_receivedContentThenReset_doesNotLoopForever() = runTest { + // 每次尝试都「收到过内容」再断流:旧实现会把 attempt 重置回 0 导致永远 1/6 死循环。 + // 回归断言:计数仍真实累计、最终在 MAX_NETWORK_RETRIES 次重试后抛出。 + val attempts = mutableListOf<Int>() + var calls = 0 + assertThrowsStreamError { + streamWithStaircaseRetry( + attemptOnce = { onContent -> + calls++ + onContent() // 模拟收到了内容块(连接已恢复) + throw IOException("unexpected end of stream") + }, + onRetry = { attempt, max, _ -> + attempts += attempt + assertEquals(MAX_NETWORK_RETRIES, max) + } + ) + } + assertEquals(MAX_NETWORK_RETRIES + 1, calls) + assertEquals((1..MAX_NETWORK_RETRIES).toList(), attempts) + } + + @Test + fun streamRetry_succeedsAfterRetries_returnsNormally() = runTest { + var calls = 0 + streamWithStaircaseRetry( + attemptOnce = { onContent -> + calls++ + if (calls < 3) { + onContent() + throw IOException("econnreset") + } else { + onContent() + } + } + ) + assertEquals(3, calls) + } + + @Test + fun streamRetry_nonRetriable_doesNotRetry() = runTest { + var calls = 0 + assertThrowsStreamError { + streamWithStaircaseRetry( + attemptOnce = { + calls++ + throw StreamApiException("invalid_request_error", "bad request") + } + ) + } + assertEquals(1, calls) + } + + private inline fun assertThrowsStreamError(block: () -> Unit): Throwable { + var caught: Throwable? = null + try { + block() + } catch (t: Throwable) { + caught = t + } + assertTrue("expected exception to be thrown", caught != null) + return caught!! + } } 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..7fb68008 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/tool/mode/SwitchModeToolTest.kt @@ -0,0 +1,163 @@ +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 toGod_succeedsAndPersistsMode() = 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("GOD"), context(AgentMode.BUILD)) + + assertTrue(r is ToolResult.Success) + assertEquals(AgentMode.GOD.name, upserted.captured.mode) + } + + @Test + fun fromGod_toBuild_succeeds() = runTest { + coEvery { dao.getById("s1") } returns session(AgentMode.GOD.name) + val upserted = slot<ChatSessionEntity>() + coEvery { dao.upsert(capture(upserted)) } just runs + + val r = tool.executeWithContext(args("BUILD"), context(AgentMode.GOD)) + + assertTrue(r is ToolResult.Success) + assertEquals(AgentMode.BUILD.name, upserted.captured.mode) + } + + @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/domain/tool/orchestration/OrchestrateTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/tool/orchestration/OrchestrateTest.kt new file mode 100644 index 00000000..dba1d018 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/tool/orchestration/OrchestrateTest.kt @@ -0,0 +1,105 @@ +package com.aicode.feature.agent.domain.tool.orchestration + +import com.aicode.feature.agent.domain.model.AgentContext +import com.aicode.feature.agent.domain.model.AgentMode +import com.aicode.feature.agent.domain.orchestration.OrchestratorService +import com.aicode.feature.agent.domain.orchestration.OrchestratorService.DeploySpec +import com.aicode.feature.agent.domain.tool.ToolResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OrchestrateToolTest { + + private val orchestrator = mockk<OrchestratorService>(relaxed = true) + private val tool = OrchestrateTool(orchestrator) + + private fun context(mode: AgentMode) = AgentContext( + currentFile = null, + selectedCode = null, + projectRoot = "/workspace", + language = null, + sessionId = "root", + mode = mode + ) + + private fun tasks(vararg prompts: String): JsonElement = buildJsonObject { + put("tasks", buildJsonArray { + prompts.forEach { prompt -> + add(buildJsonObject { put("name", "任务"); put("prompt", prompt) }) + } + }) + } + + @Test + fun nonGodMode_returnsNotGodModeError() = runTest { + val r = tool.executeWithContext( + mapOf("phase" to JsonPrimitive("decompose"), "tasks" to tasks("做A")), + context(AgentMode.BUILD) + ) + assertTrue(r is ToolResult.Error) + assertEquals("NOT_GOD_MODE", (r as ToolResult.Error).code) + } + + @Test + fun unknownPhase_returnsInvalidPhase() = runTest { + val r = tool.executeWithContext( + mapOf("phase" to JsonPrimitive("bogus")), + context(AgentMode.GOD) + ) + assertTrue(r is ToolResult.Error) + assertEquals("INVALID_PHASE", (r as ToolResult.Error).code) + } + + @Test + fun decompose_missingTasks_returnsError() = runTest { + val r = tool.executeWithContext( + mapOf("phase" to JsonPrimitive("decompose")), + context(AgentMode.GOD) + ) + assertTrue(r is ToolResult.Error) + assertEquals("MISSING_TASKS", (r as ToolResult.Error).code) + } + + @Test + fun decompose_delegatesToOrchestratorAndReturnsSuccess() = runTest { + coEvery { orchestrator.parseDeploySpecs(any()) } returns + listOf(DeploySpec("任务", null, null, null, "做A")) + coEvery { orchestrator.deploy(any(), any()) } returns + (listOf(OrchestratorService.Deployed("s1", "任务", null, null, null)) to emptyList()) + + val args = mapOf( + "phase" to JsonPrimitive("decompose"), + "goal" to JsonPrimitive("整体目标"), + "tasks" to tasks("做A") + ) + val r = tool.executeWithContext(args, context(AgentMode.GOD)) + + assertTrue(r is ToolResult.Success) + val data = (r as ToolResult.Success).data.toString() + coVerify(exactly = 1) { orchestrator.deploy(any(), any()) } + assertTrue(data.contains("\"deployedCount\":1")) + assertTrue(data.contains("\"id\":\"s1\"")) + } + + @Test + fun review_blankDiff_returnsError() = runTest { + val r = tool.executeWithContext( + mapOf("phase" to JsonPrimitive("review"), "diffSpec" to JsonPrimitive(" ")), + context(AgentMode.GOD) + ) + assertTrue(r is ToolResult.Error) + assertEquals("MISSING_DIFF", (r as ToolResult.Error).code) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/aicode/feature/agent/domain/tool/semantic/InvertedIndexTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/tool/semantic/InvertedIndexTest.kt new file mode 100644 index 00000000..efde8a16 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/tool/semantic/InvertedIndexTest.kt @@ -0,0 +1,85 @@ +package com.aicode.feature.agent.domain.tool.semantic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class InvertedIndexTest { + + @Test + fun `无交集不返回`() { + val idx = InvertedIndex() + idx.addDocument("a/Login.kt", listOf("auth", "login", "handler")) + idx.addDocument("b/Logo.kt", listOf("logo", "draw")) + + assertEquals(listOf<String>(), idx.score(listOf("database"), 8)) + } + + @Test + fun `单文档多词元计数与检索`() { + val idx = InvertedIndex() + idx.addDocument("Login.kt", listOf("auth", "login", "login", "token")) + val result = idx.score(listOf("login"), 3) + assertEquals(1, result.size) + assertEquals("Login.kt", result[0].docId) + assertTrue(result[0].score > 0) + } + + @Test + fun `相关度高者排序靠前`() { + val idx = InvertedIndex() + idx.addDocument("a/LoginStore.kt", listOf("auth", "login", "store", "token")) + idx.addDocument("b/ImageCache.kt", listOf("image", "cache", "login")) + val result = idx.score(listOf("auth", "login", "token"), 2) + assertTrue(result[0].docId.contains("LoginStore")) // 命中 3 词,更高 + } + + @Test + fun `文件名加权靠前`() { + val idx = InvertedIndex() + // 内容基本相同,但 docId 含 token 的文件名额外加权 + idx.addDocument("LoginTokenHandler.kt", listOf("auth"), listOf("token", "handler")) + idx.addDocument("AuthProvider.kt", listOf("auth"), emptyList()) + val result = idx.score(listOf("auth"), 2) + // 查 auth 时两文档 tf 相同;但 LoginTokenHandler 的 pathTerms 不含 auth,故相等 + assertEquals(2, result.size) + } + + @Test + fun `路径命中加权高于纯内容命中`() { + val idx = InvertedIndex() + // A 的 config 同时出现在内容与路径(pathTerms 再计一次 → tf 2 > 1) + idx.addDocument("ConfigFactory.kt", listOf("config", "factory"), listOf("config", "factory")) + idx.addDocument("Runner.kt", listOf("config", "runner")) + val a = idx.score(listOf("config"), 3).first { it.docId == "ConfigFactory.kt" }.score + val b = idx.score(listOf("config"), 3).first { it.docId == "Runner.kt" }.score + assertTrue(a > b) + } + + @Test + fun `幂等可复现`() { + val a1 = InvertedIndex().apply { + addDocument("Login.kt", listOf("auth", "login")) + addDocument("Cache.kt", listOf("cache", "login")) + } + val a2 = InvertedIndex().apply { + addDocument("Cache.kt", listOf("cache", "login")) + addDocument("Login.kt", listOf("auth", "login")) + } + val r1 = a1.score(listOf("login"), 2).map { it.docId to it.score } + val r2 = a2.score(listOf("login"), 2).map { it.docId to it.score } + assertEquals(r1, r2) + } + + @Test + fun `重复 docId 抛异常`() { + val idx = InvertedIndex() + idx.addDocument("A.kt", listOf("a")) + try { + idx.addDocument("A.kt", listOf("b")) + throw AssertionError("应抛 IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // expect + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/aicode/feature/agent/domain/tool/semantic/SemanticTokenizerTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/tool/semantic/SemanticTokenizerTest.kt new file mode 100644 index 00000000..110dd2a5 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/tool/semantic/SemanticTokenizerTest.kt @@ -0,0 +1,53 @@ +package com.aicode.feature.agent.domain.tool.semantic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SemanticTokenizerTest { + + @Test + fun `空串无词元`() { + assertEquals(listOf<String>(), SemanticTokenizer.tokenize("")) + assertEquals(listOf<String>(), SemanticTokenizer.tokenize(" \n\t ")) + assertEquals(listOf<String>(), SemanticTokenizer.tokenize("!!! ??")) + } + + @Test + fun `驼峰拆分小写化`() { + assertEquals(listOf("user", "login", "handler"), SemanticTokenizer.tokenize("UserLoginHandler")) + assertEquals(listOf("callback", "store"), SemanticTokenizer.tokenize("CallbackStore")) + } + + @Test + fun `下划线和中划线拆分`() { + assertEquals(listOf("auth", "token"), SemanticTokenizer.tokenize("auth_token")) + assertEquals(listOf("kebab", "case", "name"), SemanticTokenizer.tokenize("kebab-case-name")) + assertEquals(listOf("get", "user", "by", "id"), SemanticTokenizer.tokenize("get user_by-id")) + } + + @Test + fun `点号分隔拆词`() { + assertEquals(listOf("com", "aicode", "app"), SemanticTokenizer.tokenize("com.aicode.app")) + } + + @Test + fun `边界字符拆分`() { + assertEquals(listOf("fun", "main"), SemanticTokenizer.tokenize("fun main() {")) + assertEquals(listOf("data", "class", "user"), SemanticTokenizer.tokenize("data class User ")) + } + + @Test + fun `中文整段与二元组`() { + // 纯中文段:整段 + 二元组 + val tokens = SemanticTokenizer.tokenize("鉴权") + assertTrue("鉴权" in tokens) + assertTrue("鉴权".length == 2) + } + + @Test + fun `单字母保留`() { + assertTrue("i" in SemanticTokenizer.tokenize("for (i = 0")) + } +} \ No newline at end of file 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/agent/presentation/SessionForkTest.kt b/app/src/test/java/com/aicode/feature/agent/presentation/SessionForkTest.kt new file mode 100644 index 00000000..7e6deaae --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/presentation/SessionForkTest.kt @@ -0,0 +1,88 @@ +package com.aicode.feature.agent.presentation + +import com.aicode.feature.agent.data.local.entity.AgentMessageEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionForkTest { + + private fun msg(id: String, role: String = "USER", content: String = "c", ts: Long = 0L) = + AgentMessageEntity( + id = id, + sessionId = "src", + role = role, + content = content, + timestamp = ts, + toolName = null, + toolArgs = null, + inputTokens = 10, + outputTokens = 5, + ) + + @Test + fun fork_copiesThroughAnchorInclusive() { + val source = listOf(msg("a", content = "c-a", ts = 1), msg("b", content = "c-b", ts = 2), msg("c", content = "c-c", ts = 3)) + val forked = buildForkedMessages(source, "b", "new") + assertEquals(2, forked.size) + assertEquals("c-a", forked[0].content) + assertEquals("c-b", forked[1].content) + } + + @Test + fun fork_afterAnchorExcluded() { + val source = listOf(msg("a", content = "c-a", ts = 1), msg("b", content = "c-b", ts = 2), msg("c", content = "c-c", ts = 3)) + val forked = buildForkedMessages(source, "a", "new") + assertEquals(1, forked.size) + assertEquals("c-a", forked[0].content) + } + + @Test + fun fork_generatesNewIdsAndSession() { + val source = listOf(msg("a", ts = 1), msg("b", ts = 2)) + val forked = buildForkedMessages(source, "b", "new-session") + forked.forEach { entity -> + assertNotEquals("src", entity.sessionId) + assertEquals("new-session", entity.sessionId) + assertNotEquals(entity.id, "a") + } + // id 唯一 + assertNotEquals(forked[0].id, forked[1].id) + } + + @Test + fun fork_preservesFieldsAndOrder() { + val source = listOf( + msg("a", role = "USER", content = "hello", ts = 5), + AgentMessageEntity( + id = "t", sessionId = "src", role = "ASSISTANT", content = "ok", + timestamp = 6, toolName = "run", toolArgs = "{\"cmd\":\"ls\"}", isError = true, + inputTokens = 3, outputTokens = 4, + ), + ) + val forked = buildForkedMessages(source, "t", "new") + assertEquals(2, forked.size) + val tool = forked[1] + assertEquals("ASSISTANT", tool.role) + assertEquals("ok", tool.content) + assertEquals("run", tool.toolName) + assertEquals("{\"cmd\":\"ls\"}", tool.toolArgs) + assertTrue(tool.isError) + assertEquals(3, tool.inputTokens) + assertEquals(4, tool.outputTokens) + assertEquals(5, forked[0].timestamp) + assertEquals(6, forked[1].timestamp) + } + + @Test + fun fork_anchorNotFound_returnsEmpty() { + val source = listOf(msg("a"), msg("b")) + assertTrue(buildForkedMessages(source, "nope", "new").isEmpty()) + } + + @Test + fun fork_emptySource_returnsEmpty() { + assertTrue(buildForkedMessages(emptyList(), "a", "new").isEmpty()) + } +} diff --git a/app/src/test/java/com/aicode/feature/agent/presentation/component/ChatVoiceInputControllerTest.kt b/app/src/test/java/com/aicode/feature/agent/presentation/component/ChatVoiceInputControllerTest.kt new file mode 100644 index 00000000..c4bae1f7 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/presentation/component/ChatVoiceInputControllerTest.kt @@ -0,0 +1,38 @@ +package com.aicode.feature.agent.presentation.component + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatVoiceInputControllerTest { + + @Test + fun emptyBase_prependsNothing() { + assertEquals("hello", mergeDraft("", "hello")) + } + + @Test + fun baseWithTrailingSpace_singleSeparator() { + assertEquals("base hello", mergeDraft("base ", "hello")) + } + + @Test + fun recognizedBlank_returnsTrimmedBase() { + assertEquals("base", mergeDraft("base ", " ")) + } + + @Test + fun bothBlank_returnsEmpty() { + assertEquals("", mergeDraft(" ", " ")) + } + + @Test + fun trimsRecognizedEdges() { + assertEquals("base hello", mergeDraft("base", " hello ")) + } + + @Test + fun consecutiveDictation_appendsRepeatedly() { + val once = mergeDraft("", "first") + assertEquals("first second", mergeDraft(once, "second")) + } +} diff --git a/app/src/test/java/com/aicode/feature/backup/domain/SessionMarkdownExporterTest.kt b/app/src/test/java/com/aicode/feature/backup/domain/SessionMarkdownExporterTest.kt new file mode 100644 index 00000000..8d40e07d --- /dev/null +++ b/app/src/test/java/com/aicode/feature/backup/domain/SessionMarkdownExporterTest.kt @@ -0,0 +1,134 @@ +package com.aicode.feature.backup.domain + +import com.aicode.feature.agent.data.local.entity.AgentMessageEntity +import com.aicode.feature.agent.presentation.AgentAttachment +import com.aicode.feature.agent.presentation.BACKGROUND_NOTIFICATION_PREFIX +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionMarkdownExporterTest { + + private fun message( + id: String, + role: String, + content: String, + toolName: String? = null, + toolArgs: String? = null, + isError: Boolean = false, + isCompacted: Boolean = false, + isContextSummary: Boolean = false, + isCompactionMarker: Boolean = false, + attachmentsJson: String? = null, + ) = AgentMessageEntity( + id = id, + sessionId = "s", + role = role, + content = content, + timestamp = 1_700_000_000_000L, + toolName = toolName, + toolArgs = toolArgs, + isError = isError, + isCompacted = isCompacted, + isContextSummary = isContextSummary, + isCompactionMarker = isCompactionMarker, + attachmentsJson = attachmentsJson, + ) + + @Test + fun header_containsTitleTimeAndCount() { + val md = SessionMarkdownExporter.build("My Session", emptyList(), exportedAt = 1_700_000_000_000L) + assertTrue(md.startsWith("# My Session\n")) + assertTrue(md.contains("导出自 AiCode")) + assertTrue(md.contains("2023-11-14")) + assertTrue(md.contains("0 条消息")) + } + + @Test + fun userAndAssistant_renderedInOrder() { + val md = SessionMarkdownExporter.build( + "t", + listOf( + message("u", "USER", "fix the bug"), + message("a", "ASSISTANT", "done"), + ) + ) + assertTrue(md.contains("## 用户\n\nfix the bug")) + assertTrue(md.contains("## AI\n\ndone")) + assertTrue(md.indexOf("## 用户") < md.indexOf("## AI")) + } + + @Test + fun toolLine_containsNameErrorAndArgs() { + val md = SessionMarkdownExporter.build( + "t", + listOf( + message("a", "ASSISTANT", "let me run it"), + message("x", "TOOL", "ok", toolName = "executeCommand", toolArgs = "{\n \"cmd\": \"ls\"\n}", isError = true), + ) + ) + assertTrue(md.contains("> 工具 `executeCommand` · 失败")) + assertTrue(md.contains("> { \"cmd\": \"ls\" }")) + } + + @Test + fun attachment_markedAfterUserMessage() { + val json = Json.encodeToString(listOf(AgentAttachment("pic.png", "", "", "image/png", 10, true))) + val md = SessionMarkdownExporter.build( + "t", + listOf( + message("u", "USER", "see image", attachmentsJson = json), + ) + ) + assertTrue(md.contains("[附件: pic.png]")) + } + + @Test + fun internalMessages_filtered() { + val md = SessionMarkdownExporter.build( + "t", + listOf( + message("c", "USER", "compacted content", isCompacted = true), + message("s", "USER", "summary", isContextSummary = true), + message("m", "USER", "marker", isCompactionMarker = true), + message("b", "USER", "$BACKGROUND_NOTIFICATION_PREFIX done"), + message("a", "ASSISTANT", "visible"), + ) + ) + assertFalse(md.contains("compacted")) + assertFalse(md.contains("summary")) + assertFalse(md.contains("marker")) + assertFalse(md.contains(BACKGROUND_NOTIFICATION_PREFIX)) + assertTrue(md.contains("visible")) + } + + @Test + fun blankAssistantContent_skipped() { + val md = SessionMarkdownExporter.build( + "t", + listOf( + message("a", "ASSISTANT", " "), + message("x", "TOOL", "ok", toolName = "run"), + message("u", "USER", "hello"), + ) + ) + assertFalse(md.contains("## AI\n\n\n> 工具")) + assertTrue(md.contains("> 工具 `run`")) + assertTrue(md.contains("## 用户")) + } + + @Test + fun tokenTotals_inHeader() { + val md = SessionMarkdownExporter.build( + "t", + listOf( + AgentMessageEntity("a", "s", "ASSISTANT", "hi", 1L, inputTokens = 100, outputTokens = 50), + AgentMessageEntity("b", "s", "ASSISTANT", "yo", 2L, inputTokens = 200, outputTokens = 25), + ) + ) + assertTrue(md.contains("输入 300 tokens · 输出 75 tokens")) + } +} diff --git a/app/src/test/java/com/aicode/feature/git/domain/GitMergeConflictsTest.kt b/app/src/test/java/com/aicode/feature/git/domain/GitMergeConflictsTest.kt new file mode 100644 index 00000000..70a49b38 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/git/domain/GitMergeConflictsTest.kt @@ -0,0 +1,44 @@ +package com.aicode.feature.git.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GitMergeConflictsTest { + + @Test + fun noConflict_returnsEmpty() { + assertEquals(emptyList<String>(), mergeConflictsFromPorcelain(" M a.txt\n?? b.txt\n")) + } + + @Test + fun uuFiles_captured() { + val conflicts = mergeConflictsFromPorcelain("UU main.kt\n M readme.md\n") + assertEquals(listOf("main.kt"), conflicts) + } + + @Test + fun allUnmergedCodes_captured() { + val raw = "UU a.kt\nAA b.kt\nDD c.kt\nUD d.kt\nAU e.kt\nUA f.kt\nDU g.kt\n" + val conflicts = mergeConflictsFromPorcelain(raw) + assertEquals(listOf("a.kt", "b.kt", "c.kt", "d.kt", "e.kt", "f.kt", "g.kt"), conflicts) + } + + @Test + fun stagedAndUntracked_notConflicts() { + val raw = "M staged.txt\n M unstaged.txt\n?? untracked.txt\n" + assertTrue(mergeConflictsFromPorcelain(raw).isEmpty()) + } + + @Test + fun renameConflict_usesNewPath() { + val conflicts = mergeConflictsFromPorcelain("UU old.txt -> new.txt\n") + assertEquals(listOf("new.txt"), conflicts) + } + + @Test + fun quotedPath_unquoted() { + val conflicts = mergeConflictsFromPorcelain("UU \"my file.txt\"\n") + assertEquals(listOf("my file.txt"), conflicts) + } +} \ No newline at end of file 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/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 06a2d84e..869f1e61 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -111,7 +111,9 @@ export default defineConfig({ text: '核心工作流', items: [ { text: '聊天界面导览', link: '/guide/chat' }, - { text: '三种模式(Build / Plan / Auto)', link: '/guide/modes' }, + { text: '接收外部分享', link: '/guide/share' }, + { text: '语音输入', link: '/guide/voice-input' }, + { text: '五种模式(Build / Plan / Auto / Target / God)', link: '/guide/modes' }, { text: '检查点与撤销', link: '/guide/checkpoint' }, { text: '终端', link: '/guide/terminal' }, { text: '文件浏览与代码编辑', link: '/guide/files' }, 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..cb0a62c5 100644 --- a/docs-site/docs/guide/chat.md +++ b/docs-site/docs/guide/chat.md @@ -6,6 +6,7 @@ - **菜单按钮**(左上):打开侧边栏,管理会话列表和浏览文件。 - **会话标题与模型名**:上面一行是当前会话名,下面一行是正在用的模型。 +- **搜索按钮**:展开会话内搜索面板,按内容查找当前会话的历史消息(详见[会话内搜索](#会话内搜索))。 - **新建会话**:开一个新的对话。 - **Git 入口**:跳转到 Git 版本管理页。 - **终端入口**:跳转到终端页。 @@ -18,13 +19,16 @@ ### 会话页 +会话页顶部有搜索框,输入关键词即可按标题过滤会话列表,清空输入恢复全部显示。 + 会话按最后回复时间从新到旧排列,并按时间分组(今天 / 昨天 / 7 天内 / 30 天内 / 更早按月份)。置顶的会话单独放在最前面的「置顶」分组,卡片是浅蓝色背景。 短按切换会话,**长按弹出操作菜单**: - **置顶 / 取消置顶**:置顶后固定排在最前。 - **重命名**:改会话标题,不影响排序。 -- **导出**:导出这个会话。 +- **导出**:导出这个会话(tar.gz 备份格式,供备份恢复)。 +- **导出 Markdown**:生成本会话的人类可读对话文档(用户/AI 正文 + 工具调用记录),保存为 `.md` 文件,便于发给同事或存档。 - **删除**:会弹确认框,确认后删除该会话及其所有消息。 派生过子代理的会话,行尾会显示子代理数量和展开箭头,点开可以在下方看到它的子代理(**1.11.0 起**),详见[子代理](/guide/subagent)。 @@ -44,16 +48,34 @@ - **工作区按钮**:显示当前工作区名,点击弹出工作区管理面板(见下)。 - **文件按钮**:上传文件作为附件,AI 可以读它的内容。 - **图片按钮**:上传图片作为附件。点击直接打开系统相册,可以多选。需要当前模型支持图片输入。 +- **优化表达按钮**:把输入框里的文字用当前对话模型结合最近对话上下文优化后回填输入框(补全指代、去歧义、去寒暄),确认无误再发送。输入框为空或 AI 正在工作时不可用。 - **发送按钮**:AI 空闲时是普通发送;AI 正在工作时,输入框为空则显示停止按钮,有内容则显示橙色发送按钮(消息会排队)。 上传的附件会以预览卡显示在输入框上方,发送前可以移除。点图片缩略图可以[全屏看大图](#全屏看图)。 +## 行动建议 + +每轮对话正常结束后,输入框上方会自动弹出 3 条后续行动建议(基于刚完成的对话内容生成:自然的下一步、明显的缺口、验证或交付动作)。点击任一条,建议文本会填入输入框,可直接修改或发送。 + +发送新消息后旧建议自动消失,本轮结束后生成新的建议;AI 连续自动执行(AUTO 模式链式轮次)期间只在最后一轮结束后弹出。 + 未发送的草稿内容会按会话分别保存:在当前会话输入内容后切换至其它会话,返回时草稿依然保留。草稿会持久化到本地,杀掉 App 重开(进程重启)后依然在。 滚动消息列表时,输入框区域会淡出到 40% 透明度以减少干扰,停止滚动后恢复。 翻历史消息时去开编辑页、终端页或设置页,返回后仍停在原来的位置,不会被拉回最新消息;离开前本就贴在底部的话,返回后继续跟随新消息。 +## 会话内搜索 + +点标题栏的搜索按钮,消息列表上方会展开搜索面板: + +- 输入关键词后自动搜索(停顿一下即出结果,也可点键盘上的搜索键立即查),匹配当前会话的已保存消息,最多显示 50 条,按时间倒序。 +- 结果按「我 / AI / 工具」标注消息来源,显示内容片段和时间,方便辨认。 +- 点任意结果会自动滚动定位到那条消息并高亮数秒;目标消息距离较远时 App 会自动加载更多历史消息再定位。 +- 点结果条目右侧的关闭按钮或收起面板即退出搜索。 + +侧边栏会话页的搜索框按**标题**过滤会话列表;标题栏搜索按钮按**消息内容**查找当前会话,两者配合可以快速翻到「当时聊了什么」的位置。 + ## 工作区管理 点工作区按钮弹出的面板里可以: @@ -70,6 +92,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 忙碌时会进队列,空闲后自动执行。命令文本会作为用户消息显示在对话里,并进入上下文。 @@ -97,6 +126,8 @@ AI 生成回复时以**打字机效果**逐字显示,速度跟随模型的吐 若工具卡片底部显示「后台任务/子代理已结束」提示(绿点代表成功,红点代表失败或被手动终止),表明后台任务或子代理已在该轮中完成并被当前会话捕获,AI 会自动接续处理。 +**长按任意消息**会弹出操作菜单:编辑、复制、删除,以及「**在此分叉新会话**」——以这条消息为起点开一个新会话,把这条消息及之前的所有对话复制过去作上下文,你可以在新会话里换个思路继续问,原会话不受影响(适合从长对话里另起一条研究路线,省去重打上文的 token)。 + ## 全屏看图 聊天里的图片都能点开看大图,四个地方都一样: diff --git a/docs-site/docs/guide/git.md b/docs-site/docs/guide/git.md index 104ae78d..3fef2b5b 100644 --- a/docs-site/docs/guide/git.md +++ b/docs-site/docs/guide/git.md @@ -66,6 +66,13 @@ - 远程分支的删除会直接作用到远端,**不可撤销**。 - 当前所在的分支不能删。 +**合并 / 变基**:点某个本地分支 → 操作菜单 →「合并」,弹出确认框,可选「合并」或「变基」。合并把该分支合进当前分支(可快进时自动快进);变基把当前分支挪到该分支之上。 + +- 合并/变基**产生冲突**时,Git 页顶部会出现红色冲突横幅,列出冲突文件数量和前几个文件路径。在工作区终端手动解决冲突并 `git add` 后: + - 变基场景点横幅里的「继续变基」完成操作; + - 合并场景直接点「中止」再重新合并,或在终端里手动 `git commit` 收尾。 +- 想放弃本次合并/变基,点横幅里的「中止」回到操作前状态。 + **标签**:点标签分组标题右侧的 + 从当前 HEAD 创建轻量标签;点已有标签 → 操作菜单 → 删除可以删本地标签。 ## 提交 diff --git a/docs-site/docs/guide/modes.md b/docs-site/docs/guide/modes.md index a1df09ee..67f6da9b 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 / God -输入框左侧的模式按钮点一下循环切换:BUILD → PLAN → AUTO → BUILD。三种模式的区别是 AI 的**权限范围**。 +输入框左侧的模式按钮点一下循环切换:BUILD → PLAN → AUTO → TARGET → GOD → BUILD。五种模式的区别是 AI 的**权限范围**与**终止方式**。 ## BUILD(构建模式) @@ -28,8 +28,38 @@ 只有灾难性删除(比如 `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 的唯一路径。TARGET 则可以由 AI 带 `goal` 参数主动进入,达成目标后调 `completeGoal` 退出。GOD 需要你在界面上点按钮进入,AI 无法自行进入。 + +## GOD(神谕模式 / 乙方公司) + +GOD 模式把「你 ↔ AI」的关系重构为**甲方 / 乙方**协作: + +- **你(甲方 / 投资者)**:只负责提出目标、指明方向、给出预算额度,不为单次工具调用做授权决策。 +- **AI(乙方 / CEO 总汇报员)**:作为「AI 公司」的操盘手,把需求拆解、派发给**部门子代理**去并行执行,必要时组**评审团**对改动做多视角把关,再把结果作为结构化汇报呈现给你。 + +进入 GOD 后,你发消息提需求,AI 会: + +1. **拆解**:把你的一句话需求拆成多个可并行的子任务(命名部门),一次派发给子代理执行。可指定不同子代理(保守 / 革新两种执行风格)与模型、思考强度。 +2. **评审**:对重要改动,AI 会派 3 名只读评审(严苛 / 务实 / 建设)分角度审查,再让评审长 `forehead` 汇总成一个裁决(通过 / 返工 / 换风格),并列出必须修复的问题。 +3. **收口**:AI 收齐各部门与评审结论后,向你输出结构化汇报(谁做的、谁审的、结果如何、花了多少额度),而不是倾倒执行细节。 + +GOD 与 AUTO 一样权限全放行(只保留灾难性删除防护),治理与取舍由 CEO 替你决定;你只需要在方向判断或预算取舍时回应它的提问(多为选择题)。中途想退出,把模式切回 BUILD 即可。 -有一个方向上的限制:**AUTO 模式只能你自己手动进入**,AI 无法通过工具切进 AUTO。但反过来,处于 AUTO 模式时 AI 可以主动切到 PLAN——这是 AI 退出 AUTO 的唯一路径。 +> 适用场景:跨多文件的复杂改动、需要在「保守 / 现代化」两种实现间取舍、或想要一方「先实现、再多视角评审」的落地流程。 diff --git a/docs-site/docs/guide/overview.md b/docs-site/docs/guide/overview.md index 048b5886..07dac719 100644 --- a/docs-site/docs/guide/overview.md +++ b/docs-site/docs/guide/overview.md @@ -11,7 +11,9 @@ | 功能 | 说明 | | --- | --- | | 聊天交互与工作区 | 标题栏、侧边栏、工具栏、消息队列、工作区切换 → [文档](/guide/chat) | -| 运行模式(三种模式) | Build / Plan / Auto 的权限控制与场景 → [文档](/guide/modes) | +| 接收外部分享 | 从浏览器/编辑器经系统分享菜单把文本或图片发给 AI → [文档](/guide/share) | +| 语音输入 | 麦克风听写实时把语音转文字进输入框 → [文档](/guide/voice-input) | +| 运行模式(五种模式) | Build / Plan / Auto / Target / God 的权限控制与终止条件 → [文档](/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 后残留的旧通知在下次启动时清理。 + ## 页面结构 规则分「当前项目」和「全局」两组,点标题可折叠。 diff --git a/docs-site/docs/guide/quick-start.md b/docs-site/docs/guide/quick-start.md index 694c473c..85feaac5 100644 --- a/docs-site/docs/guide/quick-start.md +++ b/docs-site/docs/guide/quick-start.md @@ -78,3 +78,4 @@ AiCode 自己不提供模型,需要先接入一个模型服务。 - 识图、生图与标题总结支持独立指定专属模型,详见[默认模型](/guide/default-models)。 - AI 修改代码前会自动创建快照,支持一键安全回滚,详见[检查点与撤销](/guide/checkpoint)。 - 执行编译等耗时任务前,建议开启[软件权限](/guide/app-permissions)中的后台运行保活,防止系统在切换后台时终止进程。 +- 找不到某个功能代码在哪时,可以直接用自然语言问 AI(如「登录鉴权在哪里实现」)。内置的语义检索会按相关度在工作区内召回最相关的代码文件,全程本地离线、无需联网。 diff --git a/docs-site/docs/guide/share.md b/docs-site/docs/guide/share.md new file mode 100644 index 00000000..6c79df3d --- /dev/null +++ b/docs-site/docs/guide/share.md @@ -0,0 +1,38 @@ +# 接收外部分享 + +从浏览器、代码编辑器、剪贴板工具等任意支持系统分享菜单的 App,把选中的文本或图片发给 AiCode,让 AI 直接分析,省去手动复制粘贴。 + +## 怎么用 + +1. 在外部 App(如浏览器看到一段报错、IDE 里选中一段代码)长按或菜单选「分享」。 +2. 分享菜单里选 **AiCode**。 +3. AiCode 打开(或切回前台),分享内容填入聊天输入框。 +4. 你可以补充一句指令(如「分析这段报错」「给这段代码加注释」),再点发送。 + +内容默认只填入输入框,不会自动发给 AI——给你补指令和确认的机会。 + +## 文本与图片 + +- **纯文本**:分享菜单带文本(`text/plain`)即填入输入框。多次连续分享会换行拼接,保留每段内容。 +- **图片**:分享图片(`image/*`,如截图、相册图)会加入输入框附件区,走与手动选图相同的识图链路。需当前模型支持视觉(见 [默认与专用模型](./default-models))。 + +文本和图片可同时分享(图片带文字说明时)。 + +## 冷启动与热启动 + +- **App 没开时**从分享菜单拉起:AiCode 新建一个会话,内容进输入框。 +- **App 已在前台**时分享:内容进**当前会话**输入框;若当前在设置页等无会话处,会新建会话并跳回聊天页。 + +## 常见问题 + +::: tip 内容太长怎么办 +分享文本超过 2 万字符会自动截断前 2 万并提示。超长日志建议先存成文件再分享,或让 AI 在容器里直接读文件。 +::: + +::: warning 图片加载失败 +若图片 URI 读取失败(如来源 App 撤销了权限),仅注入有效部分,并提示「部分图片加载失败」。文本不受影响。 +::: + +::: tip 没收到内容 +若分享既无文本又无有效图片,AiCode 不创建会话、不弹窗,静默忽略。检查来源 App 是否实际选中了内容。 +::: diff --git a/docs-site/docs/guide/subagent.md b/docs-site/docs/guide/subagent.md index 1e788856..1e4c702f 100644 --- a/docs-site/docs/guide/subagent.md +++ b/docs-site/docs/guide/subagent.md @@ -57,6 +57,16 @@ AI 在派发前会弹窗向你确认授权;获得同意后子代理将在后 - **模型定制**:默认继承当前会话的模型。如果想为它配置更经济的模型,只需在 `explore.md` 中填入对应的 `provider` 与 `model`; - **持久保留**:此文件仅在首次不存在时释放,你所做的个性化修改在 App 升级时均会完整保留。 +## God 模式的家庭内子代理 + +GOD(神谕)模式会用到一组内置子代理,随模式首次启用自动释放: + +- 执行部门(两类风格):`exec-conservative`(保守执行,遵循现有风格做最小改动)与 `exec-modern`(革新执行,现代化实现); +- 评审团(三视角):`review-tough`(严苛)、`review-pragmatic`(务实)、`review-optimist`(建设); +- 评审长:`forehead`(汇总裁决)。 + +这些角色由主会话经 `orchestrate` 工具在 GOD 模式下一键派发与组队,普通会话不注入。同样可以进入 `~/.aicode/agents/`(或项目 `.aicode/agents/`)按需微调它们的提示词与模型。 + ## 自定义子代理 创建自定义子代理有两种方式,效果完全等价(App 内保存后写出的就是同一份 `.md` 文件): diff --git a/docs-site/docs/guide/terminal.md b/docs-site/docs/guide/terminal.md index 8a086b84..9d7af079 100644 --- a/docs-site/docs/guide/terminal.md +++ b/docs-site/docs/guide/terminal.md @@ -33,3 +33,10 @@ AiCode 内置一个完整的终端。本地模式下它连着手机上的 Linux - **字体大小**:10 到 22 之间无级调节,面板顶部有实时预览。 - **字体**(**1.11.0 起**):默认是 JetBrains Mono(无连字版本),`0` 和 `O`、`1` 和 `l` 区分得很清楚。也可以切换成系统等宽字体,或者点「导入」从手机里选一个 `ttf` / `otf` / `ttc` 字体文件;导入的字体会出现在列表里,点字体标签上的 × 可以删除,删掉正在用的会自动回落到系统等宽。中文字符始终由系统字体补齐。字体只影响终端,不改变 AI 对话区。 - **光标样式**:方块、下划线、竖线三种。 + +## Web 服务预览 + +在容器里跑起 Web 服务(如 `python -m http.server 3000`、`npm run dev`)后,点终端页右上角的**预览**图标,输入服务端口,AiCode 会内置一个全屏浏览器打开 `http://127.0.0.1:端口`,直接在手机上查看效果。 + +- 预览页顶栏可以**重载**,或点右上角图标**转去系统浏览器**打开。 +- 这是本地容器模式的专属功能;远程 SSH 模式下的服务不在这台手机上,点预览会提示不可用。 diff --git a/docs-site/docs/guide/voice-input.md b/docs-site/docs/guide/voice-input.md new file mode 100644 index 00000000..56f6e5e7 --- /dev/null +++ b/docs-site/docs/guide/voice-input.md @@ -0,0 +1,30 @@ +# 语音输入 + +手不动键盘,把需求「说」进输入框:聊天输入框工具栏新增麦克风按钮,点一下开始听写,说话内容实时出现在输入框里,再点一下停止,识别文本追加在已输入内容之后。 + +## 怎么用 + +1. 点击输入框工具栏的 **麦克风** 图标。 +2. 首次使用会请求麦克风权限,允许后自动开始听写。 +3. 对着手机说出需求,识别文本实时填入输入框。 +4. 说完点击 **停止** 图标(原麦克风位置),文本定稿并追加在你已输入的内容后面。 +5. 像平常一样点发送,或继续补充修改。 + +识别文本只进输入框,由你确认后才发送——可以边说边改。 + +## 说明 + +- **语言**:跟随系统语音识别语言设置。 +- **追加合并**:识别文本与已有内容以空格分隔拼接,不会覆盖你打好的字;可连续多次听写叠加。 +- **设备支持**:依赖系统语音识别服务(`SpeechRecognizer`),少数 ROM 未预装时点击会提示「当前设备不支持」。 +- **权限**:麦克风权限(RECORD_AUDIO)仅用于语音听写,拒绝后功能提示但其他功能不受影响。 + +## 常见问题 + +::: tip 点了没反应或提示未听清 +确认麦克风权限已授予;周围环境太吵或语速过快可能导致识别不到,再试一次。 +::: + +::: tip 识别结果不准 +可以在停止后手动修改输入框文本再发送;专有名词、代码符号类内容建议打字输入,识别准确率更高。 +:::