更新 README.md
This commit is contained in:
760
README.md
760
README.md
@ -1,2 +1,760 @@
|
|||||||
# openclaw_client-_on_harmonyos
|
# OpenClaw 鸿蒙客户端项目设计规格及实施要求说明书
|
||||||
|
版本: 2.0
|
||||||
|
目标平台: **HarmonyOS 6.0.2 (API 22)**
|
||||||
|
开发语言: ArkTS(严格模式)
|
||||||
|
最后更新: 2026-03-11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 1.1 项目目标
|
||||||
|
开发一款运行于鸿蒙手机的原生应用,通过手机热点与树莓派5上部署的 **OpenClaw AI 服务** 通信,提供类微信的 AI 对话界面,并完整支持 Markdown 格式的内容渲染(包括流式输出效果)。应用需具备自动发现局域网内 OpenClaw 服务器的能力,并能在无法自动发现时通过手动配置连接。
|
||||||
|
|
||||||
|
### 1.2 核心特性
|
||||||
|
- **原生 ArkUI 实现**:基于 ArkTS + Stage 模型,完全适配 API 22。
|
||||||
|
- **高性能 Markdown 渲染**:采用支付宝开源的 **FluidMarkdown 鸿蒙版**,直接调用 ArkUI 渲染引擎,支持流式增量输出,告别 WebView 性能瓶颈。
|
||||||
|
- **自动服务发现**:优先使用 mDNS/DNS-SD 协议自动发现局域网内的 OpenClaw 网关,兼容手动配置。
|
||||||
|
- **简洁对话界面**:消息气泡区分用户与 AI,支持本地历史记录存储。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术栈与依赖
|
||||||
|
|
||||||
|
| 类别 | 名称/库 | 版本/要求 | 说明 |
|
||||||
|
| ---------------- | -------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| 操作系统 | HarmonyOS | 6.0.2 (API 22) | 编译 SDK 版本必须为 22,兼容运行版本 ≥12 |
|
||||||
|
| 开发语言 | ArkTS | 严格模式 | 使用 `@ohos.*` 系统模块,无额外 JS 依赖 |
|
||||||
|
| UI 框架 | ArkUI | API 22 内置 | 声明式 UI 开发 |
|
||||||
|
| Markdown 渲染 | **@antgroup/fluidmarkdown** | ≥1.0.0 (2025.12 开源) | [GitHub 仓库](https://github.com/antgroup/FluidMarkdown) |
|
||||||
|
| 网络通信 | @ohos.net.http | API 22 内置 | HTTP 客户端 |
|
||||||
|
| 服务发现 | @ohos.net.mdns (待确认) | API 22 可能支持 | 需查阅 API 22 官方文档确认 mDNS 模块是否存在 |
|
||||||
|
| 状态管理 | AppStorage / LocalStorage | API 22 内置 | 应用级状态持久化 |
|
||||||
|
| 资源回收 | AutoFinalizer (Util) | API 22 新特性 | 用于 WebView 等资源的自动回收(本项目已无 WebView,但保留说明) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 系统架构
|
||||||
|
|
||||||
|
### 3.1 部署架构
|
||||||
|
```
|
||||||
|
┌─────────────────┐ WiFi Hotspot ┌─────────────────┐
|
||||||
|
│ 华为手机 │ ═══════════════════► │ 树莓派5 │
|
||||||
|
│ (HarmonyOS) │ (192.168.43.x) │ (OpenClaw) │
|
||||||
|
│ │ │ Port: 18789 │
|
||||||
|
│ ┌───────────┐ │ │ mDNS Broadcast │
|
||||||
|
│ │ OpenClaw │ │◄───────────────────────│ _openclaw._tcp │
|
||||||
|
│ │ Client │ │ HTTP API │ local. │
|
||||||
|
│ └───────────┘ │ └──────────────────┘
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 应用架构(文件结构)
|
||||||
|
```
|
||||||
|
OpenClaw_Client/
|
||||||
|
├── AppScope/ # (无需修改)
|
||||||
|
├── entry/
|
||||||
|
│ ├── src/main/
|
||||||
|
│ │ ├── ets/
|
||||||
|
│ │ │ ├── entryability/
|
||||||
|
│ │ │ │ └── EntryAbility.ets # 应用入口
|
||||||
|
│ │ │ ├── pages/
|
||||||
|
│ │ │ │ ├── Index.ets # 主聊天界面
|
||||||
|
│ │ │ │ └── Settings.ets # 服务器配置界面
|
||||||
|
│ │ │ ├── components/
|
||||||
|
│ │ │ │ ├── ChatBubble.ets # 消息气泡组件
|
||||||
|
│ │ │ │ └── ServerDiscovery.ets # (可选)服务发现列表组件
|
||||||
|
│ │ │ ├── model/
|
||||||
|
│ │ │ │ └── ChatMessage.ets # 消息数据模型
|
||||||
|
│ │ │ ├── service/
|
||||||
|
│ │ │ │ ├── OpenClawApi.ets # OpenClaw HTTP API 封装
|
||||||
|
│ │ │ │ └── DiscoveryService.ets # (可选)mDNS 服务发现封装
|
||||||
|
│ │ │ └── utils/
|
||||||
|
│ │ │ └── StorageUtil.ets # (可选)本地存储工具
|
||||||
|
│ │ └── resources/
|
||||||
|
│ │ └── base/
|
||||||
|
│ │ └── element/
|
||||||
|
│ │ └── string.json # 国际化字符串
|
||||||
|
│ └── module.json5 # 模块配置
|
||||||
|
├── build-profile.json5 # 项目级构建配置
|
||||||
|
└── oh-package.json5 # 项目级依赖配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 功能需求
|
||||||
|
|
||||||
|
| 功能模块 | 需求描述 | 优先级 |
|
||||||
|
| ---------------- | ------------------------------------------------------------ | ------ |
|
||||||
|
| **服务器连接** | 支持手动输入服务器地址(IP:Port)和令牌;支持自动发现局域网内 OpenClaw 服务并列出,点击即连接。 | P0 |
|
||||||
|
| **对话界面** | 类微信聊天界面,底部输入框,消息列表滚动。用户消息右对齐(纯文本),AI 消息左对齐(Markdown 渲染)。 | P0 |
|
||||||
|
| **Markdown 渲染**| AI 返回内容必须完整支持 Markdown 语法(标题、列表、代码块、表格、LaTeX 公式等)。采用 **FluidMarkdown** 原生渲染,支持流式追加输出(模拟逐字生成)。 | P0 |
|
||||||
|
| **消息历史** | 自动保存最近 50 条对话到本地存储,重启应用后恢复。 | P1 |
|
||||||
|
| **连接状态指示** | 主界面顶部显示当前连接状态(绿点/红点),点击红点可快速跳转到设置页。 | P1 |
|
||||||
|
| **错误处理** | 网络请求失败、服务端返回错误时,在对话中以气泡形式给出明确提示。 | P1 |
|
||||||
|
| **自动重连** | 当网络切换或服务重启时,尝试自动重新连接(可选)。 | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 技术规范与约束
|
||||||
|
|
||||||
|
### 5.1 API 22 合规要求
|
||||||
|
- 编译 SDK 版本必须为 **22** (`compileSdkVersion = 22`),兼容运行版本 ≥12 (`compatibleSdkVersion = 22` 表示仅支持 API 22 及以上设备)。
|
||||||
|
- 必须使用 **ArkTS 严格模式**,所有变量需显式类型声明。
|
||||||
|
- 网络请求必须使用 `@ohos.net.http`,并正确调用 `destroy()` 释放资源。
|
||||||
|
- 禁止直接操作 DOM 或使用 WebView 进行核心渲染(已替换为原生组件)。
|
||||||
|
|
||||||
|
### 5.2 FluidMarkdown 集成规范
|
||||||
|
- 通过 `ohpm` 安装依赖:`ohpm install @antgroup/fluidmarkdown`。
|
||||||
|
- 在代码中导入:`import { Markdown, EMarkdownMode, MarkdownController } from '@antgroup/fluidmarkdown';`
|
||||||
|
- **流式输出**:若需模拟逐字输出,可将内容按字符拆分后逐步设置 `content` 属性,FluidMarkdown 内部会增量更新。
|
||||||
|
- **交互处理**:通过 `onMarkdownNodeClick` 回调处理链接/图片点击事件,可使用 `router.pushUrl` 或 `@ohos.web.webview` 打开链接。
|
||||||
|
|
||||||
|
### 5.3 自动服务发现(mDNS)实现说明
|
||||||
|
- **首选方案**:使用系统 mDNS 模块 `@ohos.net.mdns`(API 22 中需确认是否存在)。若存在,通过 `addLocalServiceDiscovery` 监听 `_openclaw._tcp` 服务。
|
||||||
|
- **备选方案**:若系统 mDNS 不可用,可尝试局域网广播扫描(UDP 广播 + 特定端口探测),但可靠性较低。
|
||||||
|
- **实现位置**:建议在 `Settings.ets` 页面中集成发现列表,并在 `Index.ets` 的 `aboutToAppear` 中尝试自动连接上次成功的主机。
|
||||||
|
- **权限**:若使用 mDNS,需在 `module.json5` 中添加 `ohos.permission.DISTRIBUTED_DEVICE_DISCOVERY`(需实际验证)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 文件替换与修改指南
|
||||||
|
|
||||||
|
### 6.1 项目初始化
|
||||||
|
使用 DevEco Studio 创建一个新的 **Empty Ability** 工程,选择 **Stage 模型**,语言选择 **ArkTS**,目标 SDK 版本选择 **6.0.2(22)**。生成的标准工程目录结构如上所示。
|
||||||
|
|
||||||
|
### 6.2 文件替换与新增清单
|
||||||
|
|
||||||
|
| 文件路径 | 操作 | 说明 |
|
||||||
|
| -------------------------------------------------------- | ------ | ------------------------------------------------------------ |
|
||||||
|
| `build-profile.json5` (项目根目录) | 替换 | 设置 `compileSdkVersion` 和 `compatibleSdkVersion` 为 22 |
|
||||||
|
| `oh-package.json5` (项目根目录) | 修改 | 添加 `@antgroup/fluidmarkdown` 依赖 |
|
||||||
|
| `entry/oh-package.json5` | 新增 | 可选,若模块级依赖独立,可在此添加依赖 |
|
||||||
|
| `entry/module.json5` | 替换 | 配置权限(INTERNET、GET_NETWORK_INFO)和 Ability 信息 |
|
||||||
|
| `entry/src/main/ets/entryability/EntryAbility.ets` | 替换 | 初始化全局状态 `serverBaseUrl`, `accessToken`, `isServerConnected` |
|
||||||
|
| `entry/src/main/ets/model/ChatMessage.ets` | 新增 | 定义消息接口 |
|
||||||
|
| `entry/src/main/ets/service/OpenClawApi.ets` | 新增 | 封装 HTTP 请求方法 `chat` 和 `healthCheck` |
|
||||||
|
| `entry/src/main/ets/components/ChatBubble.ets` | 新增 | 实现消息气泡,AI 消息集成 FluidMarkdown 组件 |
|
||||||
|
| `entry/src/main/ets/pages/Index.ets` | 替换 | 主聊天界面,包含消息列表、输入框、发送逻辑 |
|
||||||
|
| `entry/src/main/ets/pages/Settings.ets` | 新增 | 服务器配置界面(手动输入 + 自动发现列表占位) |
|
||||||
|
| `entry/src/main/resources/base/element/string.json` | 修改 | 添加必要的字符串资源(可选) |
|
||||||
|
|
||||||
|
**重要**:原 WebView 相关文件(`MarkdownView.ets`, `markdown-it.min.js`)**不再使用**,请删除以避免编译冲突。
|
||||||
|
|
||||||
|
### 6.3 配置文件详细内容
|
||||||
|
|
||||||
|
#### 6.3.1 `build-profile.json5`(项目根目录)
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"signingConfigs": [],
|
||||||
|
"compileSdkVersion": 22,
|
||||||
|
"compatibleSdkVersion": 22,
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"signingConfig": "default",
|
||||||
|
"compileSdkVersion": 22,
|
||||||
|
"compatibleSdkVersion": 22,
|
||||||
|
"runtimeOS": "HarmonyOS"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "entry",
|
||||||
|
"srcPath": "./entry",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"applyToProducts": ["default"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3.2 `oh-package.json5`(项目根目录)
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"name": "openclaw-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "OpenClaw HarmonyOS Client",
|
||||||
|
"dependencies": {
|
||||||
|
"@antgroup/fluidmarkdown": "^1.0.0" // 请以仓库最新版本号为准
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**执行命令**:在项目根目录执行 `ohpm install` 下载依赖。
|
||||||
|
|
||||||
|
#### 6.3.3 `entry/module.json5`
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"module": {
|
||||||
|
"name": "entry",
|
||||||
|
"type": "entry",
|
||||||
|
"description": "$string:module_desc",
|
||||||
|
"mainElement": "EntryAbility",
|
||||||
|
"deviceTypes": ["phone"],
|
||||||
|
"deliveryWithInstall": true,
|
||||||
|
"installationFree": false,
|
||||||
|
"pages": "$profile:main_pages",
|
||||||
|
"abilities": [
|
||||||
|
{
|
||||||
|
"name": "EntryAbility",
|
||||||
|
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||||
|
"description": "$string:EntryAbility_desc",
|
||||||
|
"icon": "$media:layered_image",
|
||||||
|
"label": "$string:EntryAbility_label",
|
||||||
|
"startWindowIcon": "$media:startIcon",
|
||||||
|
"startWindowBackground": "$color:start_window_background",
|
||||||
|
"exported": true,
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"entities": ["entity.system.home"],
|
||||||
|
"actions": ["action.system.home"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestPermissions": [
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.INTERNET"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.GET_NETWORK_INFO"
|
||||||
|
}
|
||||||
|
// 若使用 mDNS,取消下一行注释
|
||||||
|
// {
|
||||||
|
// "name": "ohos.permission.DISTRIBUTED_DEVICE_DISCOVERY"
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 源代码文件详细内容
|
||||||
|
|
||||||
|
#### 6.4.1 `entry/src/main/ets/entryability/EntryAbility.ets`
|
||||||
|
```typescript
|
||||||
|
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||||
|
import { window } from '@kit.ArkUI';
|
||||||
|
|
||||||
|
export default class EntryAbility extends UIAbility {
|
||||||
|
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||||
|
AppStorage.setOrCreate('serverBaseUrl', '');
|
||||||
|
AppStorage.setOrCreate('accessToken', 'mobile-access-token-2026');
|
||||||
|
AppStorage.setOrCreate('isServerConnected', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||||
|
windowStage.loadContent('pages/Index', (err) => {
|
||||||
|
if (err.code) {
|
||||||
|
console.error(`Failed to load main page: ${JSON.stringify(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.4.2 `entry/src/main/ets/model/ChatMessage.ets`
|
||||||
|
```typescript
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.4.3 `entry/src/main/ets/service/OpenClawApi.ets`
|
||||||
|
```typescript
|
||||||
|
import { http } from '@ohos.net.http';
|
||||||
|
|
||||||
|
export class OpenClawApi {
|
||||||
|
async chat(baseUrl: string, token: string, agentId: string = 'main', message: string): Promise<string> {
|
||||||
|
const httpRequest = http.createHttp();
|
||||||
|
const url = `${baseUrl}/v1/responses`;
|
||||||
|
const payload = {
|
||||||
|
model: `openclaw:${agentId}`,
|
||||||
|
messages: [{ role: 'user', content: message }]
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpRequest.request(url, {
|
||||||
|
method: http.RequestMethod.POST,
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
extraData: JSON.stringify(payload),
|
||||||
|
expectDataType: http.HttpDataType.STRING,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
readTimeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.responseCode === 200) {
|
||||||
|
const data = JSON.parse(response.result as string);
|
||||||
|
return data.choices?.[0]?.message?.content || '[无响应内容]';
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP Error ${response.responseCode}: ${response.result}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`OpenClawApi.chat failed: ${error.message}`);
|
||||||
|
throw new Error(`请求失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
httpRequest.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(baseUrl: string, token: string): Promise<boolean> {
|
||||||
|
const httpRequest = http.createHttp();
|
||||||
|
try {
|
||||||
|
const response = await httpRequest.request(`${baseUrl}/health`, {
|
||||||
|
method: http.RequestMethod.GET,
|
||||||
|
header: { 'Authorization': `Bearer ${token}` },
|
||||||
|
connectTimeout: 5000
|
||||||
|
});
|
||||||
|
return response.responseCode === 200;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
httpRequest.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.4.4 `entry/src/main/ets/components/ChatBubble.ets`
|
||||||
|
```typescript
|
||||||
|
import { ChatMessage } from '../model/ChatMessage';
|
||||||
|
import { Markdown, EMarkdownMode, MarkdownController } from '@antgroup/fluidmarkdown';
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export struct ChatBubble {
|
||||||
|
@Prop message: ChatMessage;
|
||||||
|
private markdownController: MarkdownController = new MarkdownController();
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Row() {
|
||||||
|
if (this.message.role === 'user') {
|
||||||
|
Blank()
|
||||||
|
Column() {
|
||||||
|
Text(this.message.content)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor(Color.White)
|
||||||
|
.padding(12)
|
||||||
|
.backgroundColor('#007AFF')
|
||||||
|
.borderRadius(18)
|
||||||
|
.maxLines(100)
|
||||||
|
.wordBreak(WordBreak.BREAK_ALL)
|
||||||
|
}
|
||||||
|
.padding({ right: 8 })
|
||||||
|
.alignItems(HorizontalAlign.End)
|
||||||
|
} else {
|
||||||
|
Column() {
|
||||||
|
Markdown({
|
||||||
|
content: this.message.content,
|
||||||
|
controller: this.markdownController,
|
||||||
|
mode: EMarkdownMode.Normal,
|
||||||
|
onMarkdownNodeClick: (data) => {
|
||||||
|
if (data.type === 'link' && data.href) {
|
||||||
|
console.info('Link clicked: ' + data.href);
|
||||||
|
// 可调用系统浏览器或 WebView 打开链接
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.backgroundColor('#E9E9EB')
|
||||||
|
.borderRadius(18)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.padding({ left: 8 })
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 4, bottom: 4 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.4.5 `entry/src/main/ets/pages/Index.ets`
|
||||||
|
```typescript
|
||||||
|
import { OpenClawApi } from '../service/OpenClawApi';
|
||||||
|
import { ChatMessage } from '../model/ChatMessage';
|
||||||
|
import { ChatBubble } from '../components/ChatBubble';
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct Index {
|
||||||
|
@State messageList: ChatMessage[] = [];
|
||||||
|
@State inputText: string = '';
|
||||||
|
@State isLoading: boolean = false;
|
||||||
|
@StorageLink('serverBaseUrl') serverBaseUrl: string = '';
|
||||||
|
@StorageLink('accessToken') accessToken: string = '';
|
||||||
|
@StorageLink('isServerConnected') isServerConnected: boolean = false;
|
||||||
|
|
||||||
|
private listScroller: ListScroller = new ListScroller();
|
||||||
|
private api: OpenClawApi = new OpenClawApi();
|
||||||
|
|
||||||
|
aboutToAppear() {
|
||||||
|
this.loadHistory();
|
||||||
|
if (this.serverBaseUrl) {
|
||||||
|
this.checkServerConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHistory() {
|
||||||
|
const history = AppStorage.get<ChatMessage[]>('chat_history');
|
||||||
|
if (history) {
|
||||||
|
this.messageList = history;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveHistory() {
|
||||||
|
const historyToSave = this.messageList.slice(-50);
|
||||||
|
AppStorage.setOrCreate('chat_history', historyToSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkServerConnection() {
|
||||||
|
if (!this.serverBaseUrl) return;
|
||||||
|
const connected = await this.api.healthCheck(this.serverBaseUrl, this.accessToken);
|
||||||
|
this.isServerConnected = connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage() {
|
||||||
|
if (!this.inputText.trim() || !this.serverBaseUrl) {
|
||||||
|
// 提示用户配置服务器
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'user',
|
||||||
|
content: this.inputText,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
this.messageList.push(userMsg);
|
||||||
|
this.inputText = '';
|
||||||
|
this.scrollToBottom();
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
const aiResponse = await this.api.chat(this.serverBaseUrl, this.accessToken, 'main', userMsg.content);
|
||||||
|
const aiMsg: ChatMessage = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: aiResponse,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
this.messageList.push(aiMsg);
|
||||||
|
this.saveHistory();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg: ChatMessage = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: `**连接错误**\n\`\`\`\n${error.message}\n\`\`\``,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
this.messageList.push(errorMsg);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.listScroller.scrollEdge(Edge.Bottom);
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
// 标题栏
|
||||||
|
Row() {
|
||||||
|
Text('OpenClaw')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
Blank()
|
||||||
|
Circle()
|
||||||
|
.width(12)
|
||||||
|
.height(12)
|
||||||
|
.fill(this.isServerConnected ? Color.Green : Color.Red)
|
||||||
|
.margin({ right: 8 })
|
||||||
|
Button('设置')
|
||||||
|
.fontSize(14)
|
||||||
|
.onClick(() => {
|
||||||
|
router.pushUrl({ url: 'pages/Settings' });
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor('#F5F5F5')
|
||||||
|
|
||||||
|
// 消息列表
|
||||||
|
List({ scroller: this.listScroller }) {
|
||||||
|
ForEach(this.messageList, (msg: ChatMessage) => {
|
||||||
|
ListItem() {
|
||||||
|
ChatBubble({ message: msg })
|
||||||
|
}
|
||||||
|
}, (msg: ChatMessage) => msg.id)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.padding(8)
|
||||||
|
|
||||||
|
// 输入区域
|
||||||
|
Row() {
|
||||||
|
TextInput({ placeholder: '输入消息...', text: $$this.inputText })
|
||||||
|
.width('80%')
|
||||||
|
.height(48)
|
||||||
|
.fontSize(16)
|
||||||
|
.enabled(this.isServerConnected)
|
||||||
|
Button(this.isLoading ? '发送中' : '发送')
|
||||||
|
.width('18%')
|
||||||
|
.height(48)
|
||||||
|
.enabled(!this.isLoading && this.inputText.length > 0 && this.isServerConnected)
|
||||||
|
.onClick(() => this.sendMessage())
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor(Color.White)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor('#F0F0F0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.4.6 `entry/src/main/ets/pages/Settings.ets`
|
||||||
|
```typescript
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { OpenClawApi } from '../service/OpenClawApi';
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct Settings {
|
||||||
|
@StorageLink('serverBaseUrl') serverBaseUrl: string = '';
|
||||||
|
@StorageLink('accessToken') accessToken: string = '';
|
||||||
|
@State inputUrl: string = '';
|
||||||
|
@State inputToken: string = '';
|
||||||
|
@State discoveredServers: Array<{ name: string, url: string }> = [];
|
||||||
|
@State isScanning: boolean = false;
|
||||||
|
@State statusMessage: string = '';
|
||||||
|
|
||||||
|
private api: OpenClawApi = new OpenClawApi();
|
||||||
|
|
||||||
|
aboutToAppear() {
|
||||||
|
this.inputUrl = this.serverBaseUrl;
|
||||||
|
this.inputToken = this.accessToken;
|
||||||
|
this.startDiscovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
startDiscovery() {
|
||||||
|
// TODO: 使用 mDNS 实现自动发现
|
||||||
|
// 此处为占位示例,模拟发现过程
|
||||||
|
this.isScanning = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.discoveredServers = [
|
||||||
|
{ name: '树莓派5 (192.168.43.101)', url: 'http://192.168.43.101:18789' }
|
||||||
|
];
|
||||||
|
this.isScanning = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectServer(url: string) {
|
||||||
|
this.inputUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async testAndSave() {
|
||||||
|
if (!this.inputUrl) {
|
||||||
|
this.statusMessage = '请输入服务器地址';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isScanning = true; // 复用为测试中状态
|
||||||
|
this.statusMessage = '正在测试连接...';
|
||||||
|
const isValid = await this.api.healthCheck(this.inputUrl, this.inputToken);
|
||||||
|
if (isValid) {
|
||||||
|
this.serverBaseUrl = this.inputUrl;
|
||||||
|
this.accessToken = this.inputToken;
|
||||||
|
this.statusMessage = '连接成功!';
|
||||||
|
setTimeout(() => router.back(), 1000);
|
||||||
|
} else {
|
||||||
|
this.statusMessage = '连接失败,请检查地址和令牌';
|
||||||
|
}
|
||||||
|
this.isScanning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
// 标题栏
|
||||||
|
Row() {
|
||||||
|
Button('返回')
|
||||||
|
.onClick(() => router.back())
|
||||||
|
Blank()
|
||||||
|
Text('服务器设置')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
|
||||||
|
Column() {
|
||||||
|
// 手动输入区域
|
||||||
|
TextInput({ placeholder: '服务器地址', text: $$this.inputUrl })
|
||||||
|
.height(48)
|
||||||
|
.margin({ top: 20 })
|
||||||
|
.placeholderColor('#999999')
|
||||||
|
Text('示例: http://192.168.43.101:18789')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#999999')
|
||||||
|
.width('100%')
|
||||||
|
.textAlign(TextAlign.Start)
|
||||||
|
|
||||||
|
TextInput({ placeholder: '访问令牌', text: $$this.inputToken })
|
||||||
|
.height(48)
|
||||||
|
.margin({ top: 10 })
|
||||||
|
|
||||||
|
Button(this.isScanning ? '测试中...' : '测试并保存')
|
||||||
|
.width('100%')
|
||||||
|
.height(48)
|
||||||
|
.margin({ top: 20 })
|
||||||
|
.enabled(!this.isScanning)
|
||||||
|
.onClick(() => this.testAndSave())
|
||||||
|
|
||||||
|
if (this.statusMessage) {
|
||||||
|
Text(this.statusMessage)
|
||||||
|
.fontColor(this.statusMessage.includes('成功') ? Color.Green : Color.Red)
|
||||||
|
.margin({ top: 10 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动发现列表
|
||||||
|
Text('自动发现设备')
|
||||||
|
.fontSize(18)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.margin({ top: 30 })
|
||||||
|
if (this.isScanning) {
|
||||||
|
Text('正在扫描...')
|
||||||
|
} else {
|
||||||
|
List() {
|
||||||
|
ForEach(this.discoveredServers, (item) => {
|
||||||
|
ListItem() {
|
||||||
|
Row() {
|
||||||
|
Text(item.name)
|
||||||
|
Blank()
|
||||||
|
Button('连接')
|
||||||
|
.onClick(() => this.selectServer(item.url))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
}, item => item.url)
|
||||||
|
}
|
||||||
|
.height(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('90%')
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor('#F0F0F0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 资源文件修改
|
||||||
|
|
||||||
|
#### `entry/src/main/resources/base/element/string.json`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "module_desc",
|
||||||
|
"value": "OpenClaw 鸿蒙客户端"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_desc",
|
||||||
|
"value": "AI 对话界面"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_label",
|
||||||
|
"value": "OpenClaw"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 自动服务发现详细设计(可选,P2)
|
||||||
|
|
||||||
|
### 7.1 方案选择
|
||||||
|
- **mDNS(推荐)**:使用 `@ohos.net.mdns` 模块。需在 `module.json5` 中添加权限,并查询 API 22 文档确认接口。
|
||||||
|
- **UDP 广播扫描**:若不支持 mDNS,可向局域网广播地址发送探测包,监听特定端口响应。但可靠性较低,且需处理多线程。
|
||||||
|
|
||||||
|
### 7.2 mDNS 接口设计示例
|
||||||
|
```typescript
|
||||||
|
// DiscoveryService.ets
|
||||||
|
import { mdns } from '@ohos.net.mdns';
|
||||||
|
|
||||||
|
export class DiscoveryService {
|
||||||
|
private discovery: mdns.DiscoveryService | null = null;
|
||||||
|
|
||||||
|
startDiscovery(callback: (services: Array<{ name: string, host: string, port: number }>) => void) {
|
||||||
|
mdns.createDiscoveryService('_openclaw._tcp', (err, discovery) => {
|
||||||
|
if (err) return;
|
||||||
|
this.discovery = discovery;
|
||||||
|
discovery.on('discoveryResult', (result) => {
|
||||||
|
callback([{ name: result.serviceName, host: result.host, port: result.port }]);
|
||||||
|
});
|
||||||
|
discovery.startDiscovering();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopDiscovery() {
|
||||||
|
this.discovery?.stopDiscovering();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:以上代码仅为示例,实际需根据 API 22 的 `@ohos.net.mdns` 模块文档调整。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 构建与验证清单
|
||||||
|
|
||||||
|
### 8.1 构建步骤
|
||||||
|
1. 在 DevEco Studio 中创建工程,选择 SDK 6.0.2(22)。
|
||||||
|
2. 按照 6.2 节清单替换/新增所有文件。
|
||||||
|
3. 在项目根目录执行 `ohpm install` 下载 FluidMarkdown 依赖。
|
||||||
|
4. 连接真机(API 22 设备)或模拟器,点击运行。
|
||||||
|
|
||||||
|
### 8.2 功能验证
|
||||||
|
- [ ] **编译通过**:无报错,成功生成 HAP 并安装。
|
||||||
|
- [ ] **手动配置连接**:在设置页输入正确的树莓派 IP:Port 和令牌,点击测试并保存,返回主界面后连接状态指示变绿。
|
||||||
|
- [ ] **消息发送与渲染**:发送消息后,AI 响应内容以 Markdown 格式正确显示(如 `# 标题`、`- 列表`、\`代码\` 等)。
|
||||||
|
- [ ] **流式输出测试**:可通过模拟分片追加内容验证 FluidMarkdown 的增量渲染(例如使用定时器逐字增加消息内容)。
|
||||||
|
- [ ] **历史记录**:重启应用后,之前对话应保留。
|
||||||
|
- [ ] **自动发现(若实现)**:在树莓派启动 OpenClaw 并广播服务后,设置页应显示发现的设备,点击可自动填充地址。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 注意事项
|
||||||
|
|
||||||
|
- **FluidMarkdown 版本**:请始终参考 [GitHub 仓库](https://github.com/antgroup/FluidMarkdown) 的最新文档,API 可能迭代。
|
||||||
|
- **mDNS 可用性**:在编写自动发现代码前,务必在 API 22 真机上测试 `@ohos.net.mdns` 模块是否存在及可用。若不可用,及时回退至手动配置。
|
||||||
|
- **资源回收**:虽然本项目无 WebView,但若使用定时器或网络请求,需确保在组件销毁时取消,避免内存泄漏。
|
||||||
|
- **错误处理**:网络请求务必包含 `try-catch`,并在 UI 层给出用户友好的提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 文档结束
|
||||||
|
|
||||||
|
本说明书提供了 OpenClaw 鸿蒙客户端在 **API 22** 平台上的完整设计规格与实施细节。请开发人员严格按照上述文件替换与代码编写要求进行开发,确保应用的稳定性与性能。如有任何因平台更新导致的 API 变更,请以最新的官方文档为准,并相应调整本方案中的实现。
|
||||||
Reference in New Issue
Block a user