diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..821d8a6
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,18 @@
+* text=auto
+
+.gitattributes text eol=lf
+*.md text eol=lf
+*.kt text eol=lf
+*.java text eol=lf
+*.xml text eol=lf
+*.kts text eol=lf
+*.ps1 text eol=crlf
+*.bat text eol=crlf
+
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.apk binary
+*.jar binary
+*.ttf binary
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..c5f3f6b
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "java.configuration.updateBuildConfiguration": "interactive"
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..bd1fa09
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,18 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "Build Debug APK",
+ "type": "shell",
+ "command": ".\\build-debug.bat",
+ "options": {
+ "cwd": "${workspaceFolder}"
+ },
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "problemMatcher": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..5db7743
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,178 @@
+# ApkClaw Project Guide
+
+## Project Introduction
+
+ApkClaw is an Android-native AI automation application. It accepts natural-language instructions from messaging channels such as DingTalk, Feishu, QQ, Discord, Telegram, and WeChat, then uses an LLM-driven agent plus Android accessibility APIs to operate the device.
+
+The current project goal is no longer just to prove that an Android agent can click around. The practical goal is to make the app usable in repeated real workflows: configurable, debuggable, recoverable after interruption, and able to preserve the right amount of local memory without letting stale context corrupt new tasks.
+
+## Core Architecture
+
+The current execution chain is:
+
+1. `ChannelManager` receives user messages from external channels.
+2. `TaskOrchestrator` serializes task execution and manages lifecycle transitions.
+3. `AgentService` / `DefaultAgentService` runs the observe-think-act loop.
+4. `ToolRegistry` dispatches tool calls to device-facing tool implementations.
+5. `ClawAccessibilityService` performs actual gestures, node traversal, key injection, and screenshots.
+6. `SessionMemoryManager` manages persisted session context, condensed memory, global memory, and global prompt injection.
+7. `AppViewModel` builds the effective `AgentConfig` from current user settings and pushes updates into the running agent service.
+
+## Current Product Position
+
+This project already supports:
+
+1. Local LLM configuration.
+2. Configurable maximum iteration count.
+3. Multi-session conversation and local memory persistence.
+4. Session-specific and global prompt injection.
+5. Floating pause / resume / follow-up interaction.
+6. Wait timing configuration with global scaling.
+7. Reproducible Windows debug builds via project scripts.
+
+The active work direction is incremental hardening rather than foundational rewrites.
+
+## Development Journey
+
+The recent development path can be understood as a sequence of practical bottlenecks that were fixed one by one.
+
+### Phase 1: Build Reproducibility
+
+The first major issue was that debug builds on Windows were fragile and environment-dependent. The project now includes repo-owned build scripts so that Gradle always runs with a valid Android Studio JBR instead of relying on ad hoc local shell commands.
+
+### Phase 2: Runtime Configuration Instead of Hard-Coding
+
+Early runtime behavior such as iteration count and wait suggestions was overly static. The project moved these settings into the application UI so the effective agent configuration can be changed without editing code every time.
+
+### Phase 3: Session and Memory Foundation
+
+The next bottleneck was continuity. Tasks could run, but there was no strong structure for preserving useful conversational state across runs. The project introduced:
+
+1. Session context.
+2. Condensed session memory.
+3. Global memory.
+4. Global prompt.
+5. Multi-session switching and editing.
+
+This phase also clarified the boundary between persisted session context and transient in-flight runtime state.
+
+### Phase 4: Pause and Mid-Task Correction
+
+Users needed a way to correct a running task without always killing it. The project added pause/follow-up behavior and later replaced the earlier activity-based pause UI with a floating overlay so the original device page can remain visible.
+
+### Phase 5: Wait Timing Strategy Cleanup
+
+Wait timing started as advice embedded in prompt text. That was not enough. It is now backed by stored settings, injected dynamically into the system prompt, and partially sanitized out of old session records so outdated timing advice does not keep leaking into future runs.
+
+### Phase 6: Long-Use UX Corrections
+
+Several issues that looked minor in isolation turned out to matter in repeated daily usage:
+
+1. Chat auto-scroll behavior.
+2. Button order and wording.
+3. Mixed Chinese/English strings.
+4. Pause overlay layout and feedback.
+5. Missing summary writes on cancellation.
+
+These were treated as functional usability issues, not just polish.
+
+## Main Problems and Solution Routes
+
+### Problem 1: Builds were not reliably reproducible
+
+Route:
+
+1. Detect JBR automatically.
+2. Pin Gradle to that runtime.
+3. Keep build entry points inside the repo.
+
+### Problem 2: Agent runtime behavior was too rigid
+
+Route:
+
+1. Move runtime knobs into settings.
+2. Regenerate `AgentConfig` dynamically.
+3. Push updates through `AppViewModel -> TaskOrchestrator -> AgentService`.
+
+### Problem 3: Session continuity and memory were underpowered
+
+Route:
+
+1. Persist session messages locally.
+2. Derive transcript and condensed memory.
+3. Separate session context from global memory and global prompt.
+4. Support edit, preview, delete, and session switching flows.
+
+### Problem 4: Old memory polluted new tasks
+
+Route:
+
+1. Keep current wait strategy in system prompt, not in historical notes.
+2. Sanitize injected session content before appending it to the task prompt.
+3. Treat historical timing guidance as stale and lower its authority.
+
+### Problem 5: Mid-task correction was too expensive
+
+Route:
+
+1. Support pause instead of only cancellation.
+2. Allow follow-up instructions to enqueue into the running agent.
+3. Distinguish pause-and-continue from stop-and-restart semantics.
+
+### Problem 6: Users needed clearer task feedback
+
+Route:
+
+1. Improve floating feedback and button wording.
+2. Write cancellation outcomes into condensed memory.
+3. Tighten chat and settings page behavior around repeated use.
+
+## Important Behavioral Decisions
+
+### Persisted Context vs Runtime State
+
+Persisted session context can be injected into future tasks. In-flight runtime state cannot. This is intentional. A new task should inherit saved conversation context, but it should not silently resume a half-finished tool chain from the middle.
+
+### New Task vs Follow-Up During a Running Task
+
+If a task is already running, follow-up instructions are enqueued into the current agent run. If the task is stopped first, the next message becomes a new task and only reads persisted context.
+
+### Home Reset Before New Task
+
+The current task startup path still resets device state with a Home press before running a new task. This improves predictability but can be revisited later if a more contextual start policy is needed.
+
+## Key Files
+
+Use these as primary anchors when changing behavior:
+
+1. `app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt`
+2. `app/src/main/java/com/apk/claw/android/AppViewModel.kt`
+3. `app/src/main/java/com/apk/claw/android/agent/AgentConfig.kt`
+4. `app/src/main/java/com/apk/claw/android/agent/DefaultAgentService.kt`
+5. `app/src/main/java/com/apk/claw/android/session/SessionMemoryManager.kt`
+6. `app/src/main/java/com/apk/claw/android/service/ClawAccessibilityService.java`
+7. `app/src/main/java/com/apk/claw/android/ui/settings/SessionChatActivity.kt`
+8. `app/src/main/java/com/apk/claw/android/ui/settings/WaitTimingSettingsActivity.kt`
+9. `app/src/main/java/com/apk/claw/android/floating/FloatingCircleManager.kt`
+10. `app/build.gradle.kts`
+
+## Working Principles for Future Changes
+
+1. Prefer changing root behavior instead of stacking more prompt wording on top of broken logic.
+2. Treat persisted memory contamination as a systems problem, not just a prompt-writing problem.
+3. Keep session features and memory features independently controllable.
+4. When a user reports a UX issue in chat, first trace the controlling lifecycle path before redesigning UI text.
+5. Preserve reproducible build entry points whenever Android build configuration changes.
+6. Validate changes with at least file-level diagnostics and preferably a full build.
+
+## Build and Verification
+
+Preferred local validation path:
+
+1. Use `build-debug.bat` on Windows.
+2. Confirm the generated APK name includes the expected version.
+3. For UI-only resource changes, still run at least one full Android build.
+
+## Current State Summary
+
+The project is beyond the prototype stage but still in active product hardening. The most important theme is reducing the gap between “the agent can do something once” and “the user can trust it repeatedly, interrupt it safely, and continue from sensible context”.
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
new file mode 100644
index 0000000..37629e9
--- /dev/null
+++ b/ATTRIBUTION.md
@@ -0,0 +1,26 @@
+# Attribution
+
+This repository is a modified, unofficial derivative of:
+
+- Project: ApkClaw
+- Upstream repository: https://github.com/apkclaw-team/ApkClaw
+- Upstream license: Apache License 2.0
+
+The original project provides the core Android automation concept, project structure, accessibility-service based execution layer, messaging channel integrations, LLM client architecture, tool system, and configuration server foundation.
+
+The current repository adds or changes, among other things:
+
+- Windows debug build scripts
+- version `0.0.5`
+- configurable max iterations
+- configurable wait timing
+- local session and memory management
+- global memory and global prompt controls
+- floating pause, resume, stop, and follow-up interactions
+- Feishu session/memory text commands
+- DeepSeek V4 thinking-mode compatibility
+- additional UI and documentation updates
+
+This repository is not the official ApkClaw project and is not endorsed by the upstream maintainers unless they explicitly say otherwise.
+
+Thank you to the ApkClaw authors and contributors for making the original project available under Apache-2.0.
diff --git a/README.md b/README.md
index 521c388..b5c4a58 100644
--- a/README.md
+++ b/README.md
@@ -1,287 +1,163 @@
-# ApkClaw
+# ApkClaw Personal Enhanced Edition
-[中文文档](README_CN.md)
+> 基于 [apkclaw-team/ApkClaw](https://github.com/apkclaw-team/ApkClaw) 的个人增强版。向原项目作者和社区致敬,感谢他们把 Android 端 AI 自动化这个方向做成了可以运行、可以学习、可以继续改造的开源项目。
-An AI-powered Android automation app that lets an LLM Agent control Android devices (phones) via natural language. Users send instructions through messaging channels (DingTalk, Feishu, QQ, Discord, Telegram), and the AI Agent autonomously executes device operations.
+## 下载 APK
-## Screenshots
+当前推荐版本:`v0.0.5`
-
-
-
-
-
-## Architecture Overview
-
-```
-┌────────────────────────────────────────────────────────────────────┐
-│ Messaging Channels │
-│ DingTalk │ Feishu │ QQ │ Discord │ Telegram | WeChat │
-└──────────────────────┬─────────────────────────────────────────────┘
- │ Incoming message
- ▼
- ┌─────────────────┐
- │ ChannelManager │ Message routing & dispatch
- └────────┬────────┘
- │
- ┌────────▼────────┐
- │ TaskOrchestrator │ Task lock & lifecycle mgmt
- └────────┬────────┘
- │
- ┌────────▼────────┐
- │ AgentService │ Agent loop
- │ │
- │ ┌────────────┐ │
- │ │ LLM Call │◄─┼── LangChain4j (OpenAI / Anthropic)
- │ └─────┬──────┘ │
- │ │ │
- │ ┌─────▼──────┐ │
- │ │ Tool Exec │◄─┼── ToolRegistry → ClawAccessibilityService
- │ └─────┬──────┘ │
- │ │ │
- │ Loop until │
- │ task complete │
- └────────┬────────┘
- │
- ▼
- Reply to user via channel
-```
-
-## Star History
-
-
-
-## Core Execution Flow
-
-1. **User** sends a natural language message through any connected channel
-2. **ChannelSetup** checks that the accessibility service is running
-3. **TaskOrchestrator** acquires the task lock (single-task model) and presses Home to reset device state
-4. **DefaultAgentService** enters the agent loop:
- - Builds system prompt with device context (brand, model, resolution, registered tools)
- - Calls LLM with tool definitions (via LangChain4j bridge)
- - Extracts tool calls from LLM response
- - Executes tools via **ToolRegistry** → **ClawAccessibilityService**
- - Feeds tool results back to LLM
- - Loops until the `finish` tool is called or max iterations (40) are reached
-5. **Result** is sent back to the user through the same channel
+## 这个仓库是什么
-## Agent System
+这是一个个人维护的 ApkClaw 衍生版本,核心能力仍来自原项目:
-### Agent Loop (`DefaultAgentService`)
+- Android AccessibilityService 驱动的手机自动化能力
+- 通过钉钉、飞书、QQ、Discord、Telegram、微信等渠道接收自然语言任务
+- 使用 LLM Agent 分析屏幕、调用工具、执行点击/滑动/输入/截图等操作
+- OpenAI-compatible 与 Anthropic 模型接入
+- 局域网配置页面、移动端设置页、前台服务和浮窗
-The agent follows an **Observe → Think → Act → Verify** protocol:
+这些基础能力和整体架构请优先阅读原项目文档:
-- **System Prompt**: Injects device info (brand, model, Android version, screen resolution), registered tool list, and safety constraints
-- **LLM Call Retry**: Up to 3 attempts with exponential backoff (1s → 2s → 4s); no retry on 401/403
-- **Loop Detection**: Maintains a 4-round sliding window of `(screenHash, toolCall)` fingerprints; if all identical, injects a system message forcing the agent to try a different approach
-- **Token Optimization**: Replaces historical `get_screen_info` results with placeholders to save tokens, keeping only the most recent one
-- **System Dialog Handling**: When `getRootInActiveWindow()` returns null (protected system dialog detected), takes a screenshot, sends it to the user, and aborts the task
+[https://github.com/apkclaw-team/ApkClaw](https://github.com/apkclaw-team/ApkClaw)
-### LLM Integration
+本仓库的 README 只重点说明和原项目不同的部分。
-Pluggable LLM backends via `LlmClientFactory`:
+## 和原项目的关系
-| Provider | Client Class | Model Builder |
-|----------|-------------|---------------|
-| OpenAI-compatible | `OpenAiLlmClient` | `OpenAiChatModel` / `OpenAiStreamingChatModel` |
-| Anthropic | `AnthropicLlmClient` | `AnthropicChatModel` / `AnthropicStreamingChatModel` |
+本仓库不是 ApkClaw 官方仓库,也不代表原项目团队立场。它是在 Apache-2.0 许可下,对原项目进行二次修改和个人维护的版本。
-Both streaming and non-streaming modes are supported. The HTTP layer uses a custom `OkHttpClientBuilderAdapter` (OkHttp-based) instead of JDK HttpClient for Android compatibility.
+关系说明:
-**Configuration** (`AgentConfig`):
-- `apiKey`: From local settings
-- `baseUrl`: LLM endpoint (default: `https://api.openai.com/v1`)
-- `modelName`: User-selectable
-- `provider`: `OPENAI` (default) or `ANTHROPIC`
-- `temperature`: 0.1 (deterministic output)
-- `maxIterations`: 40
-- `streaming`: Configurable (default: off)
+- 原项目:`apkclaw-team/ApkClaw`
+- 原项目地址:[https://github.com/apkclaw-team/ApkClaw](https://github.com/apkclaw-team/ApkClaw)
+- 原项目许可证:Apache License 2.0
+- 本仓库性质:个人增强版 / 衍生版本 / 非官方分支
+- 本仓库保留原始 `LICENSE`,并在文档中明确说明修改内容
-### LangChain4j Bridge
+如果你只是想了解 ApkClaw 的基础设计、支持渠道、工具系统和基本使用方式,请先看原项目;如果你关心更长时间使用中的会话、记忆、暂停补充、等待时间调参和 DeepSeek V4 思考兼容,再看本仓库。
-`LangChain4jToolBridge` converts custom `BaseTool` abstractions into LangChain4j's `ToolSpecification` format, mapping parameter types (`string`, `integer`, `number`, `boolean`) to JSON Schema.
+## 主要改动
-## Tool System
+相对上游 ApkClaw,本仓库目前重点增强了这些方向:
-Tools are registered in `ToolRegistry` by device type:
+| 方向 | 本仓库新增或调整 |
+| --- | --- |
+| 版本与构建 | 版本升级到 `0.0.5`,新增 Windows debug 构建脚本 `build-debug.bat` / `build-debug.ps1` |
+| 运行参数 | `maxIterations` 可在 App 内配置,不再固定写死 |
+| 等待策略 | 新增等待时间设置页,可配置点击、打开应用、输入后的推荐等待时间和全局缩放 |
+| 会话与记忆 | 新增本地 Session & Memory,支持会话上下文、全局记忆、全局提示词、会话切换和编辑 |
+| 中途纠偏 | 新增浮窗暂停、继续、停止、补充指令能力;运行中发送普通补充会并入当前任务,发送类似“停止任务”的消息会立刻中断任务 |
+| 飞书指令 | 飞书支持文本命令控制会话/记忆开关,以及新建、切换、查看当前会话 |
+| 模型兼容 | 增加 DeepSeek V4 thinking 模式兼容,支持把 reasoning content 正确带回后续请求 |
+| UI 体验 | 设置页、会话页、浮窗交互和多处中文文案做了更适合长期使用的调整 |
-### Common Tools (All Devices)
-| Tool | Description |
-|------|-------------|
-| `get_screen_info` | Get UI hierarchy tree for AI to analyze the current screen |
-| `find_node_info` | Find elements by text or resource ID |
-| `take_screenshot` | Capture current screen as PNG |
-| `input_text` | Input text into the focused field |
-| `open_app` | Open an app by name |
-| `get_installed_apps` | List installed applications |
-| `press_back` / `press_home` | Navigate back / Go to home screen |
-| `open_recent_apps` | Open recent apps |
-| `expand_notifications` / `collapse_notifications` | Expand / Collapse notification shade |
-| `lock_screen` | Lock the screen |
-| `wait` | Wait for a specified duration |
-| `repeat_actions` | Repeat a set of actions |
-| `send_file` | Send a file to the user via channel |
-| `finish` | Complete the task and return a summary |
+## 截图
-### Phone-Specific Tools
-| Tool | Description |
-|------|-------------|
-| `tap` | Tap at coordinates (x, y) |
-| `long_press` | Long press at coordinates |
-| `swipe` | Swipe from point A to point B |
-| `click_by_text` | Click an element by visible text |
-| `click_by_id` | Click an element by resource ID |
-| `search_app_in_store` | Search for an app in the app store |
+
+
+
+
+
-Each tool extends `BaseTool`, implements `execute(Map): ToolResult`, and provides bilingual (Chinese/English) descriptions with typed parameter declarations.
+
+
+
+
+
+
-## Channel System
+## 我为什么改这些
-| Channel | Protocol | Required Credentials |
-|---------|----------|---------------------|
-| DingTalk | App Stream Client | Client ID + Client Secret |
-| Feishu | OAPI SDK | App ID + App Secret |
-| QQ | QQ Bot API | App ID + App Secret |
-| Discord | Gateway WebSocket + REST | Bot Token |
-| Telegram | Bot HTTP API | Bot Token |
+原项目已经证明了“LLM 可以通过 Android 无障碍服务操作手机”。这个仓库更关注下一步:让它在真实、重复、容易被打断的使用场景里更顺手。
-Channel credentials can be configured via the in-app settings page or the LAN HTTP server (`http://:9527`).
+具体来说,这一版主要解决:
-## Accessibility Service
+- 每次构建都依赖本机临时命令,发布前不够可复现
+- Agent 最大迭代次数、等待时间等参数不方便调试
+- 长任务之后缺少稳定的本地会话承接
+- 历史记忆可能把过期的等待策略带进新任务
+- 任务跑到一半时,用户只能停止重来,缺少“暂停后补一句”的交互
+- DeepSeek V4 thinking 模式在工具调用链路里需要保留 reasoning content
-`ClawAccessibilityService` (Java) is the core device interaction layer:
-- **Gestures**: Tap, swipe, long press via `dispatchGesture()`
-- **Node Traversal**: UI hierarchy tree via `getRootInActiveWindow()`
-- **Key Injection**: Home, Back, Recents via `performGlobalAction()`
-- **Screenshot**: `takeScreenshot()` (requires Android 11+)
-
-**Known Limitation**: Protected system windows (e.g., `com.android.permissioncontroller` permission dialogs) block both node tree access and gesture injection (`filterTouchesWhenObscured`). The agent detects this, takes a screenshot, and notifies the user to handle it manually.
+## 快速开始
-## LAN Configuration Server
+1. 从本仓库 GitHub Releases 下载 APK。
+2. 安装到 Android 9+ 设备或模拟器。
+3. 在 App 首页开启必要权限:无障碍服务、通知、悬浮窗、电池白名单、文件访问。
+4. 在设置页填写 LLM 配置:`API Key`、`Base URL`、`Model Name`。
+5. 配置至少一个消息渠道,或使用局域网配置页面辅助填写。
+6. 通过已配置渠道发送自然语言任务。
-A NanoHTTPD-based HTTP server runs on port 9527 for convenient configuration from a PC browser:
+任务运行中可以继续发送补充指令,普通补充会进入当前任务上下文;如果发送“停止任务”“取消任务”“终止任务”等明确停止含义的消息,当前任务会被立刻中断。
-| Endpoint | Method | Purpose |
-|----------|--------|---------|
-| `/` | GET | Configuration web page |
-| `/api/channels` | GET/POST | Read/update channel credentials |
-| `/api/llm` | GET/POST | Read/update LLM configuration |
+提示:这个应用会使用无障碍服务执行点击、滑动、输入、打开应用、卸载应用等操作。请只在你拥有或被授权操作的设备和账号上使用,并在涉及安装、卸载、删除、支付、转账等高影响操作时保持人工确认。
-Secrets are masked (only last 4 characters shown) when retrieved via GET. Debug builds additionally expose `/debug.html` with a tool execution console.
+## 构建
-## Project Structure
+Windows 下推荐使用仓库自带脚本:
+```powershell
+.\build-debug.bat
```
-app/src/main/java/com/apk/claw/android/
-├── agent/ # Agent loop, config, callbacks
-│ ├── langchain/ # LangChain4j bridge & OkHttp adapter
-│ └── llm/ # LLM clients (OpenAI, Anthropic)
-├── base/ # BaseActivity (screen density adaptation)
-├── channel/ # Messaging channel handlers
-│ ├── dingtalk/
-│ ├── feishu/
-│ ├── qqbot/
-│ ├── discord/
-│ └── telegram/
-├── floating/ # Floating button UI manager
-├── server/ # LAN config & debug HTTP server
-├── service/ # Accessibility, foreground, keep-alive services
-├── tool/ # Tool abstraction layer & registry
-│ └── impl/ # Tool implementations (common/phone/TV)
-├── ui/ # Activities (splash, home, guide, settings)
-├── utils/ # KVUtils, XLog, formatting utilities
-└── widget/ # Custom UI components
-```
-
-## Build & Run
-
-### Requirements
-- Java 17+
-- Android Studio (Ladybug or later recommended)
-- Android SDK 36 (compile/target), min SDK 28
+脚本会依次查找:
-### Build
+- `APKCLAW_JBR`
+- `ANDROID_STUDIO_JBR`
+- `JAVA_HOME`
+- 常见 Android Studio JBR 路径
-```bash
-# Clone the repository
-git clone https://github.com/apkclaw-team/ApkClaw.git
-cd ApkClaw
+它会选择同时包含 `bin/java.exe` 和 `bin/jlink.exe` 的 JBR,并把 Gradle 固定到该运行时,减少不同机器上的构建差异。
-# Debug build
-./gradlew assembleDebug
+也可以手动执行:
-# Release build
-./gradlew assembleRelease
+```powershell
+.\gradlew.bat "-Dorg.gradle.java.home=E:\2.work\Android\Android Studio\jbr" assembleDebug
```
-### Setup
+## DeepSeek V4 说明
-1. **Install** the APK on your Android device (Android 9+)
-2. **Grant permissions** on the home screen — enable all required permissions (Accessibility Service, Notification, System Window, Battery Whitelist, File Access)
-3. **Configure LLM** — go to Settings > LLM Config, fill in:
- - **API Key**: Your OpenAI or Anthropic API key
- - **Base URL**: LLM endpoint (default: `https://api.openai.com/v1`, change it if using a custom provider)
- - **Model Name**: e.g. `gpt-4o`, `claude-sonnet-4-20250514`
-4. **Configure a channel** — go to Settings, pick at least one messaging channel (DingTalk / Feishu / QQ / Discord / Telegram), fill in the bot credentials
-5. **Send a message** via the configured channel to start controlling your device
+DeepSeek V4 thinking 模式在工具调用场景下要求后续请求带回上一轮返回的 reasoning content。本仓库在 OpenAI-compatible 客户端中为 `deepseek-v4-pro` / `deepseek-v4-flash` 开启 thinking 回传,并在 Agent 历史消息里保留对应字段。
-> **Tip**: You can also configure LLM and channel credentials from a PC browser via LAN Config. Enable it in Settings, then visit `http://:9527` on your PC.
+如果你使用 DeepSeek V4,请确认模型名使用服务端支持的标准写法,例如:
-## Key Dependencies
+```text
+deepseek-v4-pro
+deepseek-v4-flash
+```
-**AI / Agent**
+## 风险与合规说明
-| Dependency | Version | Purpose |
-|------------|---------|---------|
-| [LangChain4j](https://github.com/langchain4j/langchain4j) | 1.12.2 | Agent orchestration, tool definitions, LLM integration |
+这个项目属于 Android 自动化和 AI Agent 工具。请谨慎使用:
-**Messaging Channels**
+- 不要用于未授权设备、账号或应用场景。
+- 不要用于刷量、骚扰、垃圾信息、绕过平台规则或其他违规用途。
+- 使用第三方消息平台、模型服务、应用商店和系统镜像时,请遵守对应服务条款。
+- LLM 可能误判屏幕内容或执行错误操作,重要操作前建议人工确认。
+- 本版本具备在用户明确要求卸载应用时执行卸载的能力;在必要的任务路径中也可能进入卸载流程。请谨慎下达相关指令,避免误删仍需使用的应用或数据。
+- 本仓库不声称与原项目团队、任何模型厂商、手机厂商或消息平台存在官方合作关系。
-| Dependency | Version | Purpose |
-|------------|---------|---------|
-| [DingTalk Stream Client](https://github.com/open-dingtalk/dingtalk-stream-sdk-java) | 1.3.12 | DingTalk channel |
-| [Feishu OAPI SDK](https://github.com/larksuite/oapi-sdk-java) | 2.5.3 | Feishu / Lark channel |
+本项目按 Apache-2.0 许可和开源软件常见的 “AS IS” 方式提供,不提供任何明示或暗示担保。
-**Networking**
+## 发布前清单
-| Dependency | Version | Purpose |
-|------------|---------|---------|
-| [OkHttp](https://github.com/square/okhttp) | 4.12.0 | HTTP client for LLM calls |
-| [Retrofit](https://github.com/square/retrofit) | 2.11.0 | REST API client |
-| [NanoHTTPD](https://github.com/NanoHttpd/nanohttpd) | 2.3.1 | LAN config & debug HTTP server |
+发布到 GitHub 前建议确认:
-**Storage & Utilities**
+- `LICENSE` 保留 Apache-2.0 原文。
+- README 明确说明本仓库和原项目的关系。
+- Release 页面上传 APK,不把 APK 直接提交进源码历史。
+- Release 说明里附上 APK 文件名、版本号、SHA256。
+- 不提交 `local.properties`、签名文件、API Key、机器人 Token、Android Studio 私有配置。
+- 如未来修改包名、图标、应用名,应继续保留原项目致谢。
-| Dependency | Version | Purpose |
-|------------|---------|---------|
-| [MMKV](https://github.com/Tencent/MMKV) | 2.3.0 | High-performance local key-value storage |
-| [Gson](https://github.com/google/gson) | 2.13.2 | JSON serialization |
-| [ZXing](https://github.com/zxing/zxing) | 3.5.3 | QR code generation |
-| [UtilCode](https://github.com/Blankj/AndroidUtilCode) | 1.31.1 | Android utility functions |
+## 致谢
-**UI**
+感谢 [apkclaw-team/ApkClaw](https://github.com/apkclaw-team/ApkClaw) 的原始工作。本仓库的大部分基础能力、项目结构和方向来自 ApkClaw;这里的改动是在它之上做的长期使用体验、会话记忆、构建和模型兼容增强。
-| Dependency | Version | Purpose |
-|------------|---------|---------|
-| [Glide](https://github.com/bumptech/glide) | 5.0.5 | Image loading |
-| [EasyFloat](https://github.com/princekin-f/EasyFloat) | 2.0.4 | Floating window |
-| [MultiType](https://github.com/drakeet/MultiType) | 4.3.0 | RecyclerView multi-type adapter |
+也感谢 Android、LangChain4j、OkHttp、MMKV、NanoHTTPD 以及各消息平台 SDK 的开源生态。
## License
-```
-Copyright 2026 ApkClaw
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
+本仓库基于 Apache License 2.0 发布。详见 [LICENSE](LICENSE)。
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-```
+原项目 ApkClaw 同样采用 Apache License 2.0。本仓库保留原许可文本,并在本文档中声明了二次修改关系和主要改动。
diff --git a/README_CN.md b/README_CN.md
index 6318afe..4853c5c 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -1,287 +1,7 @@
-# ApkClaw
+# 中文说明
-[English](README.md)
+本仓库的主 README 已改为中文,请直接阅读:
-AI 驱动的 Android 自动化应用,通过自然语言让 LLM Agent 操控 Android 设备(手机)。用户通过消息渠道(钉钉、飞书、QQ、Discord、Telegram)发送指令,AI Agent 理解意图后自主执行设备操作。
+[README.md](README.md)
-## 截图
-
-
-
-
-
-
-## 架构概览
-
-```
-┌───────────────────────────────────────────────────────────────┐
-│ 消息渠道 │
-│ 钉钉 │ 飞书 │ QQ │ Discord │ Telegram │ 微信 │
-└──────────────────────┬────────────────────────────────────────┘
- │ 收到消息
- ▼
- ┌─────────────────┐
- │ ChannelManager │ 消息路由与分发
- └────────┬────────┘
- │
- ┌────────▼────────┐
- │ TaskOrchestrator │ 任务锁、生命周期管理
- └────────┬────────┘
- │
- ┌────────▼────────┐
- │ AgentService │ Agent 循环
- │ │
- │ ┌────────────┐ │
- │ │ LLM 调用 │◄─┼── LangChain4j (OpenAI / Anthropic)
- │ └─────┬──────┘ │
- │ │ │
- │ ┌─────▼──────┐ │
- │ │ 工具执行 │◄─┼── ToolRegistry → ClawAccessibilityService
- │ └─────┬──────┘ │
- │ │ │
- │ 循环直到 │
- │ 任务完成 │
- └────────┬────────┘
- │
- ▼
- 通过渠道回复用户
-```
-
-## Star History
-
-
-
-## 核心执行流程
-
-1. **用户**通过任意已连接的渠道发送自然语言消息
-2. **ChannelSetup** 校验无障碍服务是否已开启
-3. **TaskOrchestrator** 获取任务锁(单任务模型),按 Home 键重置设备状态
-4. **DefaultAgentService** 进入 Agent 循环:
- - 构建系统提示词,注入设备上下文(品牌、型号、分辨率、已注册工具)
- - 调用 LLM 并传入工具定义(通过 LangChain4j 桥接层)
- - 从 LLM 响应中提取工具调用
- - 通过 **ToolRegistry** → **ClawAccessibilityService** 执行工具
- - 将工具执行结果反馈给 LLM
- - 循环直到调用 `finish` 工具或达到最大迭代次数(40 轮)
-5. **结果**通过同一渠道回复给用户
-
-## Agent 系统
-
-### Agent 循环 (`DefaultAgentService`)
-
-Agent 遵循 **观察 → 思考 → 行动 → 验证** 协议:
-
-- **系统提示词**:注入设备信息(品牌、型号、Android 版本、屏幕分辨率)、已注册工具列表和安全约束
-- **LLM 调用重试**:最多 3 次尝试,指数退避(1s → 2s → 4s),遇到 401/403 时不重试
-- **死循环检测**:维护 4 轮滑动窗口 `(screenHash, toolCall)` 指纹,若全部相同则注入系统消息强制 Agent 换一种方式
-- **Token 优化**:将历史 `get_screen_info` 结果替换为占位符以节省 token,仅保留最近一次
-- **系统弹窗处理**:当 `getRootInActiveWindow()` 返回 null(检测到受保护的系统弹窗)时,截图发送给用户并终止任务
-
-### LLM 集成
-
-通过 `LlmClientFactory` 实现可插拔的 LLM 后端:
-
-| 提供商 | 客户端类 | 模型构建器 |
-|--------|---------|-----------|
-| OpenAI 兼容 | `OpenAiLlmClient` | `OpenAiChatModel` / `OpenAiStreamingChatModel` |
-| Anthropic | `AnthropicLlmClient` | `AnthropicChatModel` / `AnthropicStreamingChatModel` |
-
-均支持流式和非流式模式。HTTP 层使用自定义的 `OkHttpClientBuilderAdapter`(基于 OkHttp)替代 JDK HttpClient 以兼容 Android。
-
-**配置项** (`AgentConfig`):
-- `apiKey`:来自本地设置
-- `baseUrl`:LLM 端点(默认:`https://api.openai.com/v1`)
-- `modelName`:用户可选
-- `provider`:`OPENAI`(默认)或 `ANTHROPIC`
-- `temperature`:0.1(确定性输出)
-- `maxIterations`:40
-- `streaming`:可配置(默认关闭)
-
-### LangChain4j 桥接层
-
-`LangChain4jToolBridge` 将自定义的 `BaseTool` 抽象转换为 LangChain4j 的 `ToolSpecification` 格式,将参数类型(`string`、`integer`、`number`、`boolean`)映射为 JSON Schema。
-
-## 工具系统
-
-工具按设备类型在 `ToolRegistry` 中注册:
-
-### 通用工具(所有设备)
-| 工具 | 说明 |
-|------|------|
-| `get_screen_info` | 获取 UI 层级树,供 AI 分析当前界面 |
-| `find_node_info` | 通过文本或资源 ID 查找元素 |
-| `take_screenshot` | 截取当前屏幕为 PNG |
-| `input_text` | 向焦点输入框输入文本 |
-| `open_app` | 通过名称打开应用 |
-| `get_installed_apps` | 获取已安装应用列表 |
-| `press_back` / `press_home` | 返回 / 回到桌面 |
-| `open_recent_apps` | 打开最近任务 |
-| `expand_notifications` / `collapse_notifications` | 展开 / 收起通知栏 |
-| `lock_screen` | 锁屏 |
-| `wait` | 等待指定时长 |
-| `repeat_actions` | 重复执行一组操作 |
-| `send_file` | 通过渠道发送文件给用户 |
-| `finish` | 完成任务并返回总结 |
-
-### 手机专属工具
-| 工具 | 说明 |
-|------|------|
-| `tap` | 点击指定坐标 (x, y) |
-| `long_press` | 长按指定坐标 |
-| `swipe` | 从 A 点滑动到 B 点 |
-| `click_by_text` | 通过可见文字点击元素 |
-| `click_by_id` | 通过资源 ID 点击元素 |
-| `search_app_in_store` | 在应用商店中搜索应用 |
-
-每个工具继承 `BaseTool`,实现 `execute(Map): ToolResult`,提供中英文双语描述和类型化参数声明。
-
-## 渠道系统
-
-| 渠道 | 协议 | 所需凭证 |
-|------|------|----------|
-| 钉钉 | App Stream Client | Client ID + Client Secret |
-| 飞书 | OAPI SDK | App ID + App Secret |
-| QQ | QQ Bot API | App ID + App Secret |
-| Discord | Gateway WebSocket + REST | Bot Token |
-| Telegram | Bot HTTP API | Bot Token |
-
-渠道凭证可通过应用内设置页或局域网 HTTP 服务器(`http://<设备IP>:9527`)配置。
-
-## 无障碍服务
-
-`ClawAccessibilityService`(Java)是设备交互的核心层:
-- **手势操作**:通过 `dispatchGesture()` 实现点击、滑动、长按
-- **节点遍历**:通过 `getRootInActiveWindow()` 获取 UI 层级树
-- **按键注入**:通过 `performGlobalAction()` 实现 Home、返回、最近任务
-- **截屏**:`takeScreenshot()`(需 Android 11+)
-
-**已知限制**:系统保护窗口(如 `com.android.permissioncontroller` 的权限弹窗)会同时阻止节点树读取和手势注入(`filterTouchesWhenObscured` 机制)。Agent 检测到此情况后会截图通知用户手动处理。
-
-## 局域网配置服务器
-
-基于 NanoHTTPD 的 HTTP 服务器运行在端口 9527,方便通过 PC 浏览器配置设备:
-
-| 端点 | 方法 | 用途 |
-|------|------|------|
-| `/` | GET | 配置页面 |
-| `/api/channels` | GET/POST | 读取/更新渠道凭证 |
-| `/api/llm` | GET/POST | 读取/更新 LLM 配置 |
-
-通过 GET 获取时,敏感信息会做脱敏处理(仅显示末尾 4 位字符)。Debug 构建额外提供 `/debug.html` 工具调试控制台。
-
-## 项目结构
-
-```
-app/src/main/java/com/apk/claw/android/
-├── agent/ # Agent 循环、配置、回调
-│ ├── langchain/ # LangChain4j 桥接层 & OkHttp 适配器
-│ └── llm/ # LLM 客户端 (OpenAI, Anthropic)
-├── base/ # BaseActivity(屏幕密度适配)
-├── channel/ # 消息渠道处理器
-│ ├── dingtalk/
-│ ├── feishu/
-│ ├── qqbot/
-│ ├── discord/
-│ └── telegram/
-├── floating/ # 悬浮球 UI 管理
-├── server/ # 局域网配置 & 调试 HTTP 服务器
-├── service/ # 无障碍服务、前台服务、保活服务
-├── tool/ # 工具抽象层 & 注册中心
-│ └── impl/ # 工具实现 (通用/手机/电视)
-├── ui/ # Activity(启动页、首页、引导页、设置)
-├── utils/ # KVUtils, XLog, 格式化工具
-└── widget/ # 自定义 UI 组件
-```
-
-## 构建与运行
-
-### 环境要求
-
-- Java 17+
-- Android Studio(建议 Ladybug 或更高版本)
-- Android SDK 36(编译/目标),最低 SDK 28
-
-### 编译
-
-```bash
-# 克隆仓库
-git clone https://github.com/apkclaw-team/ApkClaw.git
-cd ApkClaw
-
-# Debug 构建
-./gradlew assembleDebug
-
-# Release 构建
-./gradlew assembleRelease
-```
-
-### 配置与使用
-
-1. **安装** APK 到 Android 设备(Android 9+)
-2. **授权** — 在首页依次开启所有必要权限(无障碍服务、通知权限、悬浮窗、电池白名单、文件访问)
-3. **配置 LLM** — 进入 设置 > LLM Config,填写:
- - **API Key**:你的 OpenAI 或 Anthropic API Key
- - **Base URL**:LLM 接口地址(默认 `https://api.openai.com/v1`,使用第三方服务商请修改)
- - **Model Name**:例如 `gpt-4o`、`claude-sonnet-4-20250514`
-4. **配置渠道** — 进入设置,选择至少一个消息渠道(钉钉 / 飞书 / QQ / Discord / Telegram),填写机器人凭证
-5. **发送消息** — 通过已配置的渠道发送消息,即可开始控制设备
-
-> **提示**:你也可以通过局域网在 PC 浏览器上配置。在设置中开启 LAN Config,然后在 PC 上访问 `http://<设备IP>:9527`。
-
-## 主要依赖
-
-**AI / Agent**
-
-| 依赖 | 版本 | 用途 |
-|------|------|------|
-| [LangChain4j](https://github.com/langchain4j/langchain4j) | 1.12.2 | Agent 编排、工具定义、LLM 集成 |
-
-**消息渠道**
-
-| 依赖 | 版本 | 用途 |
-|------|------|------|
-| [DingTalk Stream Client](https://github.com/open-dingtalk/dingtalk-stream-sdk-java) | 1.3.12 | 钉钉渠道 |
-| [Feishu OAPI SDK](https://github.com/larksuite/oapi-sdk-java) | 2.5.3 | 飞书渠道 |
-
-**网络**
-
-| 依赖 | 版本 | 用途 |
-|------|------|------|
-| [OkHttp](https://github.com/square/okhttp) | 4.12.0 | HTTP 客户端(LLM 调用) |
-| [Retrofit](https://github.com/square/retrofit) | 2.11.0 | REST API 客户端 |
-| [NanoHTTPD](https://github.com/NanoHttpd/nanohttpd) | 2.3.1 | 局域网配置 & 调试 HTTP 服务器 |
-
-**存储 & 工具**
-
-| 依赖 | 版本 | 用途 |
-|------|------|------|
-| [MMKV](https://github.com/Tencent/MMKV) | 2.3.0 | 高性能本地键值存储 |
-| [Gson](https://github.com/google/gson) | 2.13.2 | JSON 序列化 |
-| [ZXing](https://github.com/zxing/zxing) | 3.5.3 | 二维码生成 |
-| [UtilCode](https://github.com/Blankj/AndroidUtilCode) | 1.31.1 | Android 工具函数库 |
-
-**UI**
-
-| 依赖 | 版本 | 用途 |
-|------|------|------|
-| [Glide](https://github.com/bumptech/glide) | 5.0.5 | 图片加载 |
-| [EasyFloat](https://github.com/princekin-f/EasyFloat) | 2.0.4 | 悬浮窗 |
-| [MultiType](https://github.com/drakeet/MultiType) | 4.3.0 | RecyclerView 多类型适配器 |
-
-## License
-
-```
-Copyright 2026 ApkClaw
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-```
+This repository uses the Chinese README as the primary project introduction.
diff --git a/Screenshots/floating-pause-followup.png b/Screenshots/floating-pause-followup.png
new file mode 100644
index 0000000..f4afe4f
Binary files /dev/null and b/Screenshots/floating-pause-followup.png differ
diff --git a/Screenshots/session-chat.jpg b/Screenshots/session-chat.jpg
new file mode 100644
index 0000000..6543f9f
Binary files /dev/null and b/Screenshots/session-chat.jpg differ
diff --git a/Screenshots/session-condensed-memory.jpg b/Screenshots/session-condensed-memory.jpg
new file mode 100644
index 0000000..1f741e9
Binary files /dev/null and b/Screenshots/session-condensed-memory.jpg differ
diff --git a/Screenshots/session-memory.jpg b/Screenshots/session-memory.jpg
new file mode 100644
index 0000000..a1faf01
Binary files /dev/null and b/Screenshots/session-memory.jpg differ
diff --git a/Screenshots/settings-new.jpg b/Screenshots/settings-new.jpg
new file mode 100644
index 0000000..6898e73
Binary files /dev/null and b/Screenshots/settings-new.jpg differ
diff --git a/Screenshots/wait-timing.jpg b/Screenshots/wait-timing.jpg
new file mode 100644
index 0000000..76aeeb1
Binary files /dev/null and b/Screenshots/wait-timing.jpg differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7da38a4..6d104e8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -24,7 +24,9 @@ android {
val props = Properties().apply {
rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) }
}
- storeFile = file(props.getProperty("KEYSTORE_FILE", ""))
+ props.getProperty("KEYSTORE_FILE", "").takeIf { it.isNotBlank() }?.let {
+ storeFile = file(it)
+ }
storePassword = props.getProperty("KEYSTORE_PASSWORD", "")
keyAlias = props.getProperty("KEY_ALIAS", "")
keyPassword = props.getProperty("KEY_PASSWORD", "")
@@ -35,8 +37,8 @@ android {
applicationId = "com.apk.claw.android"
minSdk = 28
targetSdk = 36
- versionCode = 2
- versionName = "0.0.2"
+ versionCode = 5
+ versionName = "0.0.5"
buildConfigField("String", "VERSION_INFO", getVersionGit())
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 76ff1a5..3dfe365 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -68,6 +68,33 @@
android:name=".ui.settings.LlmConfigActivity"
android:exported="false"
android:screenOrientation="portrait" />
+
+
+
+
+
+
submitFloatingFollowUp(message) },
+ onStop = { cancelCurrentTask() }
+ )
+ }
+
/**
* 将应用带回前台
*/
@@ -137,11 +156,25 @@ class AppViewModel : ViewModel() {
fun isTaskRunning(): Boolean = taskOrchestrator.isTaskRunning()
+ fun getRunningSessionId(): String? = taskOrchestrator.getRunningSessionId()
+
+ fun isTaskPaused(): Boolean = taskOrchestrator.isTaskPaused()
+
fun cancelCurrentTask() = taskOrchestrator.cancelCurrentTask()
+ fun pauseCurrentTask(): Boolean = taskOrchestrator.pauseCurrentTask()
+
+ fun resumeCurrentTask(): Boolean = taskOrchestrator.resumeCurrentTask()
+
+ fun submitFloatingFollowUp(message: String): Boolean = taskOrchestrator.submitFloatingFollowUp(message)
+
fun startNewTask(channel: Channel, task: String, messageID: String) =
taskOrchestrator.startNewTask(channel, task, messageID)
+ fun sendLocalSessionMessage(sessionId: String, message: String): TaskOrchestrator.LocalMessageResult {
+ return taskOrchestrator.sendLocalSessionMessage(sessionId, message)
+ }
+
private fun trySendScreenshot(channel: Channel, filePath: String, messageID: String) {
try {
val file = java.io.File(filePath)
diff --git a/app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt b/app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt
index 8f150d8..439fac7 100644
--- a/app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt
+++ b/app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt
@@ -8,15 +8,11 @@ import com.apk.claw.android.channel.Channel
import com.apk.claw.android.channel.ChannelManager
import com.apk.claw.android.floating.FloatingCircleManager
import com.apk.claw.android.service.ClawAccessibilityService
+import com.apk.claw.android.session.SessionMemoryManager
import com.apk.claw.android.tool.ToolResult
import com.apk.claw.android.utils.XLog
+import java.util.UUID
-/**
- * 任务编排器,负责 Agent 生命周期管理、任务锁、任务执行与回调处理。
- *
- * @param agentConfigProvider 延迟获取最新 AgentConfig 的回调
- * @param onTaskFinished 每次任务结束(成功/失败/取消)后的通知,用于刷新用户信息等
- */
class TaskOrchestrator(
private val agentConfigProvider: () -> AgentConfig,
private val onTaskFinished: () -> Unit
@@ -26,17 +22,67 @@ class TaskOrchestrator(
private const val TAG = "TaskOrchestrator"
}
- private lateinit var agentService: AgentService
+ enum class LocalMessageResult {
+ STARTED,
+ QUEUED,
+ CANCELLED,
+ BUSY_OTHER_SESSION,
+ SERVICE_UNAVAILABLE,
+ EMPTY
+ }
+
+ private data class RunningTaskRecord(
+ val task: String,
+ val sessionId: String
+ )
+ private interface TaskOutput {
+ val channel: Channel?
+ fun sendText(content: String)
+ fun flush()
+ fun sendImage(imageBytes: ByteArray) {}
+ }
+
+ private class ChannelTaskOutput(
+ override val channel: Channel,
+ private val messageId: String
+ ) : TaskOutput {
+ override fun sendText(content: String) {
+ ChannelManager.sendMessage(channel, content, messageId)
+ }
+
+ override fun flush() {
+ ChannelManager.flushMessages(channel)
+ }
+
+ override fun sendImage(imageBytes: ByteArray) {
+ ChannelManager.sendImage(channel, imageBytes, messageId)
+ }
+ }
+
+ private object LocalTaskOutput : TaskOutput {
+ override val channel: Channel? = null
+ override fun sendText(content: String) = Unit
+ override fun flush() = Unit
+ }
+
+ private lateinit var agentService: AgentService
private val taskLock = Any()
+ private val runningTaskRecordLock = Any()
+
@Volatile
var inProgressTaskMessageId: String = ""
private set
+
@Volatile
var inProgressTaskChannel: Channel? = null
private set
- // ==================== Agent 生命周期 ====================
+ @Volatile
+ private var runningTaskRecord: RunningTaskRecord? = null
+
+ @Volatile
+ private var manualCancellationHandled = false
fun initAgent() {
agentService = AgentServiceFactory.create()
@@ -66,12 +112,15 @@ class TaskOrchestrator(
}
}
- // ==================== 任务锁 ====================
-
- /**
- * 原子地尝试获取任务锁。如果当前无任务在执行,则标记为占用并返回 true;否则返回 false。
- */
fun tryAcquireTask(messageId: String, channel: Channel): Boolean {
+ return acquireTask(messageId, channel)
+ }
+
+ private fun tryAcquireLocalTask(): Boolean {
+ return acquireTask("local-${UUID.randomUUID().toString().substring(0, 8)}", null)
+ }
+
+ private fun acquireTask(messageId: String, channel: Channel?): Boolean {
synchronized(taskLock) {
if (inProgressTaskMessageId.isNotEmpty()) return false
inProgressTaskMessageId = messageId
@@ -80,9 +129,6 @@ class TaskOrchestrator(
}
}
- /**
- * 释放任务锁,返回释放前的 (channel, messageId) 供调用方使用。
- */
private fun releaseTask(): Pair {
synchronized(taskLock) {
val ch = inProgressTaskChannel
@@ -99,13 +145,31 @@ class TaskOrchestrator(
}
}
- // ==================== 任务执行 ====================
+ fun getRunningSessionId(): String? {
+ synchronized(runningTaskRecordLock) {
+ return runningTaskRecord?.sessionId
+ }
+ }
+
+ fun isTaskPaused(): Boolean {
+ return ::agentService.isInitialized && agentService.isPaused()
+ }
fun cancelCurrentTask() {
if (!isTaskRunning()) return
+ manualCancellationHandled = true
+
+ val record = synchronized(runningTaskRecordLock) { runningTaskRecord }
+ if (record != null) {
+ SessionMemoryManager.appendSessionMessage(record.sessionId, SessionMemoryManager.ROLE_SYSTEM, "任务已取消:用户手动停止任务")
+ SessionMemoryManager.recordCancellation(record.sessionId, record.task, "用户手动停止任务", "")
+ }
+
if (::agentService.isInitialized) {
agentService.cancel()
}
+ clearRunningTaskRecord()
+
val (channel, messageId) = releaseTask()
if (channel != null && messageId.isNotEmpty()) {
ChannelManager.sendMessage(channel, ClawApplication.instance.getString(R.string.channel_msg_task_cancelled), messageId)
@@ -115,7 +179,121 @@ class TaskOrchestrator(
XLog.d(TAG, "Current task cancelled by user")
}
+ fun pauseCurrentTask(): Boolean {
+ if (!isTaskRunning() || !::agentService.isInitialized) return false
+ return agentService.pause()
+ }
+
+ fun resumeCurrentTask(): Boolean {
+ if (!isTaskRunning() || !::agentService.isInitialized) return false
+ return agentService.resume()
+ }
+
+ fun submitFloatingFollowUp(message: String): Boolean {
+ if (!isTaskRunning() || !::agentService.isInitialized || !agentService.isRunning()) return false
+ val normalized = message.trim()
+ val sessionId = getRunningSessionId() ?: return false
+ if (normalized.isNotBlank()) {
+ val accepted = agentService.enqueueUserInstruction(normalized)
+ if (!accepted) return false
+ SessionMemoryManager.appendSessionMessage(sessionId, SessionMemoryManager.ROLE_USER, normalized)
+ }
+ return agentService.resume()
+ }
+
+ fun enqueueOrHandleRunningTaskMessage(channel: Channel, message: String, messageId: String): Boolean {
+ if (!isTaskRunning()) return false
+
+ val normalized = message.trim()
+ if (normalized.isEmpty()) return true
+
+ if (isStopCommand(normalized)) {
+ cancelCurrentTask()
+ ChannelManager.sendMessage(channel, ClawApplication.instance.getString(R.string.channel_msg_task_cancelled_by_followup), messageId)
+ ChannelManager.flushMessages(channel)
+ return true
+ }
+
+ if (!::agentService.isInitialized || !agentService.isRunning()) {
+ ChannelManager.sendMessage(channel, ClawApplication.instance.getString(R.string.channel_msg_task_in_progress), messageId)
+ ChannelManager.flushMessages(channel)
+ return true
+ }
+
+ val accepted = agentService.enqueueUserInstruction(normalized)
+ if (accepted) {
+ getRunningSessionId()?.let { sessionId ->
+ SessionMemoryManager.appendSessionMessage(sessionId, SessionMemoryManager.ROLE_USER, normalized)
+ }
+ }
+ val replyRes = if (accepted) R.string.channel_msg_followup_queued else R.string.channel_msg_followup_queue_failed
+ ChannelManager.sendMessage(channel, ClawApplication.instance.getString(replyRes), messageId)
+ ChannelManager.flushMessages(channel)
+ return true
+ }
+
+ fun sendLocalSessionMessage(sessionId: String, message: String): LocalMessageResult {
+ val normalized = message.trim()
+ if (normalized.isEmpty()) return LocalMessageResult.EMPTY
+
+ SessionMemoryManager.setCurrentSession(sessionId)
+
+ if (isTaskRunning()) {
+ val runningSessionId = getRunningSessionId()
+ if (runningSessionId != sessionId) {
+ return LocalMessageResult.BUSY_OTHER_SESSION
+ }
+ if (isStopCommand(normalized)) {
+ cancelCurrentTask()
+ return LocalMessageResult.CANCELLED
+ }
+ if (!::agentService.isInitialized || !agentService.isRunning()) {
+ return LocalMessageResult.SERVICE_UNAVAILABLE
+ }
+ val accepted = agentService.enqueueUserInstruction(normalized)
+ if (accepted) {
+ SessionMemoryManager.appendSessionMessage(sessionId, SessionMemoryManager.ROLE_USER, normalized)
+ return LocalMessageResult.QUEUED
+ }
+ return LocalMessageResult.SERVICE_UNAVAILABLE
+ }
+
+ if (!tryAcquireLocalTask()) {
+ return LocalMessageResult.BUSY_OTHER_SESSION
+ }
+ startTask(sessionId, normalized, LocalTaskOutput, onServiceNotReady = {
+ releaseTask()
+ })
+ return LocalMessageResult.STARTED
+ }
+
+ private fun isStopCommand(message: String): Boolean {
+ val normalized = message.trim().lowercase()
+ return normalized in setOf(
+ "停止", "停止任务", "取消", "取消任务", "结束任务",
+ "stop", "stop task", "cancel", "cancel task"
+ )
+ }
+
fun startNewTask(channel: Channel, task: String, messageID: String) {
+ val sessionId = SessionMemoryManager.getCurrentSessionId()
+ startTask(
+ sessionId = sessionId,
+ task = task,
+ output = ChannelTaskOutput(channel, messageID),
+ onServiceNotReady = {
+ releaseTask()
+ ChannelManager.sendMessage(channel, ClawApplication.instance.getString(R.string.channel_msg_service_not_ready), messageID)
+ }
+ )
+ }
+
+ private fun startTask(
+ sessionId: String,
+ task: String,
+ output: TaskOutput,
+ onServiceNotReady: () -> Unit
+ ) {
if (!::agentService.isInitialized) {
XLog.e(TAG, "AgentService not initialized, attempting to initialize")
try {
@@ -123,31 +301,36 @@ class TaskOrchestrator(
agentService.initialize(agentConfigProvider())
} catch (e: Exception) {
XLog.e(TAG, "Failed to initialize AgentService", e)
- releaseTask()
- ChannelManager.sendMessage(channel, ClawApplication.instance.getString(R.string.channel_msg_service_not_ready), messageID)
+ onServiceNotReady()
return
}
}
ClawAccessibilityService.getInstance()?.pressHome()
- FloatingCircleManager.showTaskNotify(task, channel)
+ FloatingCircleManager.showTaskNotify(task, output.channel)
+ SessionMemoryManager.setCurrentSession(sessionId)
+ SessionMemoryManager.appendSessionMessage(sessionId, SessionMemoryManager.ROLE_USER, task)
+ beginRunningTaskRecord(task, sessionId)
+ manualCancellationHandled = false
- // 每轮消息聚合缓冲:thinking + toolResult 攒成一条,减少发送次数
+ val effectiveTask = SessionMemoryManager.buildTaskPrompt(sessionId, task)
+ var finishSummary = ""
val roundBuffer = StringBuilder()
fun flushRoundBuffer() {
if (roundBuffer.isNotEmpty()) {
- ChannelManager.sendMessage(channel, roundBuffer.toString().trim(), messageID)
+ val chunk = roundBuffer.toString().trim()
+ output.sendText(chunk)
+ SessionMemoryManager.appendSessionMessage(sessionId, SessionMemoryManager.ROLE_ASSISTANT, chunk)
roundBuffer.clear()
}
}
- agentService.executeTask(task, object : AgentCallback {
+ agentService.executeTask(effectiveTask, object : AgentCallback {
override fun onLoopStart(round: Int) {
- // 新一轮开始前,flush 上一轮积攒的消息
flushRoundBuffer()
- FloatingCircleManager.setRunningState(round, channel)
+ FloatingCircleManager.setRunningState(round, output.channel)
}
override fun onContent(round: Int, content: String) {
@@ -172,42 +355,65 @@ class TaskOrchestrator(
}
XLog.e(TAG, "onToolResult: $toolName, $status $data")
if (toolId == "finish" && (result.data?.isNotEmpty() ?: false)) {
- // finish 的结果单独发,不合并(这是最终回复)
+ finishSummary = result.data ?: ""
flushRoundBuffer()
- ChannelManager.sendMessage(channel, result.data, messageID)
+ output.sendText(result.data ?: "")
} else {
- // 追加到本轮缓冲
if (roundBuffer.isNotEmpty()) roundBuffer.append("\n")
- roundBuffer.append(
- app.getString(R.string.channel_msg_tool_execution, toolName + parameters, status)
- )
+ roundBuffer.append(app.getString(R.string.channel_msg_tool_execution, toolName + parameters, status))
}
}
override fun onComplete(round: Int, finalAnswer: String, totalTokens: Int) {
+ if (manualCancellationHandled) {
+ XLog.i(TAG, "Ignore onComplete after manual cancellation")
+ return
+ }
XLog.i(TAG, "onComplete: 轮数=$round, totalTokens=$totalTokens, answer=$finalAnswer")
+ val completionText = finishSummary.ifBlank { finalAnswer }
flushRoundBuffer()
+ if (completionText.isNotBlank()) {
+ SessionMemoryManager.appendSessionMessage(sessionId, SessionMemoryManager.ROLE_ASSISTANT, completionText)
+ }
+ SessionMemoryManager.recordSuccess(sessionId, task, "", completionText)
+ clearRunningTaskRecord()
releaseTask()
- ChannelManager.flushMessages(channel)
+ output.flush()
FloatingCircleManager.setSuccessState()
onTaskFinished()
}
override fun onError(round: Int, error: Exception, totalTokens: Int) {
+ if (manualCancellationHandled) {
+ XLog.i(TAG, "Ignore onError after manual cancellation")
+ return
+ }
XLog.e(TAG, "onError: ${error.message}, totalTokens=$totalTokens", error)
flushRoundBuffer()
+ val message = error.message ?: "未知错误"
+ SessionMemoryManager.appendSessionMessage(sessionId, SessionMemoryManager.ROLE_SYSTEM, "任务错误:$message")
+ SessionMemoryManager.recordFailure(sessionId, task, message, "")
+ clearRunningTaskRecord()
releaseTask()
- ChannelManager.sendMessage(channel, ClawApplication.instance.getString(R.string.channel_msg_task_error, error.message), messageID)
- ChannelManager.flushMessages(channel)
+ output.sendText(ClawApplication.instance.getString(R.string.channel_msg_task_error, error.message))
+ output.flush()
FloatingCircleManager.setErrorState()
onTaskFinished()
}
override fun onSystemDialogBlocked(round: Int, totalTokens: Int) {
+ if (manualCancellationHandled) {
+ XLog.i(TAG, "Ignore onSystemDialogBlocked after manual cancellation")
+ return
+ }
XLog.w(TAG, "onSystemDialogBlocked: round=$round, totalTokens=$totalTokens")
+ val blockedMsg = ClawApplication.instance.getString(R.string.channel_msg_system_dialog_blocked)
flushRoundBuffer()
+ SessionMemoryManager.appendSessionMessage(sessionId, SessionMemoryManager.ROLE_SYSTEM, blockedMsg)
+ SessionMemoryManager.recordFailure(sessionId, task, blockedMsg, "")
+ clearRunningTaskRecord()
releaseTask()
- ChannelManager.sendMessage(channel, ClawApplication.instance.getString(R.string.channel_msg_system_dialog_blocked), messageID)
+ output.sendText(blockedMsg)
try {
val service = ClawAccessibilityService.getInstance()
val bitmap = service?.takeScreenshot(5000)
@@ -215,14 +421,27 @@ class TaskOrchestrator(
val stream = java.io.ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 80, stream)
bitmap.recycle()
- ChannelManager.sendImage(channel, stream.toByteArray(), messageID)
+ output.sendImage(stream.toByteArray())
}
} catch (e: Exception) {
XLog.e(TAG, "Failed to send screenshot for system dialog", e)
}
+ output.flush()
FloatingCircleManager.setErrorState()
onTaskFinished()
}
})
}
+
+ private fun beginRunningTaskRecord(task: String, sessionId: String) {
+ synchronized(runningTaskRecordLock) {
+ runningTaskRecord = RunningTaskRecord(task = task, sessionId = sessionId)
+ }
+ }
+
+ private fun clearRunningTaskRecord() {
+ synchronized(runningTaskRecordLock) {
+ runningTaskRecord = null
+ }
+ }
}
diff --git a/app/src/main/java/com/apk/claw/android/agent/AgentConfig.kt b/app/src/main/java/com/apk/claw/android/agent/AgentConfig.kt
index 0f2b546..699dd4e 100644
--- a/app/src/main/java/com/apk/claw/android/agent/AgentConfig.kt
+++ b/app/src/main/java/com/apk/claw/android/agent/AgentConfig.kt
@@ -13,7 +13,17 @@ data class AgentConfig(
val streaming: Boolean = false
) {
companion object {
- const val DEFAULT_SYSTEM_PROMPT =
+ val DEFAULT_SYSTEM_PROMPT = buildSystemPrompt(
+ clickWaitMs = 2000,
+ openAppWaitMs = 3000,
+ inputWaitMs = 1000
+ )
+
+ fun buildSystemPrompt(
+ clickWaitMs: Int,
+ openAppWaitMs: Int,
+ inputWaitMs: Int
+ ): String =
"""## ROLE
你是一个控制 Android 手机的智能助手(AI Agent)。你通过无障碍服务提供的工具与设备交互,完成用户的任务。
@@ -47,13 +57,13 @@ data class AgentConfig(
- 权限弹窗:任务需要该权限则点击"允许/仅本次允许",否则点击"拒绝"
- 升级弹窗:点击 "以后再说/暂不更新"
- 协议弹窗:点击 "同意/我已阅读"
- - 登录/付费拦截:**不要自动操作**,立即通知用户需要登录或付费,然后调用 finish 结束任务
+ - 登录/付费拦截:**不要自动操作**,立即通知用户需要登录或付费,然后等待,不可以直接用finish结束任务,等待无果才finish结束任务。
规则 5:善用 wait_after 减少轮次。
大部分操作工具支持可选的 wait_after 参数(毫秒),操作完成后自动等待。
- - 点击后预期有页面跳转/加载 → 加 wait_after=2000
- - 打开 App → 加 wait_after=3000(App 启动较慢)
- - 输入文字后页面需要刷新 → 加 wait_after=1000
+ - 点击后预期有页面跳转/加载 → 加 wait_after=${clickWaitMs}
+ - 打开 App → 加 wait_after=${openAppWaitMs}(App 启动较慢)
+ - 输入文字后页面需要刷新 → 加 wait_after=${inputWaitMs}
- 不确定是否需要等待 → 不传此参数(默认不等待)
不要为了等待而单独用 wait 工具,尽量用 wait_after 合并到操作中。
@@ -83,11 +93,12 @@ data class AgentConfig(
规则 10:任务完成。
只有当任务目标已经**可以确认达成**时,才调用 finish(summary)。
summary 要描述完成了什么,而不只是说"完成了"。
+ 自己可以完成的简单任务不需要调用 finish,除非用户明确要求每个小步骤都反馈完成。
## 安全约束
- 绝不自动填写账户密码、支付密码、银行卡号等敏感凭证(WiFi 密码等用户明确要求输入的除外)
- 绝不确认购买/支付操作
-- 禁止执行卸载应用、清除数据、恢复出厂设置等破坏性操作。如果用户要求,直接拒绝并调用 finish 说明原因
+- 除非用户要求,否则禁止执行卸载应用、清除数据、恢复出厂设置等破坏性操作。
- 遇到登录墙或付费墙 → 停止操作并通知用户"""
}
diff --git a/app/src/main/java/com/apk/claw/android/agent/AgentService.kt b/app/src/main/java/com/apk/claw/android/agent/AgentService.kt
index bbd77f4..a76cc25 100644
--- a/app/src/main/java/com/apk/claw/android/agent/AgentService.kt
+++ b/app/src/main/java/com/apk/claw/android/agent/AgentService.kt
@@ -4,7 +4,11 @@ interface AgentService {
fun initialize(config: AgentConfig)
fun updateConfig(config: AgentConfig)
fun executeTask(userPrompt: String, callback: AgentCallback)
+ fun enqueueUserInstruction(message: String): Boolean
+ fun pause(): Boolean
+ fun resume(): Boolean
fun cancel()
fun shutdown()
fun isRunning(): Boolean
+ fun isPaused(): Boolean
}
diff --git a/app/src/main/java/com/apk/claw/android/agent/DefaultAgentService.kt b/app/src/main/java/com/apk/claw/android/agent/DefaultAgentService.kt
index 16e4f6b..4a5209d 100644
--- a/app/src/main/java/com/apk/claw/android/agent/DefaultAgentService.kt
+++ b/app/src/main/java/com/apk/claw/android/agent/DefaultAgentService.kt
@@ -25,6 +25,7 @@ import dev.langchain4j.data.message.UserMessage
import dev.langchain4j.agent.tool.ToolExecutionRequest
import java.io.File
import java.util.LinkedList
+import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@@ -53,6 +54,9 @@ class DefaultAgentService : AgentService {
private var executor: ExecutorService? = null
private val running = AtomicBoolean(false)
private val cancelled = AtomicBoolean(false)
+ private val paused = AtomicBoolean(false)
+ private val pendingUserInstructions = ConcurrentLinkedQueue()
+ private val pauseMonitor = Object()
override fun initialize(config: AgentConfig) {
this.config = config
@@ -80,6 +84,8 @@ class DefaultAgentService : AgentService {
running.set(true)
cancelled.set(false)
+ paused.set(false)
+ pendingUserInstructions.clear()
executor?.submit {
try {
@@ -93,6 +99,29 @@ class DefaultAgentService : AgentService {
}
}
+ override fun enqueueUserInstruction(message: String): Boolean {
+ if (!running.get()) return false
+ val normalized = message.trim()
+ if (normalized.isEmpty()) return false
+ pendingUserInstructions.offer(normalized)
+ return true
+ }
+
+ override fun pause(): Boolean {
+ if (!running.get()) return false
+ paused.set(true)
+ return true
+ }
+
+ override fun resume(): Boolean {
+ if (!running.get()) return false
+ synchronized(pauseMonitor) {
+ paused.set(false)
+ pauseMonitor.notifyAll()
+ }
+ return true
+ }
+
// ==================== 环境预检 ====================
private fun preCheck(): String? {
@@ -331,9 +360,13 @@ class DefaultAgentService : AgentService {
var lastScreenHash = 0
while (iterations < maxIterations && !cancelled.get()) {
+ waitIfPaused()
+ if (cancelled.get()) break
iterations++
callback.onLoopStart(iterations)
+ drainPendingUserInstructions(messages)
+
// 发送前分级压缩历史消息,节省 token
compressHistoryForSend(messages)
@@ -351,15 +384,11 @@ class DefaultAgentService : AgentService {
llmResponse.tokenUsage?.totalTokenCount()?.let { totalTokens += it }
// 将 AI 消息添加到历史(需要构造 AiMessage)
- val aiMessage = if (llmResponse.hasToolExecutionRequests()) {
- if (llmResponse.text.isNullOrEmpty()) {
- AiMessage.from(llmResponse.toolExecutionRequests)
- } else {
- AiMessage.from(llmResponse.text, llmResponse.toolExecutionRequests)
- }
- } else {
- AiMessage.from(llmResponse.text ?: "")
- }
+ val aiMessage = AiMessage.builder()
+ .text(llmResponse.text)
+ .thinking(llmResponse.thinking)
+ .toolExecutionRequests(llmResponse.toolExecutionRequests)
+ .build()
messages.add(aiMessage)
// 非流式模式下推送思考内容
@@ -375,6 +404,7 @@ class DefaultAgentService : AgentService {
// 执行工具调用
for (toolRequest in llmResponse.toolExecutionRequests) {
+ waitIfPaused()
if (cancelled.get()) {
callback.onComplete(iterations, ClawApplication.instance.getString(R.string.agent_task_cancel), totalTokens)
return
@@ -452,6 +482,11 @@ class DefaultAgentService : AgentService {
override fun cancel() {
cancelled.set(true)
+ paused.set(false)
+ pendingUserInstructions.clear()
+ synchronized(pauseMonitor) {
+ pauseMonitor.notifyAll()
+ }
}
override fun shutdown() {
@@ -460,4 +495,40 @@ class DefaultAgentService : AgentService {
}
override fun isRunning(): Boolean = running.get()
+
+ override fun isPaused(): Boolean = paused.get()
+
+ private fun drainPendingUserInstructions(messages: MutableList) {
+ val pending = mutableListOf()
+ while (true) {
+ val instruction = pendingUserInstructions.poll() ?: break
+ pending += instruction
+ }
+ if (pending.isEmpty()) return
+
+ val combined = buildString {
+ append("[补充指令] 用户在任务执行过程中追加了以下最新信息,请优先结合当前页面状态调整计划:\n")
+ pending.forEachIndexed { index, item ->
+ append(index + 1).append(". ").append(item).append("\n")
+ }
+ append("如果这些补充指令与之前计划冲突,以最新补充指令为准。")
+ }
+ messages.add(UserMessage.from(combined.trimEnd()))
+ }
+
+ private fun waitIfPaused() {
+ while (paused.get() && !cancelled.get()) {
+ try {
+ synchronized(pauseMonitor) {
+ if (!paused.get() || cancelled.get()) {
+ return
+ }
+ pauseMonitor.wait(250)
+ }
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ return
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/apk/claw/android/agent/llm/LlmResponse.kt b/app/src/main/java/com/apk/claw/android/agent/llm/LlmResponse.kt
index 96adc22..94706da 100644
--- a/app/src/main/java/com/apk/claw/android/agent/llm/LlmResponse.kt
+++ b/app/src/main/java/com/apk/claw/android/agent/llm/LlmResponse.kt
@@ -5,6 +5,7 @@ import dev.langchain4j.model.output.TokenUsage
data class LlmResponse(
val text: String?,
+ val thinking: String? = null,
val toolExecutionRequests: List,
val tokenUsage: TokenUsage? = null
) {
diff --git a/app/src/main/java/com/apk/claw/android/agent/llm/OpenAiLlmClient.kt b/app/src/main/java/com/apk/claw/android/agent/llm/OpenAiLlmClient.kt
index 3984646..2891289 100644
--- a/app/src/main/java/com/apk/claw/android/agent/llm/OpenAiLlmClient.kt
+++ b/app/src/main/java/com/apk/claw/android/agent/llm/OpenAiLlmClient.kt
@@ -28,6 +28,7 @@ class OpenAiLlmClient(
.apiKey(config.apiKey)
.modelName(config.modelName)
.temperature(config.temperature)
+ .applyDeepSeekV4Compatibility()
if (config.baseUrl.isNotEmpty()) {
builder.baseUrl(config.baseUrl)
}
@@ -40,12 +41,38 @@ class OpenAiLlmClient(
.apiKey(config.apiKey)
.modelName(config.modelName)
.temperature(config.temperature)
+ .applyDeepSeekV4Compatibility()
if (config.baseUrl.isNotEmpty()) {
builder.baseUrl(config.baseUrl)
}
return builder.build()
}
+ private fun OpenAiChatModel.OpenAiChatModelBuilder.applyDeepSeekV4Compatibility():
+ OpenAiChatModel.OpenAiChatModelBuilder {
+ if (isDeepSeekV4Model()) {
+ returnThinking(true)
+ sendThinking(true)
+ }
+ return this
+ }
+
+ private fun OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder.applyDeepSeekV4Compatibility():
+ OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder {
+ if (isDeepSeekV4Model()) {
+ returnThinking(true)
+ sendThinking(true)
+ }
+ return this
+ }
+
+ private fun isDeepSeekV4Model(): Boolean {
+ return when (config.modelName.lowercase()) {
+ "deepseek-v4-pro", "deepseek-v4-flash" -> true
+ else -> false
+ }
+ }
+
override fun chat(messages: List, toolSpecs: List): LlmResponse {
val request = ChatRequest.builder()
.messages(messages)
@@ -98,6 +125,7 @@ internal fun ChatResponse.toLlmResponse(): LlmResponse {
val aiMessage = aiMessage()
return LlmResponse(
text = aiMessage.text(),
+ thinking = aiMessage.thinking(),
toolExecutionRequests = aiMessage.toolExecutionRequests() ?: emptyList(),
tokenUsage = tokenUsage()
)
diff --git a/app/src/main/java/com/apk/claw/android/channel/ChannelSetup.kt b/app/src/main/java/com/apk/claw/android/channel/ChannelSetup.kt
index 1fc4f14..86663ae 100644
--- a/app/src/main/java/com/apk/claw/android/channel/ChannelSetup.kt
+++ b/app/src/main/java/com/apk/claw/android/channel/ChannelSetup.kt
@@ -3,6 +3,7 @@ package com.apk.claw.android.channel
import com.apk.claw.android.ClawApplication
import com.apk.claw.android.R
import com.apk.claw.android.TaskOrchestrator
+import com.apk.claw.android.session.SessionMemoryCommandHandler
import com.apk.claw.android.service.ClawAccessibilityService
import com.apk.claw.android.utils.KVUtils
@@ -30,6 +31,12 @@ class ChannelSetup(
ChannelManager.setOnMessageReceivedListener(object : ChannelManager.OnMessageReceivedListener {
override fun onMessageReceived(channel: Channel, message: String, messageID: String) {
val app = ClawApplication.instance
+ if (channel == Channel.FEISHU && SessionMemoryCommandHandler.handleIfCommand(message, messageID)) {
+ return
+ }
+ if (channel == Channel.FEISHU && taskOrchestrator.enqueueOrHandleRunningTaskMessage(channel, message, messageID)) {
+ return
+ }
if (!ClawAccessibilityService.isRunning()) {
ChannelManager.sendMessage(channel, app.getString(R.string.channel_msg_no_accessibility), messageID)
ChannelManager.flushMessages(channel)
diff --git a/app/src/main/java/com/apk/claw/android/floating/FloatingCircleManager.kt b/app/src/main/java/com/apk/claw/android/floating/FloatingCircleManager.kt
index aeb1d31..6a2bac9 100644
--- a/app/src/main/java/com/apk/claw/android/floating/FloatingCircleManager.kt
+++ b/app/src/main/java/com/apk/claw/android/floating/FloatingCircleManager.kt
@@ -29,6 +29,7 @@ import com.lzf.easyfloat.utils.DisplayUtils
object FloatingCircleManager {
private const val FLOAT_TAG = "circle_float"
+ private const val INPUT_PANEL_TAG = "task_input_panel_float"
private const val KEY_FLOAT_X = "floating_circle_x"
private const val KEY_FLOAT_Y = "floating_circle_y"
private const val AUTO_RESET_DELAY_MS = 5000L // 5秒后自动重置
@@ -152,6 +153,77 @@ object FloatingCircleManager {
EasyFloat.dismiss(FLOAT_TAG)
isShowing = false
}
+ EasyFloat.dismiss(INPUT_PANEL_TAG)
+ }
+
+ fun showTaskInputPanel(
+ onResume: () -> Boolean,
+ onSendAndResume: (String) -> Boolean,
+ onStop: () -> Unit
+ ) {
+ val application = appRef ?: return
+ EasyFloat.dismiss(INPUT_PANEL_TAG)
+ var handled = false
+
+ EasyFloat.with(application)
+ .setLayout(R.layout.activity_floating_task_input)
+ .setShowPattern(ShowPattern.ALL_TIME)
+ .setGravity(android.view.Gravity.START or android.view.Gravity.TOP, 0, 0)
+ .setDragEnable(false)
+ .hasEditText(true)
+ .setTag(INPUT_PANEL_TAG)
+ .registerCallbacks(object : OnFloatCallbacks {
+ override fun createdResult(isCreated: Boolean, msg: String?, view: View?) {
+ if (!isCreated || view == null) return
+ val etFollowUp = view.findViewById(R.id.etFloatingFollowUp)
+ val backdrop = view.findViewById(R.id.floatingInputBackdrop)
+ val panel = view.findViewById(R.id.floatingInputPanel)
+
+ view.findViewById(R.id.btnFloatingResume)?.setOnClickListener {
+ handled = true
+ onResume()
+ EasyFloat.dismiss(INPUT_PANEL_TAG)
+ }
+ view.findViewById(R.id.btnFloatingStop)?.setOnClickListener {
+ handled = true
+ onStop()
+ EasyFloat.dismiss(INPUT_PANEL_TAG)
+ }
+ view.findViewById(R.id.btnFloatingSend)?.setOnClickListener {
+ val text = etFollowUp?.text?.toString()?.trim().orEmpty()
+ if (text.isBlank()) {
+ android.widget.Toast.makeText(application, R.string.floating_task_followup_empty, android.widget.Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ if (onSendAndResume(text)) {
+ handled = true
+ android.widget.Toast.makeText(application, R.string.floating_task_followup_sent, android.widget.Toast.LENGTH_SHORT).show()
+ EasyFloat.dismiss(INPUT_PANEL_TAG)
+ } else {
+ android.widget.Toast.makeText(application, R.string.floating_task_followup_failed, android.widget.Toast.LENGTH_SHORT).show()
+ }
+ }
+ backdrop?.setOnClickListener {
+ handled = true
+ onResume()
+ EasyFloat.dismiss(INPUT_PANEL_TAG)
+ }
+ panel?.setOnClickListener { }
+ }
+
+ override fun dismiss() {
+ if (!handled) {
+ onResume()
+ }
+ }
+
+ override fun show(view: View) = Unit
+ override fun hide(view: View) = Unit
+ override fun drag(view: View, event: MotionEvent) = Unit
+ override fun dragEnd(view: View) = Unit
+ override fun touchEvent(view: View, event: MotionEvent) = Unit
+ })
+ .show()
}
/**
@@ -173,7 +245,7 @@ object FloatingCircleManager {
* @param taskText 任务文本(会截断显示)
* @param channel 消息来源渠道
*/
- fun showTaskNotify(taskText: String, channel: Channel) {
+ fun showTaskNotify(taskText: String, channel: Channel?) {
ThreadUtils.runOnUiThread {
pendingTaskText = taskText
currentChannel = channel
@@ -199,7 +271,7 @@ object FloatingCircleManager {
* @param round 当前轮数
* @param channel 消息来源渠道
*/
- fun setRunningState(round: Int, channel: Channel) {
+ fun setRunningState(round: Int, channel: Channel?) {
ThreadUtils.runOnUiThread {
currentRound = round
currentChannel = channel
diff --git a/app/src/main/java/com/apk/claw/android/session/SessionMemoryCommandHandler.kt b/app/src/main/java/com/apk/claw/android/session/SessionMemoryCommandHandler.kt
new file mode 100644
index 0000000..62b543a
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/session/SessionMemoryCommandHandler.kt
@@ -0,0 +1,124 @@
+package com.apk.claw.android.session
+
+import com.apk.claw.android.channel.Channel
+import com.apk.claw.android.channel.ChannelManager
+
+object SessionMemoryCommandHandler {
+
+ fun handleIfCommand(message: String, messageId: String): Boolean {
+ val trimmed = message.trim()
+ if (!trimmed.startsWith("/")) return false
+
+ val parts = trimmed.split(Regex("\\s+"), limit = 3)
+ val root = parts.firstOrNull()?.lowercase() ?: return false
+
+ val reply = when (root) {
+ "/memory", "/mem", "/记忆" -> handleMemoryCommand(parts)
+ "/session", "/sess", "/会话" -> handleSessionCommand(parts)
+ else -> null
+ } ?: return false
+
+ ChannelManager.sendMessage(Channel.FEISHU, reply, messageId)
+ ChannelManager.flushMessages(Channel.FEISHU)
+ return true
+ }
+
+ private fun handleMemoryCommand(parts: List): String {
+ val action = parts.getOrNull(1)?.lowercase()
+ return when (action) {
+ "on", "enable", "开启" -> {
+ SessionMemoryManager.setMemoryEnabled(true)
+ "全局记忆已开启,当前会话:${SessionMemoryManager.getCurrentSession()?.name ?: "默认会话"}"
+ }
+ "off", "disable", "关闭" -> {
+ SessionMemoryManager.setMemoryEnabled(false)
+ "全局记忆已关闭。"
+ }
+ "status", "状态", null -> {
+ val current = SessionMemoryManager.getCurrentSession()
+ val enabledText = if (SessionMemoryManager.isMemoryEnabled()) "开启" else "关闭"
+ val summary = SessionMemoryManager.getMemoryText("", SessionMemoryManager.FIELD_GLOBAL_MEMORY)
+ val promptEnabled = if (SessionMemoryManager.isGlobalPromptEnabled()) "开启" else "关闭"
+ "全局记忆:$enabledText\n全局Prompt:$promptEnabled\n当前会话:${current?.name ?: "默认会话"} (${current?.id ?: "-"})\n全局记忆内容:${summary.ifBlank { "暂无内容。" }}"
+ }
+ else -> buildHelpText()
+ }
+ }
+
+ private fun handleSessionCommand(parts: List): String {
+ val action = parts.getOrNull(1)?.lowercase()
+ return when (action) {
+ "on", "enable", "开启" -> {
+ SessionMemoryManager.setSessionEnabled(true)
+ "会话功能已开启,当前会话:${SessionMemoryManager.getCurrentSession()?.name ?: "默认会话"}"
+ }
+ "off", "disable", "关闭" -> {
+ SessionMemoryManager.setSessionEnabled(false)
+ "会话功能已关闭。"
+ }
+ "status", "状态" -> {
+ val current = SessionMemoryManager.getCurrentSession()
+ val enabledText = if (SessionMemoryManager.isSessionEnabled()) "开启" else "关闭"
+ "会话状态:$enabledText\n当前会话:${current?.name ?: "默认会话"} (${current?.id ?: "-"})"
+ }
+ "list", "列表", null -> {
+ val sessions = SessionMemoryManager.listSessions()
+ val currentId = SessionMemoryManager.getCurrentSessionId()
+ buildString {
+ append("可用会话:\n")
+ sessions.forEachIndexed { index, session ->
+ val currentMark = if (session.id == currentId) " [当前]" else ""
+ append(index + 1).append(". ")
+ .append(session.name)
+ .append(" (").append(session.id).append(")")
+ .append(currentMark)
+ .append("\n")
+ }
+ }.trimEnd()
+ }
+ "new", "create", "新建" -> {
+ val name = parts.getOrNull(2).orEmpty()
+ val session = SessionMemoryManager.createSession(name)
+ "已创建并切换到新会话:${session.name} (${session.id})"
+ }
+ "use", "continue", "切换", "使用", "继续" -> {
+ val target = parts.getOrNull(2).orEmpty().trim()
+ if (target.isBlank()) {
+ "请提供会话 ID 或会话名,例如:/session use session-default"
+ } else {
+ val session = findSession(target)
+ if (session == null) {
+ "未找到会话:$target"
+ } else {
+ SessionMemoryManager.setCurrentSession(session.id)
+ "已切换到会话:${session.name} (${session.id})"
+ }
+ }
+ }
+ "current", "当前" -> {
+ val current = SessionMemoryManager.getCurrentSession()
+ if (current == null) {
+ "当前没有可用会话。"
+ } else {
+ "当前会话:${current.name} (${current.id})\n消息数:${SessionMemoryManager.getSessionMessages(current.id).size}"
+ }
+ }
+ else -> buildHelpText()
+ }
+ }
+
+ private fun findSession(target: String): SessionMemory? {
+ val sessions = SessionMemoryManager.listSessions()
+ return sessions.firstOrNull { it.id.equals(target, ignoreCase = true) }
+ ?: sessions.firstOrNull { it.name.equals(target, ignoreCase = true) }
+ }
+
+ private fun buildHelpText(): String {
+ return "可用命令:\n" +
+ "/memory on|off|status\n" +
+ "/session on|off|status|list\n" +
+ "/session new 会话名\n" +
+ "/session use 会话ID\n" +
+ "/session current"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/apk/claw/android/session/SessionMemoryManager.kt b/app/src/main/java/com/apk/claw/android/session/SessionMemoryManager.kt
new file mode 100644
index 0000000..8a9194d
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/session/SessionMemoryManager.kt
@@ -0,0 +1,673 @@
+package com.apk.claw.android.session
+
+import com.apk.claw.android.utils.KVUtils
+import com.google.gson.Gson
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.google.gson.reflect.TypeToken
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.UUID
+
+data class SessionChatMessage(
+ val id: String,
+ val role: String,
+ var content: String,
+ val timestamp: Long
+)
+
+data class SessionMemory(
+ val id: String,
+ var name: String,
+ val createdAt: Long,
+ var updatedAt: Long,
+ val messages: MutableList = mutableListOf(),
+ var sessionTranscript: String = "",
+ var condensedSummary: String = "",
+ var habitNotes: String = "",
+ var sessionPrompt: String = "",
+ val recentTasks: MutableList = mutableListOf(),
+ val successfulTaskCounts: MutableMap = mutableMapOf(),
+ val errorLessons: MutableList = mutableListOf()
+)
+
+data class GlobalMemory(
+ var memoryText: String = "",
+ var promptText: String = ""
+)
+
+data class SessionMemoryState(
+ var sessionEnabled: Boolean = false,
+ var globalMemoryEnabled: Boolean = false,
+ var globalPromptEnabled: Boolean = false,
+ var currentSessionId: String = "",
+ val globalMemory: GlobalMemory = GlobalMemory(),
+ val sessions: MutableList = mutableListOf()
+)
+
+object SessionMemoryManager {
+
+ const val ROLE_USER = "user"
+ const val ROLE_ASSISTANT = "assistant"
+ const val ROLE_SYSTEM = "system"
+
+ const val FIELD_CONDENSED_SUMMARY = "condensed_summary"
+ const val FIELD_HABIT_NOTES = "habit_notes"
+ const val FIELD_SESSION_PROMPT = "session_prompt"
+ const val FIELD_GLOBAL_MEMORY = "global_memory"
+ const val FIELD_GLOBAL_PROMPT = "global_prompt"
+
+ private const val KEY_SESSION_MEMORY_STATE = "KEY_SESSION_MEMORY_STATE"
+ private const val DEFAULT_SESSION_NAME = "默认会话"
+ private const val MAX_SESSION_TRANSCRIPT_CHARS = 1500
+ private const val MAX_PROMPT_BLOCK_CHARS = 1200
+
+ private val gson = Gson()
+ private val stateType = object : TypeToken() {}.type
+ private val dayFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
+
+ @Synchronized
+ fun isSessionEnabled(): Boolean = loadState().sessionEnabled
+
+ @Synchronized
+ fun setSessionEnabled(enabled: Boolean) {
+ val state = loadState()
+ state.sessionEnabled = enabled
+ saveState(state)
+ }
+
+ @Synchronized
+ fun isMemoryEnabled(): Boolean = loadState().globalMemoryEnabled
+
+ @Synchronized
+ fun setMemoryEnabled(enabled: Boolean) {
+ val state = loadState()
+ state.globalMemoryEnabled = enabled
+ saveState(state)
+ }
+
+ @Synchronized
+ fun isGlobalPromptEnabled(): Boolean = loadState().globalPromptEnabled
+
+ @Synchronized
+ fun setGlobalPromptEnabled(enabled: Boolean) {
+ val state = loadState()
+ state.globalPromptEnabled = enabled
+ saveState(state)
+ }
+
+ @Synchronized
+ fun listSessions(): List = loadState().sessions.sortedByDescending { it.updatedAt }
+
+ @Synchronized
+ fun getCurrentSession(): SessionMemory? {
+ val state = loadState()
+ return state.sessions.firstOrNull { it.id == state.currentSessionId }
+ }
+
+ @Synchronized
+ fun getSession(sessionId: String): SessionMemory? {
+ val state = loadState()
+ return state.sessions.firstOrNull { it.id == sessionId }
+ }
+
+ @Synchronized
+ fun getSessionMessages(sessionId: String): List {
+ return getSession(sessionId)?.messages?.sortedBy { it.timestamp } ?: emptyList()
+ }
+
+ @Synchronized
+ fun getCurrentSessionId(): String = loadState().currentSessionId
+
+ @Synchronized
+ fun setCurrentSession(sessionId: String): Boolean {
+ val state = loadState()
+ if (state.sessions.none { it.id == sessionId }) return false
+ state.currentSessionId = sessionId
+ saveState(state)
+ return true
+ }
+
+ @Synchronized
+ fun createSession(name: String): SessionMemory {
+ val state = loadState()
+ val now = System.currentTimeMillis()
+ val session = SessionMemory(
+ id = "session-${UUID.randomUUID().toString().substring(0, 8)}",
+ name = name.ifBlank { "会话 ${state.sessions.size + 1}" },
+ createdAt = now,
+ updatedAt = now
+ )
+ state.sessions.add(0, session)
+ state.currentSessionId = session.id
+ saveState(state)
+ return session
+ }
+
+ @Synchronized
+ fun deleteSession(sessionId: String): Boolean {
+ val state = loadState()
+ val removed = state.sessions.removeAll { it.id == sessionId }
+ if (!removed) return false
+ val normalized = ensureState(state)
+ saveState(normalized)
+ return true
+ }
+
+ @Synchronized
+ fun renameSession(sessionId: String, name: String): Boolean {
+ val state = loadState()
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return false
+ session.name = name.ifBlank { session.name }
+ session.updatedAt = System.currentTimeMillis()
+ saveState(state)
+ return true
+ }
+
+ @Synchronized
+ fun appendSessionMessage(sessionId: String, role: String, content: String): Boolean {
+ val normalized = content.trim()
+ if (normalized.isEmpty()) return false
+ val state = loadState()
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return false
+ session.messages += SessionChatMessage(
+ id = "msg-${UUID.randomUUID().toString().substring(0, 8)}",
+ role = role,
+ content = normalized,
+ timestamp = System.currentTimeMillis()
+ )
+ session.sessionTranscript = buildTranscriptFromMessages(session.messages)
+ session.updatedAt = System.currentTimeMillis()
+ saveState(state)
+ return true
+ }
+
+ @Synchronized
+ fun getMemoryText(sessionId: String, field: String): String {
+ val state = loadState()
+ return when (field) {
+ FIELD_CONDENSED_SUMMARY -> state.sessions.firstOrNull { it.id == sessionId }?.condensedSummary.orEmpty()
+ FIELD_HABIT_NOTES -> state.sessions.firstOrNull { it.id == sessionId }?.habitNotes.orEmpty()
+ FIELD_SESSION_PROMPT -> state.sessions.firstOrNull { it.id == sessionId }?.sessionPrompt.orEmpty()
+ FIELD_GLOBAL_MEMORY -> state.globalMemory.memoryText
+ FIELD_GLOBAL_PROMPT -> state.globalMemory.promptText
+ else -> ""
+ }
+ }
+
+ @Synchronized
+ fun updateMemoryText(sessionId: String, field: String, value: String): Boolean {
+ val state = loadState()
+ when (field) {
+ FIELD_CONDENSED_SUMMARY -> {
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return false
+ session.condensedSummary = value.trim()
+ session.updatedAt = System.currentTimeMillis()
+ }
+ FIELD_HABIT_NOTES -> {
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return false
+ session.habitNotes = value.trim()
+ session.updatedAt = System.currentTimeMillis()
+ }
+ FIELD_SESSION_PROMPT -> {
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return false
+ session.sessionPrompt = value.trim()
+ session.updatedAt = System.currentTimeMillis()
+ }
+ FIELD_GLOBAL_MEMORY -> state.globalMemory.memoryText = value.trim()
+ FIELD_GLOBAL_PROMPT -> state.globalMemory.promptText = value.trim()
+ else -> return false
+ }
+ saveState(state)
+ return true
+ }
+
+ @Synchronized
+ fun updateSessionContent(
+ sessionId: String,
+ name: String,
+ sessionTranscript: String,
+ condensedSummary: String,
+ habitNotes: List,
+ errorLessons: List
+ ): Boolean {
+ val state = loadState()
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return false
+ session.name = name.ifBlank { session.name }
+ session.sessionTranscript = trimTranscript(sessionTranscript)
+ session.condensedSummary = condensedSummary.trim()
+ session.habitNotes = habitNotes.joinToString("\n")
+ session.sessionPrompt = errorLessons.joinToString("\n")
+ session.updatedAt = System.currentTimeMillis()
+ saveState(state)
+ return true
+ }
+
+ @Synchronized
+ fun writeSessionFieldToGlobalMemory(sessionId: String, field: String, overrideValue: String? = null): Boolean {
+ val state = loadState()
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return false
+ val source = overrideValue?.trim().orEmpty().ifBlank {
+ when (field) {
+ FIELD_CONDENSED_SUMMARY -> session.condensedSummary
+ FIELD_HABIT_NOTES -> session.habitNotes
+ else -> ""
+ }
+ }.trim()
+ if (source.isBlank()) return false
+ val title = when (field) {
+ FIELD_CONDENSED_SUMMARY -> "会话凝练记忆"
+ FIELD_HABIT_NOTES -> "会话习惯偏好"
+ else -> return false
+ }
+ state.globalMemory.memoryText = appendDatedSection(
+ current = state.globalMemory.memoryText,
+ date = todayTag(),
+ header = "$title · ${session.name}",
+ body = source
+ )
+ saveState(state)
+ return true
+ }
+
+ @Synchronized
+ fun buildTaskPrompt(userTask: String): String {
+ return buildTaskPrompt(loadState().currentSessionId, userTask)
+ }
+
+ @Synchronized
+ fun buildTaskPrompt(sessionId: String, userTask: String): String {
+ val state = loadState()
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return userTask
+
+ val sections = mutableListOf()
+ if (state.sessionEnabled && session.sessionTranscript.isNotBlank()) {
+ buildPromptSection(
+ title = "当前会话原始记录(已压缩)",
+ content = session.sessionTranscript,
+ limit = MAX_SESSION_TRANSCRIPT_CHARS
+ )?.let { sections += it }
+ }
+ if (session.sessionPrompt.isNotBlank()) {
+ buildPromptSection(
+ title = "会话Prompt",
+ content = session.sessionPrompt,
+ limit = MAX_PROMPT_BLOCK_CHARS
+ )?.let { sections += it }
+ }
+ if (session.condensedSummary.isNotBlank()) {
+ buildPromptSection(
+ title = "会话凝练记忆",
+ content = session.condensedSummary,
+ limit = MAX_PROMPT_BLOCK_CHARS
+ )?.let { sections += it }
+ }
+ if (session.habitNotes.isNotBlank()) {
+ buildPromptSection(
+ title = "会话习惯/偏好",
+ content = session.habitNotes,
+ limit = MAX_PROMPT_BLOCK_CHARS
+ )?.let { sections += it }
+ }
+ if (state.globalMemoryEnabled && state.globalMemory.memoryText.isNotBlank()) {
+ buildPromptSection(
+ title = "全局记忆",
+ content = state.globalMemory.memoryText,
+ limit = MAX_PROMPT_BLOCK_CHARS
+ )?.let { sections += it }
+ }
+ if (state.globalPromptEnabled && state.globalMemory.promptText.isNotBlank()) {
+ buildPromptSection(
+ title = "全局Prompt",
+ content = state.globalMemory.promptText,
+ limit = MAX_PROMPT_BLOCK_CHARS
+ )?.let { sections += it }
+ }
+
+ if (sections.isEmpty()) return userTask
+
+ return buildString {
+ append("## 当前会话上下文\n")
+ append("- 会话名: ").append(session.name).append("\n")
+ append("- 历史会话里如果出现旧的 wait_after 数值、等待习惯或缩放说明,全部视为过期;当前任务必须以当前系统提示词里的等待建议为准。\n")
+ append(sections.joinToString("\n"))
+ append("\n- 以上内容仅作为参考;如果与当前用户指令冲突,以当前指令为准。\n\n")
+ append("## 当前用户任务\n")
+ append(userTask)
+ }
+ }
+
+ @Synchronized
+ fun updateCurrentSessionTranscriptSnapshot(transcript: String): Boolean {
+ return updateSessionTranscriptSnapshot(loadState().currentSessionId, transcript)
+ }
+
+ @Synchronized
+ fun updateSessionTranscriptSnapshot(sessionId: String, transcript: String): Boolean {
+ val state = loadState()
+ if (!state.sessionEnabled) return false
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return false
+ session.sessionTranscript = deriveTranscript(session, transcript)
+ session.updatedAt = System.currentTimeMillis()
+ saveState(state)
+ return true
+ }
+
+ @Synchronized
+ fun recordSuccess(userTask: String, sessionTranscript: String, finalSummary: String) {
+ recordSuccess(loadState().currentSessionId, userTask, sessionTranscript, finalSummary)
+ }
+
+ @Synchronized
+ fun recordSuccess(sessionId: String, userTask: String, sessionTranscript: String, finalSummary: String) {
+ val state = loadState()
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return
+ val normalizedTask = normalizeTask(userTask)
+ if (state.sessionEnabled) {
+ session.sessionTranscript = deriveTranscript(session, sessionTranscript)
+ }
+ if (finalSummary.isNotBlank()) {
+ session.condensedSummary = appendDatedBullet(session.condensedSummary, todayTag(), sanitizeLine(finalSummary))
+ }
+ val count = (session.successfulTaskCounts[normalizedTask] ?: 0) + 1
+ session.successfulTaskCounts[normalizedTask] = count
+ if (count >= 2) {
+ session.habitNotes = appendDatedBullet(session.habitNotes, todayTag(), "高频任务偏好:$normalizedTask")
+ }
+ session.updatedAt = System.currentTimeMillis()
+ saveState(state)
+ }
+
+ @Synchronized
+ fun recordFailure(userTask: String, errorMessage: String, sessionTranscript: String) {
+ recordFailure(loadState().currentSessionId, userTask, errorMessage, sessionTranscript)
+ }
+
+ @Synchronized
+ fun recordFailure(sessionId: String, userTask: String, errorMessage: String, sessionTranscript: String) {
+ val state = loadState()
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return
+ val normalizedTask = normalizeTask(userTask)
+ if (state.sessionEnabled) {
+ session.sessionTranscript = deriveTranscript(session, sessionTranscript)
+ }
+ session.condensedSummary = appendDatedBullet(
+ session.condensedSummary,
+ todayTag(),
+ "任务“$normalizedTask”失败:${sanitizeLine(errorMessage)}"
+ )
+ session.updatedAt = System.currentTimeMillis()
+ saveState(state)
+ }
+
+ @Synchronized
+ fun recordCancellation(userTask: String, reason: String, sessionTranscript: String) {
+ recordCancellation(loadState().currentSessionId, userTask, reason, sessionTranscript)
+ }
+
+ @Synchronized
+ fun recordCancellation(sessionId: String, userTask: String, reason: String, sessionTranscript: String) {
+ val state = loadState()
+ val session = state.sessions.firstOrNull { it.id == sessionId } ?: return
+ val normalizedTask = normalizeTask(userTask)
+ if (state.sessionEnabled) {
+ session.sessionTranscript = deriveTranscript(session, sessionTranscript)
+ }
+ session.condensedSummary = appendDatedBullet(
+ session.condensedSummary,
+ todayTag(),
+ "任务“$normalizedTask”已取消:${sanitizeLine(reason)}"
+ )
+ session.updatedAt = System.currentTimeMillis()
+ saveState(state)
+ }
+
+ @Synchronized
+ fun getStatusSummary(): String {
+ val state = loadState()
+ val enabledCount = listOf(state.sessionEnabled, state.globalMemoryEnabled, state.globalPromptEnabled).count { it }
+ return when (enabledCount) {
+ 0 -> "全部关闭"
+ 3 -> "全部开启"
+ else -> "已开 $enabledCount 项"
+ }
+ }
+
+ private fun loadState(): SessionMemoryState {
+ val raw = KVUtils.getString(KEY_SESSION_MEMORY_STATE, "")
+ val state = if (raw.isBlank()) {
+ SessionMemoryState()
+ } else {
+ runCatching { gson.fromJson(normalizeStateJson(raw), stateType) }
+ .getOrDefault(SessionMemoryState())
+ }
+ return ensureState(state)
+ }
+
+ private fun ensureState(state: SessionMemoryState): SessionMemoryState {
+ if (state.sessions.isEmpty()) {
+ val now = System.currentTimeMillis()
+ state.sessions += SessionMemory(
+ id = "session-default",
+ name = DEFAULT_SESSION_NAME,
+ createdAt = now,
+ updatedAt = now
+ )
+ }
+ if (state.currentSessionId.isBlank() || state.sessions.none { it.id == state.currentSessionId }) {
+ state.currentSessionId = state.sessions.first().id
+ }
+ state.sessions.forEach { session ->
+ if (session.sessionTranscript.isBlank() && session.recentTasks.isNotEmpty()) {
+ session.sessionTranscript = trimTranscript(session.recentTasks.joinToString("\n"))
+ session.recentTasks.clear()
+ }
+ if (session.messages.isEmpty() && session.sessionTranscript.isNotBlank()) {
+ session.messages += SessionChatMessage(
+ id = "msg-${UUID.randomUUID().toString().substring(0, 8)}",
+ role = ROLE_SYSTEM,
+ content = session.sessionTranscript.trim(),
+ timestamp = session.updatedAt
+ )
+ }
+ if (session.messages.isNotEmpty()) {
+ session.sessionTranscript = buildTranscriptFromMessages(session.messages)
+ }
+ if (session.errorLessons.isNotEmpty()) {
+ session.condensedSummary = appendDatedBullet(
+ session.condensedSummary,
+ todayTag(),
+ session.errorLessons.joinToString(";")
+ )
+ session.errorLessons.clear()
+ }
+ }
+ migrateLegacyGlobalMemory(state)
+ return state
+ }
+
+ private fun normalizeStateJson(raw: String): String {
+ val root = gson.fromJson(raw, JsonObject::class.java) ?: return raw
+ if (!root.has("currentSessionId") || root.get("currentSessionId").isJsonNull) {
+ root.addProperty("currentSessionId", "")
+ }
+ if (!root.has("globalMemoryEnabled") || root.get("globalMemoryEnabled").isJsonNull) {
+ val legacy = root.get("memoryEnabled")?.takeIf { !it.isJsonNull }?.asBoolean ?: false
+ root.addProperty("globalMemoryEnabled", legacy)
+ }
+ if (!root.has("globalPromptEnabled") || root.get("globalPromptEnabled").isJsonNull) {
+ root.addProperty("globalPromptEnabled", false)
+ }
+
+ val sessions = if (root.has("sessions") && root.get("sessions").isJsonArray) {
+ root.getAsJsonArray("sessions")
+ } else {
+ JsonArray().also { root.add("sessions", it) }
+ }
+
+ val globalMemory = if (root.has("globalMemory") && root.get("globalMemory").isJsonObject) {
+ root.getAsJsonObject("globalMemory")
+ } else {
+ JsonObject().also { root.add("globalMemory", it) }
+ }
+ ensureStringField(globalMemory, "memoryText", "")
+ ensureStringField(globalMemory, "promptText", "")
+ if ((!globalMemory.has("memoryText") || globalMemory.get("memoryText").asString.isBlank()) &&
+ (globalMemory.has("condensedSummary") || globalMemory.has("habitNotes") || globalMemory.has("errorLessons"))) {
+ val merged = listOf(
+ globalMemory.get("condensedSummary")?.takeIf { !it.isJsonNull }?.asString.orEmpty(),
+ globalMemory.get("habitNotes")?.takeIf { !it.isJsonNull }?.asString.orEmpty(),
+ globalMemory.get("errorLessons")?.takeIf { !it.isJsonNull }?.asString.orEmpty()
+ ).filter { it.isNotBlank() }.joinToString("\n\n")
+ globalMemory.addProperty("memoryText", merged)
+ }
+
+ sessions.forEach { element ->
+ val session = element as? JsonObject ?: return@forEach
+ ensureStringField(session, "id", "")
+ ensureStringField(session, "name", DEFAULT_SESSION_NAME)
+ ensureArrayField(session, "messages")
+ ensureStringField(session, "sessionTranscript", "")
+ ensureStringField(session, "condensedSummary", "")
+ if (session.has("habitNotes") && session.get("habitNotes").isJsonArray) {
+ val merged = session.getAsJsonArray("habitNotes").mapNotNull { if (it.isJsonNull) null else it.asString }.joinToString("\n")
+ session.addProperty("habitNotes", merged)
+ }
+ ensureStringField(session, "habitNotes", "")
+ ensureStringField(session, "sessionPrompt", "")
+ ensureArrayField(session, "recentTasks")
+ ensureObjectField(session, "successfulTaskCounts")
+ ensureArrayField(session, "errorLessons")
+ }
+
+ return root.toString()
+ }
+
+ private fun ensureStringField(target: JsonObject, key: String, defaultValue: String) {
+ if (!target.has(key) || target.get(key).isJsonNull) {
+ target.addProperty(key, defaultValue)
+ }
+ }
+
+ private fun ensureArrayField(target: JsonObject, key: String) {
+ if (!target.has(key) || target.get(key).isJsonNull || !target.get(key).isJsonArray) {
+ target.add(key, JsonArray())
+ }
+ }
+
+ private fun ensureObjectField(target: JsonObject, key: String) {
+ if (!target.has(key) || target.get(key).isJsonNull || !target.get(key).isJsonObject) {
+ target.add(key, JsonObject())
+ }
+ }
+
+ private fun saveState(state: SessionMemoryState) {
+ KVUtils.putString(KEY_SESSION_MEMORY_STATE, gson.toJson(state))
+ }
+
+ private fun trimTranscript(text: String): String {
+ val sanitized = text.trim()
+ return if (sanitized.length <= MAX_SESSION_TRANSCRIPT_CHARS) sanitized else sanitized.takeLast(MAX_SESSION_TRANSCRIPT_CHARS)
+ }
+
+ private fun shortenForPrompt(text: String, limit: Int): String {
+ val sanitized = text.trim()
+ return if (sanitized.length <= limit) sanitized else "...\n${sanitized.takeLast(limit)}"
+ }
+
+ private fun sanitizeInjectedPromptContext(text: String): String {
+ return text.lineSequence()
+ .map { sanitizeWaitAfterValue(it).trimEnd() }
+ .filterNot { line ->
+ val normalized = line.trim().lowercase(Locale.getDefault())
+ normalized.contains("缩放因子") ||
+ normalized.contains("推荐等待") ||
+ normalized.contains("recommended wait")
+ }
+ .joinToString("\n")
+ .trim()
+ }
+
+ private fun sanitizeWaitAfterValue(line: String): String {
+ return line
+ .replace(Regex("""(?i)(wait_after\s*=\s*)\d+(?:\.\d+)?"""), "$1")
+ .replace(Regex("""(?i)(wait_after\s*:\s*)\d+(?:\.\d+)?"""), "$1")
+ .replace(Regex("""(?i)(wait_after\s+)\d+(?:\.\d+)?"""), "$1")
+ }
+
+ private fun buildPromptSection(title: String, content: String, limit: Int): String? {
+ val sanitized = sanitizeInjectedPromptContext(content)
+ if (sanitized.isBlank()) return null
+ return buildString {
+ append("- ").append(title).append(":\n")
+ append(shortenForPrompt(sanitized, limit))
+ }.trimEnd()
+ }
+
+ private fun normalizeTask(task: String): String {
+ val oneLine = sanitizeLine(task)
+ return if (oneLine.length > 80) oneLine.take(80) + "..." else oneLine
+ }
+
+ private fun sanitizeLine(text: String): String {
+ return text.replace("\n", " ").replace(Regex("\\s+"), " ").trim()
+ }
+
+ private fun buildTranscriptFromMessages(messages: List): String {
+ val raw = messages.sortedBy { it.timestamp }
+ .joinToString("\n") { message ->
+ when (message.role) {
+ ROLE_USER -> "用户: ${sanitizeLine(message.content)}"
+ ROLE_ASSISTANT -> "AI: ${sanitizeLine(message.content)}"
+ else -> "系统: ${sanitizeLine(message.content)}"
+ }
+ }
+ return trimTranscript(raw)
+ }
+
+ private fun deriveTranscript(session: SessionMemory, fallback: String): String {
+ return if (session.messages.isNotEmpty()) buildTranscriptFromMessages(session.messages) else trimTranscript(fallback)
+ }
+
+ private fun migrateLegacyGlobalMemory(state: SessionMemoryState) {
+ if (state.globalMemory.memoryText.isNotBlank()) return
+ val legacy = state.sessions.mapNotNull { session ->
+ listOf(session.condensedSummary, session.habitNotes)
+ .filter { it.isNotBlank() }
+ .joinToString("\n\n")
+ .takeIf { it.isNotBlank() }
+ }
+ if (legacy.isNotEmpty()) {
+ state.globalMemory.memoryText = legacy.joinToString("\n\n")
+ }
+ }
+
+ private fun appendDatedBullet(current: String, date: String, entry: String): String {
+ val normalizedEntry = sanitizeLine(entry)
+ if (normalizedEntry.isBlank()) return current.trim()
+ val header = "[$date]"
+ val trimmed = current.trim()
+ if (trimmed.contains("- $normalizedEntry")) return trimmed
+ return if (trimmed.isBlank()) {
+ "$header\n- $normalizedEntry"
+ } else if (trimmed.contains(header)) {
+ "$trimmed\n- $normalizedEntry"
+ } else {
+ "$trimmed\n\n$header\n- $normalizedEntry"
+ }
+ }
+
+ private fun appendDatedSection(current: String, date: String, header: String, body: String): String {
+ val normalizedBody = body.trim()
+ if (normalizedBody.isBlank()) return current.trim()
+ val section = buildString {
+ append("[$date] ").append(header).append("\n")
+ append(normalizedBody)
+ }
+ val trimmed = current.trim()
+ return if (trimmed.isBlank()) section else "$trimmed\n\n$section"
+ }
+
+ private fun todayTag(): String = dayFormatter.format(Date())
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/apk/claw/android/ui/floating/FloatingTaskInputActivity.kt b/app/src/main/java/com/apk/claw/android/ui/floating/FloatingTaskInputActivity.kt
new file mode 100644
index 0000000..820090b
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/ui/floating/FloatingTaskInputActivity.kt
@@ -0,0 +1,71 @@
+package com.apk.claw.android.ui.floating
+
+import android.os.Bundle
+import android.widget.EditText
+import android.widget.Toast
+import com.apk.claw.android.ClawApplication
+import com.apk.claw.android.R
+import com.apk.claw.android.base.BaseActivity
+import com.apk.claw.android.widget.KButton
+
+class FloatingTaskInputActivity : BaseActivity() {
+
+ private val appViewModel = ClawApplication.appViewModelInstance
+ private var handled = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (!appViewModel.isTaskRunning()) {
+ finish()
+ return
+ }
+
+ setContentView(R.layout.activity_floating_task_input)
+
+ val etFollowUp = findViewById(R.id.etFloatingFollowUp)
+ findViewById(R.id.btnFloatingResume).setOnClickListener {
+ handled = true
+ appViewModel.resumeCurrentTask()
+ finish()
+ }
+ findViewById(R.id.btnFloatingStop).setOnClickListener {
+ handled = true
+ appViewModel.cancelCurrentTask()
+ finish()
+ }
+ findViewById(R.id.btnFloatingSend).setOnClickListener {
+ val text = etFollowUp.text.toString().trim()
+ if (text.isBlank()) {
+ Toast.makeText(this, R.string.floating_task_followup_empty, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ if (appViewModel.submitFloatingFollowUp(text)) {
+ handled = true
+ finish()
+ } else {
+ Toast.makeText(this, R.string.floating_task_followup_failed, Toast.LENGTH_SHORT).show()
+ }
+ }
+ findViewById(R.id.floatingInputBackdrop).setOnClickListener {
+ handled = true
+ appViewModel.resumeCurrentTask()
+ finish()
+ }
+ findViewById(R.id.floatingInputPanel).setOnClickListener { }
+ }
+
+ override fun onBackPressed() {
+ handled = true
+ appViewModel.resumeCurrentTask()
+ super.onBackPressed()
+ }
+
+ override fun onDestroy() {
+ if (!handled && appViewModel.isTaskRunning() && appViewModel.isTaskPaused()) {
+ appViewModel.resumeCurrentTask()
+ }
+ super.onDestroy()
+ }
+
+ override fun isApplyStatusBarPadding(): Boolean = false
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/apk/claw/android/ui/home/HomeActivity.kt b/app/src/main/java/com/apk/claw/android/ui/home/HomeActivity.kt
index 94d54ee..e236d41 100644
--- a/app/src/main/java/com/apk/claw/android/ui/home/HomeActivity.kt
+++ b/app/src/main/java/com/apk/claw/android/ui/home/HomeActivity.kt
@@ -10,7 +10,9 @@ import android.os.Build
import android.Manifest
import android.os.Environment
import android.widget.Toast
+import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
+import com.apk.claw.android.BuildConfig
import com.apk.claw.android.service.ForegroundService
import androidx.core.content.ContextCompat
import android.view.View
@@ -41,6 +43,7 @@ class HomeActivity : BaseActivity() {
private lateinit var cardBattery: PermissionCardView
private lateinit var cardStorage: PermissionCardView
private lateinit var btnCancelTask: KButton
+ private lateinit var tvVersion: TextView
private val handler = Handler(Looper.getMainLooper())
private val checkRunnable = object : Runnable {
@@ -133,6 +136,9 @@ class HomeActivity : BaseActivity() {
updateCancelTaskVisibility()
}
+ tvVersion = findViewById(R.id.tvVersion)
+ tvVersion.text = getString(R.string.home_version, BuildConfig.VERSION_NAME)
+
// 点击卡片申请权限
cardAccessibility.setOnClickListener { requestAccessibilityPermission() }
cardNotification.setOnClickListener { requestNotificationPermission() }
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/LlmConfigActivity.kt b/app/src/main/java/com/apk/claw/android/ui/settings/LlmConfigActivity.kt
index 78a7ef1..b3b8508 100644
--- a/app/src/main/java/com/apk/claw/android/ui/settings/LlmConfigActivity.kt
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/LlmConfigActivity.kt
@@ -27,24 +27,33 @@ class LlmConfigActivity : BaseActivity() {
val etApiKey = findViewById(R.id.etApiKey)
val etBaseUrl = findViewById(R.id.etBaseUrl)
val etModelName = findViewById(R.id.etModelName)
+ val etMaxIterations = findViewById(R.id.etMaxIterations)
etApiKey.setText(KVUtils.getLlmApiKey())
etBaseUrl.setText(KVUtils.getLlmBaseUrl())
etModelName.setText(KVUtils.getLlmModelName())
+ etMaxIterations.setText(KVUtils.getAgentMaxIterations().toString())
findViewById(R.id.btnSave).setOnClickListener {
val apiKey = etApiKey.text.toString().trim()
val baseUrl = etBaseUrl.text.toString().trim()
val modelName = etModelName.text.toString().trim().ifEmpty { "" }
+ val maxIterations = etMaxIterations.text.toString().trim().toIntOrNull()
if (apiKey.isEmpty()) {
Toast.makeText(this, getString(R.string.llm_config_api_key_required), Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
+ if (maxIterations == null || maxIterations <= 0) {
+ Toast.makeText(this, getString(R.string.llm_config_max_iterations_invalid), Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
KVUtils.setLlmApiKey(apiKey)
KVUtils.setLlmBaseUrl(baseUrl)
KVUtils.setLlmModelName(modelName)
+ KVUtils.setAgentMaxIterations(maxIterations)
ClawApplication.appViewModelInstance.updateAgentConfig()
ClawApplication.appViewModelInstance.initAgent()
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/SessionChatActivity.kt b/app/src/main/java/com/apk/claw/android/ui/settings/SessionChatActivity.kt
new file mode 100644
index 0000000..06bec8f
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/SessionChatActivity.kt
@@ -0,0 +1,218 @@
+package com.apk.claw.android.ui.settings
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.AbsListView
+import android.os.Handler
+import android.os.Looper
+import android.view.View
+import android.widget.EditText
+import android.widget.ListView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import com.apk.claw.android.ClawApplication
+import com.apk.claw.android.R
+import com.apk.claw.android.TaskOrchestrator
+import com.apk.claw.android.base.BaseActivity
+import com.apk.claw.android.session.SessionMemoryManager
+import com.apk.claw.android.widget.CommonToolbar
+import com.apk.claw.android.widget.KButton
+
+class SessionChatActivity : BaseActivity() {
+
+ companion object {
+ private const val EXTRA_SESSION_ID = "session_id"
+
+ fun newIntent(context: Context, sessionId: String): Intent {
+ return Intent(context, SessionChatActivity::class.java).putExtra(EXTRA_SESSION_ID, sessionId)
+ }
+ }
+
+ private val appViewModel = ClawApplication.appViewModelInstance
+ private val handler = Handler(Looper.getMainLooper())
+ private val refreshRunnable = object : Runnable {
+ override fun run() {
+ refreshUi(scrollToBottom = false)
+ handler.postDelayed(this, 1000)
+ }
+ }
+
+ private lateinit var listMessages: ListView
+ private lateinit var tvStatus: TextView
+ private lateinit var etInput: EditText
+ private lateinit var btnSend: KButton
+ private lateinit var btnCancelTask: KButton
+ private lateinit var btnEditSummary: KButton
+ private lateinit var btnEditHabits: KButton
+ private lateinit var btnEditPrompt: KButton
+ private lateinit var inputPanel: View
+ private lateinit var adapter: SessionChatAdapter
+ private lateinit var sessionId: String
+ private var inputPanelBasePaddingBottom: Int = 0
+ private var lastMessageCount: Int = -1
+ private var initialScrollDone: Boolean = false
+ private var shouldAutoScroll: Boolean = true
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ sessionId = intent.getStringExtra(EXTRA_SESSION_ID).orEmpty()
+ if (sessionId.isBlank()) {
+ finish()
+ return
+ }
+ setContentView(R.layout.activity_session_chat)
+
+ listMessages = findViewById(R.id.listMessages)
+ tvStatus = findViewById(R.id.tvChatStatus)
+ etInput = findViewById(R.id.etChatInput)
+ btnSend = findViewById(R.id.btnSendChatMessage)
+ btnCancelTask = findViewById(R.id.btnCancelChatTask)
+ btnEditSummary = findViewById(R.id.btnEditSummary)
+ btnEditHabits = findViewById(R.id.btnEditHabits)
+ btnEditPrompt = findViewById(R.id.btnEditPrompt)
+ inputPanel = findViewById(R.id.chatInputPanel)
+ inputPanelBasePaddingBottom = inputPanel.paddingBottom
+ adapter = SessionChatAdapter(this)
+ listMessages.adapter = adapter
+ listMessages.setOnScrollListener(object : AbsListView.OnScrollListener {
+ override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) = Unit
+
+ override fun onScroll(
+ view: AbsListView?,
+ firstVisibleItem: Int,
+ visibleItemCount: Int,
+ totalItemCount: Int
+ ) {
+ shouldAutoScroll = totalItemCount == 0 || isListAtBottom()
+ }
+ })
+
+ findViewById(R.id.toolbar).apply {
+ setTitle(SessionMemoryManager.getSession(sessionId)?.name ?: getString(R.string.session_memory_title))
+ showBackButton(true) { finish() }
+ }
+
+ btnSend.setOnClickListener { sendMessage() }
+ btnCancelTask.setOnClickListener {
+ appViewModel.cancelCurrentTask()
+ refreshUi(scrollToBottom = true)
+ }
+ btnEditSummary.setOnClickListener { openEditor(SessionMemoryManager.FIELD_CONDENSED_SUMMARY) }
+ btnEditHabits.setOnClickListener { openEditor(SessionMemoryManager.FIELD_HABIT_NOTES) }
+ btnEditPrompt.setOnClickListener { openEditor(SessionMemoryManager.FIELD_SESSION_PROMPT) }
+ setupKeyboardInsets()
+
+ refreshUi(scrollToBottom = true)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ SessionMemoryManager.setCurrentSession(sessionId)
+ initialScrollDone = false
+ shouldAutoScroll = true
+ refreshUi(scrollToBottom = true)
+ handler.post(refreshRunnable)
+ }
+
+ override fun onPause() {
+ super.onPause()
+ handler.removeCallbacks(refreshRunnable)
+ }
+
+ private fun sendMessage() {
+ val result = appViewModel.sendLocalSessionMessage(sessionId, etInput.text.toString())
+ when (result) {
+ TaskOrchestrator.LocalMessageResult.STARTED,
+ TaskOrchestrator.LocalMessageResult.QUEUED -> {
+ etInput.setText("")
+ refreshUi(scrollToBottom = true)
+ }
+ TaskOrchestrator.LocalMessageResult.CANCELLED -> {
+ etInput.setText("")
+ Toast.makeText(this, R.string.session_memory_task_cancelled_local, Toast.LENGTH_SHORT).show()
+ refreshUi(scrollToBottom = true)
+ }
+ TaskOrchestrator.LocalMessageResult.BUSY_OTHER_SESSION -> {
+ Toast.makeText(this, R.string.session_memory_busy_other_session, Toast.LENGTH_SHORT).show()
+ }
+ TaskOrchestrator.LocalMessageResult.SERVICE_UNAVAILABLE -> {
+ Toast.makeText(this, R.string.session_memory_service_unavailable, Toast.LENGTH_SHORT).show()
+ }
+ TaskOrchestrator.LocalMessageResult.EMPTY -> Unit
+ }
+ }
+
+ private fun openEditor(field: String) {
+ startActivity(SessionMemoryTextEditorActivity.newIntent(this, sessionId, field))
+ }
+
+ private fun setupKeyboardInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(inputPanel) { view, insets ->
+ val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
+ val navBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
+ val keyboardOverlap = (imeBottom - navBottom).coerceAtLeast(0)
+ view.setPadding(
+ view.paddingLeft,
+ view.paddingTop,
+ view.paddingRight,
+ inputPanelBasePaddingBottom + keyboardOverlap
+ )
+ insets
+ }
+ }
+
+ private fun refreshUi(scrollToBottom: Boolean) {
+ val session = SessionMemoryManager.getSession(sessionId)
+ if (session == null) {
+ finish()
+ return
+ }
+ val messages = SessionMemoryManager.getSessionMessages(sessionId)
+ val hasNewMessages = messages.size > lastMessageCount
+ adapter.submitList(messages)
+ if (scrollToBottom || !initialScrollDone || (hasNewMessages && shouldAutoScroll)) {
+ scrollToBottomAfterLayout(forceDoublePass = scrollToBottom || !initialScrollDone)
+ }
+ lastMessageCount = messages.size
+ tvStatus.text = if (appViewModel.isTaskRunning() && appViewModel.getRunningSessionId() == sessionId) {
+ getString(R.string.session_memory_chat_running)
+ } else {
+ getString(R.string.session_memory_chat_idle)
+ }
+ btnCancelTask.isEnabled = appViewModel.isTaskRunning() && appViewModel.getRunningSessionId() == sessionId
+ }
+
+ private fun scrollToBottomAfterLayout(forceDoublePass: Boolean) {
+ listMessages.post {
+ if (adapter.count > 0) {
+ listMessages.setSelection(adapter.count - 1)
+ if (forceDoublePass) {
+ listMessages.post {
+ if (adapter.count > 0) {
+ listMessages.setSelection(adapter.count - 1)
+ }
+ shouldAutoScroll = true
+ initialScrollDone = true
+ }
+ } else {
+ shouldAutoScroll = true
+ initialScrollDone = true
+ }
+ } else {
+ shouldAutoScroll = true
+ initialScrollDone = true
+ }
+ }
+ }
+
+ private fun isListAtBottom(): Boolean {
+ if (adapter.count == 0) return true
+ val lastVisible = listMessages.lastVisiblePosition
+ if (lastVisible < adapter.count - 1) return false
+ val lastChild = listMessages.getChildAt(listMessages.childCount - 1) ?: return false
+ return lastChild.bottom <= listMessages.height - listMessages.paddingBottom
+ }
+}
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/SessionChatAdapter.kt b/app/src/main/java/com/apk/claw/android/ui/settings/SessionChatAdapter.kt
new file mode 100644
index 0000000..2728665
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/SessionChatAdapter.kt
@@ -0,0 +1,90 @@
+package com.apk.claw.android.ui.settings
+
+import android.content.Context
+import android.graphics.drawable.GradientDrawable
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseAdapter
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import com.apk.claw.android.R
+import com.apk.claw.android.session.SessionChatMessage
+import com.apk.claw.android.session.SessionMemoryManager
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class SessionChatAdapter(context: Context) : BaseAdapter() {
+
+ private val inflater = LayoutInflater.from(context)
+ private val appContext = context
+ private val timeFormatter = SimpleDateFormat("HH:mm", Locale.getDefault())
+ private var items: List = emptyList()
+
+ override fun getCount(): Int = items.size
+
+ override fun getItem(position: Int): SessionChatMessage = items[position]
+
+ override fun getItemId(position: Int): Long = position.toLong()
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view = convertView ?: inflater.inflate(R.layout.item_session_chat_message, parent, false)
+ val holder = (view.tag as? ViewHolder) ?: ViewHolder(view).also { view.tag = it }
+ val item = getItem(position)
+ val isUser = item.role == SessionMemoryManager.ROLE_USER
+ val isSystem = item.role == SessionMemoryManager.ROLE_SYSTEM
+
+ holder.container.gravity = when {
+ isUser -> Gravity.END
+ isSystem -> Gravity.CENTER_HORIZONTAL
+ else -> Gravity.START
+ }
+ holder.tvRole.text = when (item.role) {
+ SessionMemoryManager.ROLE_USER -> parent.context.getString(R.string.session_memory_role_user)
+ SessionMemoryManager.ROLE_SYSTEM -> parent.context.getString(R.string.session_memory_role_system)
+ else -> parent.context.getString(R.string.session_memory_role_assistant)
+ }
+ holder.tvMessage.text = item.content
+ holder.tvTime.text = timeFormatter.format(Date(item.timestamp))
+ holder.tvMessage.background = GradientDrawable().apply {
+ cornerRadius = 28f
+ setColor(
+ ContextCompat.getColor(
+ appContext,
+ when {
+ isUser -> R.color.colorSessionBubbleUser
+ isSystem -> R.color.colorSessionBubbleSystem
+ else -> R.color.colorSessionBubbleAssistant
+ }
+ )
+ )
+ setStroke(
+ 2,
+ ContextCompat.getColor(
+ appContext,
+ when {
+ isUser -> R.color.colorSessionBubbleUserBorder
+ isSystem -> R.color.colorSessionBubbleSystemBorder
+ else -> R.color.colorSessionBubbleAssistantBorder
+ }
+ )
+ )
+ }
+ return view
+ }
+
+ fun submitList(items: List) {
+ this.items = items
+ notifyDataSetChanged()
+ }
+
+ private class ViewHolder(view: View) {
+ val container: LinearLayout = view.findViewById(R.id.layoutMessageContainer)
+ val tvRole: TextView = view.findViewById(R.id.tvMessageRole)
+ val tvMessage: TextView = view.findViewById(R.id.tvMessageContent)
+ val tvTime: TextView = view.findViewById(R.id.tvMessageTime)
+ }
+}
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/SessionListAdapter.kt b/app/src/main/java/com/apk/claw/android/ui/settings/SessionListAdapter.kt
new file mode 100644
index 0000000..1a4a6eb
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/SessionListAdapter.kt
@@ -0,0 +1,64 @@
+package com.apk.claw.android.ui.settings
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseAdapter
+import android.widget.TextView
+import com.apk.claw.android.R
+import com.apk.claw.android.session.SessionMemory
+import com.apk.claw.android.widget.KButton
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class SessionListAdapter(
+ context: Context,
+ private val onOpen: (SessionMemory) -> Unit,
+ private val onDelete: (SessionMemory) -> Unit
+) : BaseAdapter() {
+
+ private val inflater = LayoutInflater.from(context)
+ private val timeFormatter = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
+ private var items: List = emptyList()
+ private var currentSessionId: String = ""
+
+ override fun getCount(): Int = items.size
+
+ override fun getItem(position: Int): SessionMemory = items[position]
+
+ override fun getItemId(position: Int): Long = position.toLong()
+
+ @SuppressLint("SetTextI18n")
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view = convertView ?: inflater.inflate(R.layout.item_session_entry, parent, false)
+ val holder = (view.tag as? ViewHolder) ?: ViewHolder(view).also { view.tag = it }
+ val item = getItem(position)
+ holder.tvName.text = item.name
+ holder.tvMeta.text = parent.context.getString(
+ R.string.session_memory_updated_at,
+ timeFormatter.format(Date(item.updatedAt))
+ )
+ holder.tvCurrent.visibility = if (item.id == currentSessionId) View.VISIBLE else View.GONE
+ holder.btnOpen.setOnClickListener { onOpen(item) }
+ holder.btnDelete.setOnClickListener { onDelete(item) }
+ view.setOnClickListener { onOpen(item) }
+ return view
+ }
+
+ fun submitList(items: List, currentSessionId: String) {
+ this.items = items
+ this.currentSessionId = currentSessionId
+ notifyDataSetChanged()
+ }
+
+ private class ViewHolder(view: View) {
+ val tvName: TextView = view.findViewById(R.id.tvSessionName)
+ val tvMeta: TextView = view.findViewById(R.id.tvSessionMeta)
+ val tvCurrent: TextView = view.findViewById(R.id.tvCurrentSession)
+ val btnOpen: KButton = view.findViewById(R.id.btnOpenSession)
+ val btnDelete: KButton = view.findViewById(R.id.btnDeleteSession)
+ }
+}
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryActivity.kt b/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryActivity.kt
new file mode 100644
index 0000000..82d07bc
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryActivity.kt
@@ -0,0 +1,125 @@
+package com.apk.claw.android.ui.settings
+
+import android.os.Bundle
+import android.widget.EditText
+import android.widget.ListView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.widget.SwitchCompat
+import com.apk.claw.android.R
+import com.apk.claw.android.base.BaseActivity
+import com.apk.claw.android.session.SessionMemoryManager
+import com.apk.claw.android.widget.AlertDialog
+import com.apk.claw.android.widget.CommonToolbar
+import com.apk.claw.android.widget.KButton
+
+class SessionMemoryActivity : BaseActivity() {
+
+ private lateinit var switchEnableSession: SwitchCompat
+ private lateinit var switchEnableMemory: SwitchCompat
+ private lateinit var switchEnableGlobalPrompt: SwitchCompat
+ private lateinit var etNewSessionName: EditText
+ private lateinit var btnCreateSession: KButton
+ private lateinit var btnGlobalMemory: KButton
+ private lateinit var btnGlobalPrompt: KButton
+ private lateinit var listSessions: ListView
+ private lateinit var tvEmptyState: TextView
+ private lateinit var adapter: SessionListAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_session_memory)
+
+ findViewById(R.id.toolbar).apply {
+ setTitle(getString(R.string.session_memory_title))
+ showBackButton(true) { finish() }
+ }
+
+ switchEnableSession = findViewById(R.id.switchEnableSession)
+ switchEnableMemory = findViewById(R.id.switchEnableMemory)
+ switchEnableGlobalPrompt = findViewById(R.id.switchEnableGlobalPrompt)
+ etNewSessionName = findViewById(R.id.etNewSessionName)
+ btnCreateSession = findViewById(R.id.btnCreateSession)
+ btnGlobalMemory = findViewById(R.id.btnGlobalMemory)
+ btnGlobalPrompt = findViewById(R.id.btnGlobalPrompt)
+ listSessions = findViewById(R.id.listSessions)
+ tvEmptyState = findViewById(R.id.tvEmptySessions)
+
+ adapter = SessionListAdapter(
+ context = this,
+ onOpen = { session ->
+ SessionMemoryManager.setCurrentSession(session.id)
+ startActivity(SessionChatActivity.newIntent(this, session.id))
+ },
+ onDelete = { session -> confirmDelete(session.id, session.name) }
+ )
+ listSessions.adapter = adapter
+
+ switchEnableSession.isChecked = SessionMemoryManager.isSessionEnabled()
+ switchEnableMemory.isChecked = SessionMemoryManager.isMemoryEnabled()
+ switchEnableGlobalPrompt.isChecked = SessionMemoryManager.isGlobalPromptEnabled()
+ switchEnableSession.setOnCheckedChangeListener { _, isChecked ->
+ SessionMemoryManager.setSessionEnabled(isChecked)
+ }
+ switchEnableMemory.setOnCheckedChangeListener { _, isChecked ->
+ SessionMemoryManager.setMemoryEnabled(isChecked)
+ }
+ switchEnableGlobalPrompt.setOnCheckedChangeListener { _, isChecked ->
+ SessionMemoryManager.setGlobalPromptEnabled(isChecked)
+ }
+
+ btnGlobalMemory.setOnClickListener {
+ startActivity(
+ SessionMemoryTextEditorActivity.newIntent(
+ this,
+ "",
+ SessionMemoryManager.FIELD_GLOBAL_MEMORY
+ )
+ )
+ }
+ btnGlobalPrompt.setOnClickListener {
+ startActivity(
+ SessionMemoryTextEditorActivity.newIntent(
+ this,
+ "",
+ SessionMemoryManager.FIELD_GLOBAL_PROMPT
+ )
+ )
+ }
+
+ btnCreateSession.setOnClickListener {
+ val created = SessionMemoryManager.createSession(etNewSessionName.text.toString().trim())
+ etNewSessionName.setText("")
+ Toast.makeText(this, getString(R.string.session_memory_created, created.name), Toast.LENGTH_SHORT).show()
+ refreshSessions()
+ startActivity(SessionChatActivity.newIntent(this, created.id))
+ }
+
+ refreshSessions()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshSessions()
+ }
+
+ private fun refreshSessions() {
+ val sessions = SessionMemoryManager.listSessions()
+ adapter.submitList(sessions, SessionMemoryManager.getCurrentSessionId())
+ tvEmptyState.visibility = if (sessions.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE
+ }
+
+ private fun confirmDelete(sessionId: String, sessionName: String) {
+ AlertDialog.showWarm(
+ context = this,
+ title = getString(R.string.session_memory_delete_title),
+ message = getString(R.string.session_memory_delete_message, sessionName),
+ actionTitle = getString(R.string.session_memory_delete_action),
+ onAction = {
+ SessionMemoryManager.deleteSession(sessionId)
+ refreshSessions()
+ Toast.makeText(this, R.string.session_memory_deleted, Toast.LENGTH_SHORT).show()
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryDetailActivity.kt b/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryDetailActivity.kt
new file mode 100644
index 0000000..1a8d8fb
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryDetailActivity.kt
@@ -0,0 +1,111 @@
+package com.apk.claw.android.ui.settings
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.widget.EditText
+import android.widget.TextView
+import android.widget.Toast
+import com.apk.claw.android.R
+import com.apk.claw.android.base.BaseActivity
+import com.apk.claw.android.session.SessionMemoryManager
+import com.apk.claw.android.widget.CommonToolbar
+import com.apk.claw.android.widget.KButton
+
+class SessionMemoryDetailActivity : BaseActivity() {
+
+ companion object {
+ private const val EXTRA_SESSION_ID = "extra_session_id"
+
+ fun newIntent(context: Context, sessionId: String): Intent {
+ return Intent(context, SessionMemoryDetailActivity::class.java)
+ .putExtra(EXTRA_SESSION_ID, sessionId)
+ }
+ }
+
+ private lateinit var etSessionName: EditText
+ private lateinit var tvTranscriptPreview: TextView
+ private lateinit var etSessionTranscript: EditText
+ private lateinit var etCondensedSummary: EditText
+ private lateinit var etHabitNotes: EditText
+ private lateinit var etErrorLessons: EditText
+ private lateinit var btnSave: KButton
+
+ private var sessionId: String = ""
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_session_memory_detail)
+
+ sessionId = intent.getStringExtra(EXTRA_SESSION_ID).orEmpty()
+ .ifBlank { SessionMemoryManager.getCurrentSessionId() }
+
+ findViewById(R.id.toolbar).apply {
+ setTitle(getString(R.string.session_memory_detail_title))
+ showBackButton(true) { finish() }
+ }
+
+ etSessionName = findViewById(R.id.etSelectedSessionName)
+ tvTranscriptPreview = findViewById(R.id.tvTranscriptPreview)
+ etSessionTranscript = findViewById(R.id.etSessionTranscript)
+ etCondensedSummary = findViewById(R.id.etCondensedSummary)
+ etHabitNotes = findViewById(R.id.etHabitNotes)
+ etErrorLessons = findViewById(R.id.etErrorLessons)
+ btnSave = findViewById(R.id.btnSaveSessionMemory)
+
+ etSessionTranscript.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+ override fun afterTextChanged(s: Editable?) {
+ tvTranscriptPreview.text = SessionMemoryUiFormatter.buildTranscriptPreview(
+ this@SessionMemoryDetailActivity,
+ s?.toString().orEmpty()
+ )
+ }
+ })
+
+ btnSave.setOnClickListener {
+ val updated = SessionMemoryManager.updateSessionContent(
+ sessionId = sessionId,
+ name = etSessionName.text.toString().trim(),
+ sessionTranscript = etSessionTranscript.text.toString().trim(),
+ condensedSummary = etCondensedSummary.text.toString().trim(),
+ habitNotes = parseLines(etHabitNotes),
+ errorLessons = parseLines(etErrorLessons)
+ )
+ if (!updated) {
+ Toast.makeText(this, R.string.session_memory_choose_session, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ Toast.makeText(this, R.string.session_memory_saved, Toast.LENGTH_SHORT).show()
+ setResult(RESULT_OK)
+ finish()
+ }
+
+ loadSession()
+ }
+
+ private fun loadSession() {
+ val session = SessionMemoryManager.getSession(sessionId)
+ if (session == null) {
+ Toast.makeText(this, R.string.session_memory_choose_session, Toast.LENGTH_SHORT).show()
+ finish()
+ return
+ }
+ etSessionName.setText(session.name)
+ etSessionTranscript.setText(session.sessionTranscript)
+ tvTranscriptPreview.text = SessionMemoryUiFormatter.buildTranscriptPreview(this, session.sessionTranscript)
+ etCondensedSummary.setText(session.condensedSummary)
+ etHabitNotes.setText(session.habitNotes)
+ etErrorLessons.setText(session.sessionPrompt)
+ }
+
+ private fun parseLines(editText: EditText): List {
+ return editText.text.toString()
+ .lines()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ }
+}
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryTextEditorActivity.kt b/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryTextEditorActivity.kt
new file mode 100644
index 0000000..18c4534
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryTextEditorActivity.kt
@@ -0,0 +1,100 @@
+package com.apk.claw.android.ui.settings
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.EditText
+import android.widget.Toast
+import android.view.View
+import com.apk.claw.android.R
+import com.apk.claw.android.base.BaseActivity
+import com.apk.claw.android.session.SessionMemoryManager
+import com.apk.claw.android.widget.CommonToolbar
+import com.apk.claw.android.widget.KButton
+
+class SessionMemoryTextEditorActivity : BaseActivity() {
+
+ companion object {
+ private const val EXTRA_SESSION_ID = "session_id"
+ private const val EXTRA_FIELD = "field"
+
+ fun newIntent(context: Context, sessionId: String, field: String): Intent {
+ return Intent(context, SessionMemoryTextEditorActivity::class.java)
+ .putExtra(EXTRA_SESSION_ID, sessionId)
+ .putExtra(EXTRA_FIELD, field)
+ }
+ }
+
+ private lateinit var sessionId: String
+ private lateinit var field: String
+ private lateinit var etContent: EditText
+ private lateinit var btnSave: KButton
+ private lateinit var btnWriteGlobal: KButton
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ sessionId = intent.getStringExtra(EXTRA_SESSION_ID).orEmpty()
+ field = intent.getStringExtra(EXTRA_FIELD).orEmpty()
+ if (field.isBlank()) {
+ finish()
+ return
+ }
+ setContentView(R.layout.activity_session_memory_editor)
+
+ etContent = findViewById(R.id.etMemoryEditorContent)
+ btnSave = findViewById(R.id.btnSaveMemoryEditor)
+ btnWriteGlobal = findViewById(R.id.btnWriteGlobalMemoryEditor)
+ findViewById(R.id.toolbar).apply {
+ setTitle(getTitleText(field))
+ showBackButton(true) { finish() }
+ }
+ etContent.hint = getEditorHint(field)
+ etContent.setText(SessionMemoryManager.getMemoryText(sessionId, field))
+ btnSave.setOnClickListener {
+ SessionMemoryManager.updateMemoryText(sessionId, field, etContent.text.toString())
+ Toast.makeText(this, R.string.session_memory_editor_saved, Toast.LENGTH_SHORT).show()
+ finish()
+ }
+ btnWriteGlobal.visibility = if (shouldShowWriteGlobal()) View.VISIBLE else View.GONE
+ btnWriteGlobal.setOnClickListener {
+ val content = etContent.text.toString().trim()
+ if (content.isBlank()) {
+ Toast.makeText(this, R.string.session_memory_write_global_empty, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ if (SessionMemoryManager.writeSessionFieldToGlobalMemory(sessionId, field, content)) {
+ Toast.makeText(this, R.string.session_memory_write_global_success, Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(this, R.string.common_load_failed, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun getTitleText(field: String): String {
+ return when (field) {
+ SessionMemoryManager.FIELD_CONDENSED_SUMMARY -> getString(R.string.session_memory_condensed_summary)
+ SessionMemoryManager.FIELD_HABIT_NOTES -> getString(R.string.session_memory_habit_notes)
+ SessionMemoryManager.FIELD_SESSION_PROMPT -> getString(R.string.session_memory_session_prompt)
+ SessionMemoryManager.FIELD_GLOBAL_MEMORY -> getString(R.string.session_memory_global_memory)
+ SessionMemoryManager.FIELD_GLOBAL_PROMPT -> getString(R.string.session_memory_global_prompt)
+ else -> getString(R.string.session_memory_title)
+ }
+ }
+
+ private fun getEditorHint(field: String): String {
+ return when (field) {
+ SessionMemoryManager.FIELD_CONDENSED_SUMMARY -> getString(R.string.session_memory_condensed_summary_hint)
+ SessionMemoryManager.FIELD_HABIT_NOTES -> getString(R.string.session_memory_habit_notes_hint)
+ SessionMemoryManager.FIELD_SESSION_PROMPT -> getString(R.string.session_memory_session_prompt_hint)
+ SessionMemoryManager.FIELD_GLOBAL_MEMORY -> getString(R.string.session_memory_global_memory_hint)
+ SessionMemoryManager.FIELD_GLOBAL_PROMPT -> getString(R.string.session_memory_global_prompt_hint)
+ else -> getString(R.string.session_memory_editor_hint)
+ }
+ }
+
+ private fun shouldShowWriteGlobal(): Boolean {
+ if (sessionId.isBlank()) return false
+ return field == SessionMemoryManager.FIELD_CONDENSED_SUMMARY ||
+ field == SessionMemoryManager.FIELD_HABIT_NOTES
+ }
+}
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryUiFormatter.kt b/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryUiFormatter.kt
new file mode 100644
index 0000000..831ec6b
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/SessionMemoryUiFormatter.kt
@@ -0,0 +1,73 @@
+package com.apk.claw.android.ui.settings
+
+import android.content.Context
+import com.apk.claw.android.R
+import com.apk.claw.android.session.SessionMemory
+
+object SessionMemoryUiFormatter {
+
+ private val sectionPrefixes = listOf(
+ "用户任务:",
+ "AI执行记录:",
+ "补充指令:",
+ "任务结果:",
+ "任务错误:",
+ "任务已取消:"
+ )
+
+ fun buildTranscriptPreview(context: Context, transcript: String): String {
+ if (transcript.isBlank()) {
+ return context.getString(R.string.session_memory_preview_empty)
+ }
+ val blocks = splitTranscriptBlocks(transcript)
+ .asReversed()
+ .take(8)
+ return blocks.joinToString("\n\n")
+ }
+
+ fun buildMemoryPreview(context: Context, session: SessionMemory?): String {
+ if (session == null) {
+ return context.getString(R.string.session_memory_memory_empty)
+ }
+
+ val sections = mutableListOf()
+ if (session.condensedSummary.isNotBlank()) {
+ sections += context.getString(R.string.session_memory_condensed_summary) + "\n" + session.condensedSummary
+ }
+ if (session.habitNotes.isNotBlank()) {
+ val habits = session.habitNotes.lines().map { it.trim() }.filter { it.isNotBlank() }.take(4)
+ sections += context.getString(R.string.session_memory_habit_notes) + "\n" + habits.joinToString("\n") { "- $it" }
+ }
+ if (session.sessionPrompt.isNotBlank()) {
+ sections += context.getString(R.string.session_memory_session_prompt) + "\n" + session.sessionPrompt
+ }
+
+ if (sections.isEmpty()) {
+ return context.getString(R.string.session_memory_memory_empty)
+ }
+ return sections.joinToString("\n\n")
+ }
+
+ private fun splitTranscriptBlocks(transcript: String): List {
+ val result = mutableListOf()
+ val current = StringBuilder()
+ transcript.lines().forEach { rawLine ->
+ val line = rawLine.trimEnd()
+ if (line.isBlank() && current.isEmpty()) {
+ return@forEach
+ }
+ if (sectionPrefixes.any { line.startsWith(it) } && current.isNotEmpty()) {
+ result += current.toString().trim()
+ current.clear()
+ }
+ if (current.isNotEmpty()) {
+ current.append("\n")
+ }
+ current.append(line)
+ }
+ if (current.isNotEmpty()) {
+ result += current.toString().trim()
+ }
+ return result.filter { it.isNotBlank() }
+ }
+}
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/SettingsActivity.kt b/app/src/main/java/com/apk/claw/android/ui/settings/SettingsActivity.kt
index 30f6521..d6123ce 100644
--- a/app/src/main/java/com/apk/claw/android/ui/settings/SettingsActivity.kt
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/SettingsActivity.kt
@@ -35,6 +35,14 @@ class SettingsActivity : BaseActivity() {
viewModel.refresh()
}
+ private val sessionMemoryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
+ viewModel.refresh()
+ }
+
+ private val waitTimingLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
+ viewModel.refresh()
+ }
+
// 注册通道配置结果回调
private val channelConfigLauncher = ChannelConfigActivity.registerLauncher(this) { result ->
result?.let {
@@ -125,9 +133,25 @@ class SettingsActivity : BaseActivity() {
leadingIcon = R.drawable.icon_current_model,
title = getString(R.string.menu_llm_config),
onClick = { viewModel.onMenuItemClick(SettingsViewModel.MenuAction.LLM_CONFIG) },
- showDivider = false
+ showDivider = true
)
menuItems[SettingsViewModel.MenuAction.LLM_CONFIG.name]?.setLeadingIconColor(getColor(R.color.colorTextPrimary))
+
+ menuItems[SettingsViewModel.MenuAction.SESSION_MEMORY.name] = modelGroup.addMenuItem(
+ leadingIcon = R.drawable.ic_storage,
+ title = getString(R.string.menu_session_memory),
+ onClick = { viewModel.onMenuItemClick(SettingsViewModel.MenuAction.SESSION_MEMORY) },
+ showDivider = true
+ )
+ menuItems[SettingsViewModel.MenuAction.SESSION_MEMORY.name]?.setLeadingIconColor(getColor(R.color.colorTextPrimary))
+
+ menuItems[SettingsViewModel.MenuAction.WAIT_TIMING.name] = modelGroup.addMenuItem(
+ leadingIcon = R.drawable.ic_settings,
+ title = getString(R.string.menu_wait_timing),
+ onClick = { viewModel.onMenuItemClick(SettingsViewModel.MenuAction.WAIT_TIMING) },
+ showDivider = false
+ )
+ menuItems[SettingsViewModel.MenuAction.WAIT_TIMING.name]?.setLeadingIconColor(getColor(R.color.colorTextPrimary))
}
private fun observeViewModel() {
@@ -231,6 +255,12 @@ class SettingsActivity : BaseActivity() {
SettingsViewModel.MenuAction.LLM_CONFIG -> {
llmConfigLauncher.launch(Intent(this@SettingsActivity, LlmConfigActivity::class.java))
}
+ SettingsViewModel.MenuAction.SESSION_MEMORY -> {
+ sessionMemoryLauncher.launch(Intent(this@SettingsActivity, SessionMemoryActivity::class.java))
+ }
+ SettingsViewModel.MenuAction.WAIT_TIMING -> {
+ waitTimingLauncher.launch(Intent(this@SettingsActivity, WaitTimingSettingsActivity::class.java))
+ }
null -> {}
else -> {}
}
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/apk/claw/android/ui/settings/SettingsViewModel.kt
index 6a10094..45fc0f6 100644
--- a/app/src/main/java/com/apk/claw/android/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/SettingsViewModel.kt
@@ -8,6 +8,7 @@ import com.apk.claw.android.ClawApplication
import com.apk.claw.android.R
import com.apk.claw.android.channel.ChannelManager
import com.apk.claw.android.server.ConfigServerManager
+import com.apk.claw.android.session.SessionMemoryManager
import com.apk.claw.android.utils.KVUtils
import com.apk.claw.android.widget.QRCodeDialog
import com.apk.claw.android.utils.XLog
@@ -47,6 +48,8 @@ class SettingsViewModel : ViewModel() {
val wechatBotToken = KVUtils.getWechatBotToken().isNotEmpty()
val map = mapOf(
MenuAction.LLM_CONFIG.name to SettingValue.Text(if (KVUtils.hasLlmConfig()) KVUtils.getLlmModelName() else ClawApplication.instance.getString(R.string.common_unconfigured)),
+ MenuAction.SESSION_MEMORY.name to SettingValue.Text(SessionMemoryManager.getStatusSummary()),
+ MenuAction.WAIT_TIMING.name to SettingValue.Text(ClawApplication.instance.getString(R.string.wait_timing_summary, KVUtils.getWaitScalePercent())),
MenuAction.DINGDING.name to SettingValue.Text(ClawApplication.instance.getString(if (dingtalkAppKey && dingtalkAppSecret) R.string.common_bound else R.string.common_unbound)),
MenuAction.FEISHU.name to SettingValue.Text(ClawApplication.instance.getString(if (feishuAppId && feishuAppSecret) R.string.common_bound else R.string.common_unbound)),
MenuAction.QQ.name to SettingValue.Text(ClawApplication.instance.getString(if (qqAppId && qqAppSecret) R.string.common_bound else R.string.common_unbound)),
@@ -303,6 +306,8 @@ class SettingsViewModel : ViewModel() {
enum class MenuAction {
DINGDING, FEISHU, QQ, DISCORD, TELEGRAM, WECHAT,
LAN_CONFIG,
- LLM_CONFIG
+ LLM_CONFIG,
+ SESSION_MEMORY,
+ WAIT_TIMING
}
}
diff --git a/app/src/main/java/com/apk/claw/android/ui/settings/WaitTimingSettingsActivity.kt b/app/src/main/java/com/apk/claw/android/ui/settings/WaitTimingSettingsActivity.kt
new file mode 100644
index 0000000..53ed84e
--- /dev/null
+++ b/app/src/main/java/com/apk/claw/android/ui/settings/WaitTimingSettingsActivity.kt
@@ -0,0 +1,91 @@
+package com.apk.claw.android.ui.settings
+
+import android.os.Bundle
+import android.widget.EditText
+import android.widget.TextView
+import android.widget.Toast
+import com.apk.claw.android.ClawApplication
+import com.apk.claw.android.R
+import com.apk.claw.android.base.BaseActivity
+import com.apk.claw.android.utils.KVUtils
+import com.apk.claw.android.widget.CommonToolbar
+import com.apk.claw.android.widget.KButton
+
+class WaitTimingSettingsActivity : BaseActivity() {
+
+ private lateinit var etScalePercent: EditText
+ private lateinit var etTapWaitMs: EditText
+ private lateinit var etOpenAppWaitMs: EditText
+ private lateinit var etInputWaitMs: EditText
+ private lateinit var tvEffectiveSummary: TextView
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_wait_timing_settings)
+
+ findViewById(R.id.toolbar).apply {
+ setTitle(getString(R.string.wait_timing_title))
+ showBackButton(true) { finish() }
+ }
+
+ etScalePercent = findViewById(R.id.etWaitScalePercent)
+ etTapWaitMs = findViewById(R.id.etTapWaitMs)
+ etOpenAppWaitMs = findViewById(R.id.etOpenAppWaitMs)
+ etInputWaitMs = findViewById(R.id.etInputWaitMs)
+ tvEffectiveSummary = findViewById(R.id.tvEffectiveWaitSummary)
+
+ bindCurrentValues()
+
+ findViewById(R.id.btnRestoreWaitDefaults).setOnClickListener {
+ KVUtils.resetWaitTimingDefaults()
+ bindCurrentValues()
+ applyAgentConfig()
+ Toast.makeText(this, R.string.wait_timing_restored, Toast.LENGTH_SHORT).show()
+ }
+
+ findViewById(R.id.btnSaveWaitTiming).setOnClickListener {
+ val scale = etScalePercent.text.toString().trim().toIntOrNull()
+ val tap = etTapWaitMs.text.toString().trim().toIntOrNull()
+ val openApp = etOpenAppWaitMs.text.toString().trim().toIntOrNull()
+ val input = etInputWaitMs.text.toString().trim().toIntOrNull()
+
+ if (scale == null || scale !in 0..200) {
+ Toast.makeText(this, R.string.wait_timing_invalid_scale, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ if (tap == null || tap < 0 || openApp == null || openApp < 0 || input == null || input < 0) {
+ Toast.makeText(this, R.string.wait_timing_invalid_wait, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ KVUtils.setWaitScalePercent(scale)
+ KVUtils.setTapWaitAfterMs(tap)
+ KVUtils.setOpenAppWaitAfterMs(openApp)
+ KVUtils.setInputWaitAfterMs(input)
+ bindCurrentValues()
+ applyAgentConfig()
+ Toast.makeText(this, R.string.wait_timing_saved, Toast.LENGTH_SHORT).show()
+ setResult(RESULT_OK)
+ finish()
+ }
+ }
+
+ private fun bindCurrentValues() {
+ etScalePercent.setText(KVUtils.getWaitScalePercent().toString())
+ etTapWaitMs.setText(KVUtils.getTapWaitAfterMs().toString())
+ etOpenAppWaitMs.setText(KVUtils.getOpenAppWaitAfterMs().toString())
+ etInputWaitMs.setText(KVUtils.getInputWaitAfterMs().toString())
+ tvEffectiveSummary.text = getString(
+ R.string.wait_timing_effective_summary,
+ KVUtils.getEffectiveTapWaitAfterMs(),
+ KVUtils.getEffectiveOpenAppWaitAfterMs(),
+ KVUtils.getEffectiveInputWaitAfterMs()
+ )
+ }
+
+ private fun applyAgentConfig() {
+ ClawApplication.appViewModelInstance.updateAgentConfig()
+ ClawApplication.appViewModelInstance.initAgent()
+ ClawApplication.appViewModelInstance.afterInit()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/apk/claw/android/utils/KVUtils.kt b/app/src/main/java/com/apk/claw/android/utils/KVUtils.kt
index 6c4295f..c0ee93e 100644
--- a/app/src/main/java/com/apk/claw/android/utils/KVUtils.kt
+++ b/app/src/main/java/com/apk/claw/android/utils/KVUtils.kt
@@ -16,6 +16,12 @@ import com.tencent.mmkv.MMKV
*/
object KVUtils {
+ const val DEFAULT_WAIT_SCALE_PERCENT = 100
+ const val DEFAULT_TAP_WAIT_AFTER_MS = 2000
+ const val DEFAULT_OPEN_APP_WAIT_AFTER_MS = 3000
+ const val DEFAULT_INPUT_WAIT_AFTER_MS = 1000
+ private const val MAX_EFFECTIVE_WAIT_AFTER_MS = 10000
+
// 钉钉配置
const val KEY_DINGTALK_APP_KEY = "DEFAULT_DINGTALK_APP_KEY"
@@ -192,6 +198,12 @@ object KVUtils {
private const val KEY_LLM_API_KEY = "KEY_LLM_API_KEY"
private const val KEY_LLM_BASE_URL = "KEY_LLM_BASE_URL"
private const val KEY_LLM_MODEL_NAME = "KEY_LLM_MODEL_NAME"
+ private const val KEY_AGENT_MAX_ITERATIONS = "KEY_AGENT_MAX_ITERATIONS"
+ private const val KEY_WAIT_SCALE_PERCENT = "KEY_WAIT_SCALE_PERCENT"
+ private const val KEY_TAP_WAIT_AFTER_MS = "KEY_TAP_WAIT_AFTER_MS"
+ private const val KEY_OPEN_APP_WAIT_AFTER_MS = "KEY_OPEN_APP_WAIT_AFTER_MS"
+ private const val KEY_INPUT_WAIT_AFTER_MS = "KEY_INPUT_WAIT_AFTER_MS"
+ private const val DEFAULT_AGENT_MAX_ITERATIONS = 60
fun getLlmApiKey(): String = getString(KEY_LLM_API_KEY, "")
fun setLlmApiKey(value: String) = putString(KEY_LLM_API_KEY, value)
@@ -199,6 +211,38 @@ object KVUtils {
fun setLlmBaseUrl(value: String) = putString(KEY_LLM_BASE_URL, value)
fun getLlmModelName(): String = getString(KEY_LLM_MODEL_NAME, "")
fun setLlmModelName(value: String) = putString(KEY_LLM_MODEL_NAME, value)
+ fun getAgentMaxIterations(): Int = getInt(KEY_AGENT_MAX_ITERATIONS, DEFAULT_AGENT_MAX_ITERATIONS)
+ fun setAgentMaxIterations(value: Int) = putInt(KEY_AGENT_MAX_ITERATIONS, value)
+
+ fun getWaitScalePercent(): Int = getInt(KEY_WAIT_SCALE_PERCENT, DEFAULT_WAIT_SCALE_PERCENT).coerceIn(0, 200)
+ fun setWaitScalePercent(value: Int) = putInt(KEY_WAIT_SCALE_PERCENT, value.coerceIn(0, 200))
+
+ fun getTapWaitAfterMs(): Int = getInt(KEY_TAP_WAIT_AFTER_MS, DEFAULT_TAP_WAIT_AFTER_MS).coerceAtLeast(0)
+ fun setTapWaitAfterMs(value: Int) = putInt(KEY_TAP_WAIT_AFTER_MS, value.coerceAtLeast(0))
+
+ fun getOpenAppWaitAfterMs(): Int = getInt(KEY_OPEN_APP_WAIT_AFTER_MS, DEFAULT_OPEN_APP_WAIT_AFTER_MS).coerceAtLeast(0)
+ fun setOpenAppWaitAfterMs(value: Int) = putInt(KEY_OPEN_APP_WAIT_AFTER_MS, value.coerceAtLeast(0))
+
+ fun getInputWaitAfterMs(): Int = getInt(KEY_INPUT_WAIT_AFTER_MS, DEFAULT_INPUT_WAIT_AFTER_MS).coerceAtLeast(0)
+ fun setInputWaitAfterMs(value: Int) = putInt(KEY_INPUT_WAIT_AFTER_MS, value.coerceAtLeast(0))
+
+ fun resetWaitTimingDefaults() {
+ setWaitScalePercent(DEFAULT_WAIT_SCALE_PERCENT)
+ setTapWaitAfterMs(DEFAULT_TAP_WAIT_AFTER_MS)
+ setOpenAppWaitAfterMs(DEFAULT_OPEN_APP_WAIT_AFTER_MS)
+ setInputWaitAfterMs(DEFAULT_INPUT_WAIT_AFTER_MS)
+ }
+
+ fun getEffectiveTapWaitAfterMs(): Int = scaleWait(getTapWaitAfterMs())
+
+ fun getEffectiveOpenAppWaitAfterMs(): Int = scaleWait(getOpenAppWaitAfterMs())
+
+ fun getEffectiveInputWaitAfterMs(): Int = scaleWait(getInputWaitAfterMs())
+
+ private fun scaleWait(baseMs: Int): Int {
+ val scaled = (baseMs.toLong() * getWaitScalePercent().toLong()) / 100L
+ return scaled.coerceIn(0L, MAX_EFFECTIVE_WAIT_AFTER_MS.toLong()).toInt()
+ }
/** 是否已配置 LLM(API Key 非空即视为已配置) */
fun hasLlmConfig(): Boolean = getLlmApiKey().isNotEmpty()
diff --git a/app/src/main/res/color/session_switch_thumb.xml b/app/src/main/res/color/session_switch_thumb.xml
new file mode 100644
index 0000000..351398e
--- /dev/null
+++ b/app/src/main/res/color/session_switch_thumb.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/color/session_switch_track.xml b/app/src/main/res/color/session_switch_track.xml
new file mode 100644
index 0000000..52617bc
--- /dev/null
+++ b/app/src/main/res/color/session_switch_track.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_session_input.xml b/app/src/main/res/drawable/bg_session_input.xml
new file mode 100644
index 0000000..d25f47c
--- /dev/null
+++ b/app/src/main/res/drawable/bg_session_input.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_session_message_list.xml b/app/src/main/res/drawable/bg_session_message_list.xml
new file mode 100644
index 0000000..1d2fab5
--- /dev/null
+++ b/app/src/main/res/drawable/bg_session_message_list.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_session_page.xml b/app/src/main/res/drawable/bg_session_page.xml
new file mode 100644
index 0000000..6442aaa
--- /dev/null
+++ b/app/src/main/res/drawable/bg_session_page.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_session_panel.xml b/app/src/main/res/drawable/bg_session_panel.xml
new file mode 100644
index 0000000..4c65a2f
--- /dev/null
+++ b/app/src/main/res/drawable/bg_session_panel.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_session_tag.xml b/app/src/main/res/drawable/bg_session_tag.xml
new file mode 100644
index 0000000..0876c3f
--- /dev/null
+++ b/app/src/main/res/drawable/bg_session_tag.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_floating_task_input.xml b/app/src/main/res/layout/activity_floating_task_input.xml
new file mode 100644
index 0000000..8c06e04
--- /dev/null
+++ b/app/src/main/res/layout/activity_floating_task_input.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml
index 8873880..05fac4f 100644
--- a/app/src/main/res/layout/activity_home.xml
+++ b/app/src/main/res/layout/activity_home.xml
@@ -82,4 +82,17 @@
app:btnTextColor="@color/colorErrorOnPrimary"
app:layout_constraintTop_toBottomOf="@id/cardStorage" />
+
+
diff --git a/app/src/main/res/layout/activity_llm_config.xml b/app/src/main/res/layout/activity_llm_config.xml
index f0f8c39..53ac50d 100644
--- a/app/src/main/res/layout/activity_llm_config.xml
+++ b/app/src/main/res/layout/activity_llm_config.xml
@@ -131,6 +131,39 @@
android:textSize="15pt" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_session_memory.xml b/app/src/main/res/layout/activity_session_memory.xml
new file mode 100644
index 0000000..679a3ce
--- /dev/null
+++ b/app/src/main/res/layout/activity_session_memory.xml
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_session_memory_detail.xml b/app/src/main/res/layout/activity_session_memory_detail.xml
new file mode 100644
index 0000000..0fa2442
--- /dev/null
+++ b/app/src/main/res/layout/activity_session_memory_detail.xml
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_session_memory_editor.xml b/app/src/main/res/layout/activity_session_memory_editor.xml
new file mode 100644
index 0000000..be22877
--- /dev/null
+++ b/app/src/main/res/layout/activity_session_memory_editor.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_wait_timing_settings.xml b/app/src/main/res/layout/activity_wait_timing_settings.xml
new file mode 100644
index 0000000..d819829
--- /dev/null
+++ b/app/src/main/res/layout/activity_wait_timing_settings.xml
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_session_chat_message.xml b/app/src/main/res/layout/item_session_chat_message.xml
new file mode 100644
index 0000000..c3df2ad
--- /dev/null
+++ b/app/src/main/res/layout/item_session_chat_message.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_session_entry.xml b/app/src/main/res/layout/item_session_entry.xml
new file mode 100644
index 0000000..83aea80
--- /dev/null
+++ b/app/src/main/res/layout/item_session_entry.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 240bba3..509046a 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -74,4 +74,15 @@
#FFFFFFFF
#FF003552
#FFAFE3FF
+
+
+ #FF121216
+ #FF0E0E10
+ #FF10151B
+ #FF2B2B2E
+ #FF3E3E44
+ #FF113A57
+ #FF1D6B9D
+ #FF343438
+ #FF4A4A50
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 37d52b3..a74b4b4 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,6 +1,7 @@
+
+