feat(ohos): 鸿蒙端聊天历史分段懒加载 + 首次提交完整工程

原有 cmd/ohos/HomeAgent 是未入库的鸿蒙原生 ArkTS 工程,本次随改动一并入库,
保证他人 clone 后可直接编译(含 .gitignore 排除 build/oh_modules/签名材料,
提供 build-profile.json5.example 模板)。

本次功能改动(与 WebUI / GUI 三端对齐):
- /chat/history 首屏只拉最新 CHAT_PAGE_SIZE(40) 条,1.26MB → 48.5KB
- 抽出 parseHistoryPayload() 复用解析,记录 chatOffset/chatHasMore
- 新增 loadOlderChat():向上滚动触顶(yOffset<60)懒加载更早页
- 工具调用 args/result 与 reasoning_content 完整还原,不做裁剪

构建验证:hvigorw assembleHap BUILD SUCCESSFUL(7.8s,ChatPage 零告警)
This commit is contained in:
JianFeeeee
2026-08-29 10:24:13 +08:00
parent 3de6b0426f
commit 46e942f0c2
76 changed files with 10838 additions and 0 deletions

2
cmd/ohos/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# 开发过程截图(体积大、非源码),不入库
screenshots/

19
cmd/ohos/HomeAgent/.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# 构建产物
build/
.hvigor/
.cxx/
# 依赖
oh_modules/
node_modules/
# 本地 SDK / Node 路径,每台机器不同
local.properties
# 签名材料:含 keyPassword / storePassword 明文与本机绝对路径,不入库
build-profile.json5
# hvigorw 在本机是指向 /opt/huawei/command-line-tools/bin/hvigorw 的符号链接,
# 绝对路径因机而异,入库后他人 clone 得到的是坏链接。
# 请改用本机 DevEco command-line-tools 里的 hvigorw见 README
hvigorw

View File

@ -0,0 +1,10 @@
{
"app": {
"bundleName": "com.example.homeagent",
"vendor": "HomeAgent",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:app_icon",
"label": "$string:app_name"
}
}

View File

@ -0,0 +1,8 @@
{
"string": [
{
"name": "app_name",
"value": "HomeAgent"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -0,0 +1,57 @@
{
app: {
products: [
{
name: 'default',
signingConfig: 'default',
compileSdkVersion: '26.0.0',
compatibleSdkVersion: '6.1.1(24)',
runtimeOS: 'HarmonyOS',
buildOption: {
strictMode: {
useNormalizedOHMUrl: true,
},
},
},
],
buildModeSet: [
{
name: 'debug',
},
{
name: 'release',
},
],
// 复制为 build-profile.json5 后,把下面四项换成本机 DevEco 生成的调试签名材料
// (默认在 ~/.ohos/config/ 下keyPassword / storePassword 用自己的值。
signingConfigs: [
{
name: 'default',
type: 'HarmonyOS',
material: {
certpath: 'REPLACE_WITH_YOUR_CER_PATH',
keyAlias: 'debugKey',
keyPassword: 'REPLACE_WITH_YOUR_KEY_PASSWORD',
profile: 'REPLACE_WITH_YOUR_P7B_PATH',
signAlg: 'SHA256withECDSA',
storeFile: 'REPLACE_WITH_YOUR_P12_PATH',
storePassword: 'REPLACE_WITH_YOUR_STORE_PASSWORD',
},
},
],
},
modules: [
{
name: 'entry',
srcPath: './entry',
targets: [
{
name: 'default',
applyToProducts: [
'default',
],
},
],
},
],
}

View File

@ -0,0 +1 @@
export { hapTasks } from '@ohos/hvigor-ohos-plugin';

View File

@ -0,0 +1,20 @@
{
"meta": {
"stableOrder": true,
"enableUnifiedLockfile": false
},
"lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": {
"@ohos/hypium@1.0.21": "@ohos/hypium@1.0.21"
},
"packages": {
"@ohos/hypium@1.0.21": {
"name": "@ohos/hypium",
"version": "1.0.21",
"integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har",
"registryType": "ohpm"
}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "entry",
"version": "1.0.0",
"description": "HomeAgent HarmonyOS client entry module",
"main": "",
"author": "",
"license": "Apache-2.0",
"dependencies": {},
"devDependencies": {
"@ohos/hypium": "1.0.21"
}
}

View File

@ -0,0 +1,217 @@
import { http } from '@kit.NetworkKit';
import { ConnectionConfig } from '../model/Model';
import { DEFAULT_API_TIMEOUT } from './Constants';
export class ApiError extends Error {
status: number;
body: string;
constructor(message: string, status: number, body: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
export interface ApiResponse {
status: number;
body: string;
}
/** 二进制响应:附件预览需要原始字节来解码成 PixelMap */
export interface ApiBinaryResponse {
status: number;
data: ArrayBuffer;
}
export class ApiClient {
private conn: ConnectionConfig | null = null;
setConnection(conn: ConnectionConfig): void {
this.conn = conn;
}
getConnection(): ConnectionConfig | null {
return this.conn;
}
hasConnection(): boolean {
return this.conn !== null && this.conn.url.length > 0;
}
private buildUrl(path: string): string {
if (this.conn === null) {
return path;
}
const base = this.conn.url.replace(/\/+$/, '');
return base + '/api/v1' + path;
}
/**
* 把附件的 url 字段解析成可直接请求的绝对地址。
*
* 后端给的是 `/files/<name>` 或 `/uploads/<name>`(注意:不带 /api/v1 前缀),
* 远程附件则直接是 http(s) 绝对地址,原样返回。
*/
absoluteUrl(url: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (this.conn === null) {
return url;
}
const base = this.conn.url.replace(/\/+$/, '');
return url.startsWith('/') ? base + url : base + '/' + url;
}
/**
* 读取附件字节。/files/ 与 /uploads/ 走 requireWeb
* 但后端对 API Key 客户端同等放行,所以带上同一套鉴权头即可。
*/
async getBinary(absUrl: string, timeoutMs: number): Promise<ApiBinaryResponse> {
const httpRequest = http.createHttp();
try {
const options: http.HttpRequestOptions = {
method: http.RequestMethod.GET,
header: this.buildHeaders(),
expectDataType: http.HttpDataType.ARRAY_BUFFER,
readTimeout: timeoutMs,
connectTimeout: Math.min(timeoutMs, 15000),
};
const resp = await httpRequest.request(absUrl, options);
const statusCode = resp.responseCode;
if (statusCode >= 400) {
throw new ApiError('下载失败', statusCode, '');
}
const buf: ArrayBuffer = resp.result as ArrayBuffer;
return { status: statusCode, data: buf };
} finally {
httpRequest.destroy();
}
}
/**
* multipart/form-data 上传。
*
* 后端 POST /api/v1/chat/file 要求 file 字段为文件本体,
* message / device_id / device_name / client_msg_id 为普通文本字段。
* ArkTS 侧用 http 的 multiFormDataList文件走 filePath文本走 data。
*/
async postMultipart(path: string, parts: http.MultiFormData[],
timeoutMs: number): Promise<ApiResponse> {
if (this.conn === null) {
throw new ApiError('未选择连接', -1, '');
}
const url = this.buildUrl(path);
const httpRequest = http.createHttp();
try {
const header: Record<string, string> = this.buildHeaders();
// multipart 的 boundary 由底层生成,必须让出 Content-Type 的控制权
header['Content-Type'] = 'multipart/form-data';
const options: http.HttpRequestOptions = {
method: http.RequestMethod.POST,
header: header,
expectDataType: http.HttpDataType.STRING,
multiFormDataList: parts,
readTimeout: timeoutMs,
connectTimeout: Math.min(timeoutMs, 15000),
};
const resp = await httpRequest.request(url, options);
const statusCode = resp.responseCode;
const body = typeof resp.result === 'string' ? resp.result : '';
if (statusCode >= 400) {
let errMsg = body;
try {
const parsed: Record<string, string> = JSON.parse(body) as Record<string, string>;
if (parsed['error'] !== undefined) {
errMsg = parsed['error'];
}
} catch (e) {
// keep raw body
}
throw new ApiError(errMsg, statusCode, body);
}
return { status: statusCode, body: body };
} finally {
httpRequest.destroy();
}
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
headers['Content-Type'] = 'application/json';
if (this.conn !== null && this.conn.apiKey.length > 0) {
// 后端 validAPIKey 认 X-API-Key也认 Authorization: Bearer
// query string 形式会被判 401不要用。
headers['X-API-Key'] = this.conn.apiKey;
headers['Authorization'] = 'Bearer ' + this.conn.apiKey;
}
return headers;
}
async request(path: string, method: string, bodyStr: string,
timeoutMs: number): Promise<ApiResponse> {
if (this.conn === null) {
throw new ApiError('未选择连接', -1, '');
}
const url = this.buildUrl(path);
const httpRequest = http.createHttp();
try {
const options: http.HttpRequestOptions = {
method: (method === 'POST' ? http.RequestMethod.POST :
method === 'PUT' ? http.RequestMethod.PUT :
method === 'DELETE' ? http.RequestMethod.DELETE :
http.RequestMethod.GET) as http.RequestMethod,
header: this.buildHeaders(),
expectDataType: http.HttpDataType.STRING,
readTimeout: timeoutMs,
connectTimeout: Math.min(timeoutMs, 15000),
};
if (bodyStr.length > 0) {
options.extraData = bodyStr;
}
const resp = await httpRequest.request(url, options);
const statusCode = resp.responseCode;
const body = typeof resp.result === 'string' ? resp.result : '';
if (statusCode >= 400) {
let errMsg = body;
try {
const parsed: Record<string, string> = JSON.parse(body) as Record<string, string>;
if (parsed['error'] !== undefined) {
errMsg = parsed['error'];
} else if (parsed['message'] !== undefined) {
errMsg = parsed['message'];
}
} catch (e) {
// keep raw body
}
throw new ApiError(errMsg, statusCode, body);
}
return { status: statusCode, body: body };
} finally {
httpRequest.destroy();
}
}
async get(path: string): Promise<ApiResponse> {
return this.request(path, 'GET', '', DEFAULT_API_TIMEOUT);
}
async getWithTimeout(path: string, timeoutMs: number): Promise<ApiResponse> {
return this.request(path, 'GET', '', timeoutMs);
}
async post(path: string, bodyObj: object | null): Promise<ApiResponse> {
const bodyStr = bodyObj === null ? '' : JSON.stringify(bodyObj);
return this.request(path, 'POST', bodyStr, DEFAULT_API_TIMEOUT);
}
async postWithTimeout(path: string, bodyObj: object | null,
timeoutMs: number): Promise<ApiResponse> {
const bodyStr = bodyObj === null ? '' : JSON.stringify(bodyObj);
return this.request(path, 'POST', bodyStr, timeoutMs);
}
}
export const apiClient: ApiClient = new ApiClient();

View File

@ -0,0 +1,195 @@
import { image } from '@kit.ImageKit';
import { util } from '@kit.ArkTS';
import { pasteboard } from '@kit.BasicServicesKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import { textToSpeech } from '@kit.CoreSpeechKit';
import { componentSnapshot } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
// ===== 能力结果 =====
export interface CapResult {
status: string; // 'ok' | 'error'
output: string;
error: string;
}
function okResult(output: string): CapResult {
const r: CapResult = { status: 'ok', output: output, error: '' };
return r;
}
function errResult(errMsg: string): CapResult {
const r: CapResult = { status: 'error', output: '', error: errMsg };
return r;
}
// ===== screensee截取本应用当前画面前台时为整屏可见内容=====
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
/** 根组件 idIndex 的根 Stack 设置同名 .id()),截屏时按此定位。 */
export function snapshotComponentId(): string {
return SNAPSHOT_COMPONENT_ID;
}
async function captureScreenPixelMap(): Promise<image.PixelMap> {
const pm: image.PixelMap = await componentSnapshot.get(SNAPSHOT_COMPONENT_ID);
return pm;
}
/**
* screensee 实现:截本应用画面,缩放到最大宽度 720px 后压成 jpeg base64 data URL。
* 说明鸿蒙三方应用无法无弹窗截取整个系统屏幕CUSTOM_SCREEN_CAPTURE 为系统权限),
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
*/
export async function capScreensee(): Promise<CapResult> {
try {
const full: image.PixelMap = await captureScreenPixelMap();
const info: image.ImageInfo = await full.getImageInfo();
const maxW: number = 720;
let targetW: number = info.size.width;
let targetH: number = info.size.height;
if (targetW > maxW) {
targetH = Math.floor(targetH * maxW / targetW);
targetW = maxW;
}
let packed: ArrayBuffer;
if (targetW !== info.size.width) {
await full.scale(targetW / info.size.width, targetH / info.size.height);
}
const packer: image.ImagePacker = image.createImagePacker();
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
packed = await packer.packing(full, opt);
packer.release();
full.release();
const helper: util.Base64Helper = new util.Base64Helper();
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
return okResult('data:image/jpeg;base64,' + b64);
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('screensee failed: ' + msg);
}
}
// ===== clipboardsee / clipboardsue =====
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
// 说明READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
// 这里直接尝试读取;系统拒绝时回错误信息。
try {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const has: boolean = await clip.hasData();
if (!has) {
const empty: CapResult = { status: 'ok', output: '', error: '' };
return empty;
}
const data: pasteboard.PasteData = await clip.getData();
const txt: string = data.getPrimaryText();
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
return out;
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
}
}
export async function capClipboardsue(text: string): Promise<CapResult> {
try {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
await clip.setPasteData(data);
return okResult('written ' + text.length + ' chars');
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('clipboardsue failed: ' + msg);
}
}
// ===== speakeruseTTS 朗读 =====
class TtsSession {
private engine: textToSpeech.TextToSpeechEngine | null = null;
async speak(text: string): Promise<CapResult> {
try {
if (this.engine === null) {
const extra: Record<string, Object> = {
'style': 'interaction-broadcast',
'locate': 'CN',
'name': 'EngineName',
};
const params: textToSpeech.CreateEngineParams = {
language: 'zh-CN',
person: 0,
online: 1,
};
const eng: textToSpeech.TextToSpeechEngine = await textToSpeech.createEngine(params);
this.engine = eng;
}
const sp: textToSpeech.SpeakParams = {
requestId: 'spk-' + Date.now().toString(),
};
this.engine.speak(text, sp);
return okResult('speaking');
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('speakeruse failed: ' + msg);
}
}
shutdown(): void {
if (this.engine !== null) {
try {
this.engine.shutdown();
} catch (e) {
// ignore
}
this.engine = null;
}
}
}
const ttsSession: TtsSession = new TtsSession();
export async function capSpeakerUse(text: string): Promise<CapResult> {
return ttsSession.speak(text);
}
// ===== deviceinfo =====
export function capDeviceInfo(): CapResult {
const lines: string[] = [];
lines.push('brand=' + deviceInfo.brand);
lines.push('manufacturer=' + deviceInfo.manufacture);
lines.push('model=' + deviceInfo.productModel);
lines.push('series=' + deviceInfo.productSeries);
lines.push('osFullName=' + deviceInfo.osFullName);
lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString());
lines.push('securityPatch=' + deviceInfo.securityPatchTag);
lines.push('abiList=' + deviceInfo.abiList);
lines.push('deviceType=' + deviceInfo.deviceType);
return okResult(lines.join('\n'));
}
// ===== screensue 内容解析 =====
// 服务端协议: screensue [秒] <内容>0=常驻。
export interface ScreensuePayload {
duration: number; // 秒0 表示常驻直到用户关闭
content: string;
}
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
let rest: string = rawArgs.trim();
const tokens: string[] = rest.split(/\s+/);
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
p.duration = parseInt(tokens[0], 10);
rest = tokens.slice(1).join(' ');
} else {
rest = tokens.join(' ');
}
p.content = rest.trim();
return p;
}

View File

@ -0,0 +1,99 @@
import { deviceBridge, CmdReply } from './DeviceBridge';
import {
CapResult,
capScreensee,
capClipboardSee,
capClipboardsue,
capSpeakerUse,
capDeviceInfo,
parseScreensue,
ScreensuePayload,
} from './BridgeCaps';
import { common } from '@kit.AbilityKit';
// screensue 展示回调由 UI 层注册Index 挂全局悬浮层)
export type ScreensueHandler = (payload: ScreensuePayload) => void;
let screensueHandler: ScreensueHandler | null = null;
let appContext: common.UIAbilityContext | null = null;
export function registerScreensueHandler(handler: ScreensueHandler): void {
screensueHandler = handler;
}
export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
appContext = ctx;
}
/** 解析 homeagent-* 命令:返回能力名与参数串。 */
function splitCapability(command: string): string[] {
const cmd: string = command.trim();
const idx: number = cmd.indexOf(' ');
if (idx < 0) {
return [cmd];
}
const out: string[] = [cmd.substring(0, idx), cmd.substring(idx + 1)];
return out;
}
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
const parts: string[] = splitCapability(command);
const name: string = parts[0];
const args: string = parts.length > 1 ? parts[1] : '';
// screensee 截屏回传data URL 走文本结果,服务端兼容)
if (name === 'screensee') {
return capScreensee();
}
if (name === 'screensue') {
const payload: ScreensuePayload = parseScreensue(args);
if (screensueHandler !== null) {
screensueHandler(payload);
return okRes('shown');
}
return errRes('screensue: display layer not ready');
}
if (name === 'clipboardsee') {
if (appContext === null) {
return errRes('clipboardsee: app context missing');
}
return capClipboardSee(appContext);
}
if (name === 'clipboardsue') {
if (args.length === 0) {
return errRes('clipboardsue: empty text');
}
return capClipboardsue(args);
}
if (name === 'speakeruse') {
if (args.length === 0) {
return errRes('speakeruse: empty text');
}
return capSpeakerUse(args);
}
if (name === 'deviceinfo' || name === 'status') {
return capDeviceInfo();
}
if (name === 'camerasue') {
return errRes('camerasue: camera capture not supported on this build');
}
if (name === 'computeruse') {
return errRes('computeruse: not applicable to touch-only device');
}
return errRes('unsupported homeagent capability: ' + name);
}
function okRes(output: string): CapResult {
const r: CapResult = { status: 'ok', output: output, error: '' };
return r;
}
function errRes(errMsg: string): CapResult {
const r: CapResult = { status: 'error', output: '', error: errMsg };
return r;
}
/** 安装命令处理器到 bridge 单例。 */
export function installCmdRouter(): void {
deviceBridge.setCmdHandler(executeCommand);
}

View File

@ -0,0 +1,240 @@
import { preferences } from '@kit.ArkData';
import { Context } from '@kit.AbilityKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import { ConnectionConfig, AppSettings, emptySettings, defaultConnection } from '../model/Model';
const PREF_NAME: string = 'homeagent_prefs';
const KEY_CONNECTIONS: string = 'connections_json';
const KEY_SETTINGS: string = 'settings_json';
const KEY_DEVICE_ID: string = 'device_id';
const KEY_DEVICE_NAME: string = 'device_name';
const KEY_DEVICE_AUTH: string = 'device_authorized';
export class ConnStore {
private prefs: preferences.Preferences | null = null;
private connections: ConnectionConfig[] = [];
private settings: AppSettings = emptySettings();
async init(context: Context): Promise<void> {
this.prefs = await preferences.getPreferences(context, PREF_NAME);
await this.load();
}
private async load(): Promise<void> {
if (this.prefs === null) {
return;
}
const connJson: string = await this.prefs.get(KEY_CONNECTIONS, '') as string;
if (connJson.length > 0) {
try {
this.connections = JSON.parse(connJson) as ConnectionConfig[];
} catch (e) {
this.connections = [];
}
}
const settingsJson: string = await this.prefs.get(KEY_SETTINGS, '') as string;
if (settingsJson.length > 0) {
try {
this.settings = JSON.parse(settingsJson) as AppSettings;
} catch (e) {
this.settings = emptySettings();
}
}
}
// 首次启动时预置默认连接(用户后端),避免所有页面空白
async ensureDefaultConnection(): Promise<void> {
await this.load();
if (this.connections.length > 0) {
return;
}
const conn: ConnectionConfig = defaultConnection();
conn.id = 'default';
this.connections.push(conn);
this.settings.currentConnId = conn.id;
await this.save();
}
getConnections(): ConnectionConfig[] {
return this.connections;
}
getCurrentConnection(): ConnectionConfig | null {
if (this.settings.currentConnId.length === 0) {
if (this.connections.length > 0) {
return this.connections[0];
}
return null;
}
for (let i = 0; i < this.connections.length; i++) {
const c: ConnectionConfig = this.connections[i];
if (c.id === this.settings.currentConnId) {
return c;
}
}
if (this.connections.length > 0) {
return this.connections[0];
}
return null;
}
async addConnection(name: string, url: string, apiKey: string): Promise<ConnectionConfig> {
const conn: ConnectionConfig = defaultConnection();
conn.id = Date.now().toString(36) + Math.floor(Math.random() * 10000).toString(36);
conn.name = name;
conn.url = url;
conn.apiKey = apiKey;
conn.type = 'webui';
this.connections.push(conn);
if (this.settings.currentConnId.length === 0) {
this.settings.currentConnId = conn.id;
}
await this.save();
return conn;
}
async updateConnection(id: string, name: string, url: string,
apiKey: string): Promise<void> {
for (let i = 0; i < this.connections.length; i++) {
const c: ConnectionConfig = this.connections[i];
if (c.id === id) {
c.name = name;
c.url = url;
c.apiKey = apiKey;
break;
}
}
await this.save();
}
async deleteConnection(id: string): Promise<void> {
const next: ConnectionConfig[] = [];
for (let i = 0; i < this.connections.length; i++) {
const c: ConnectionConfig = this.connections[i];
if (c.id !== id) {
next.push(c);
}
}
this.connections = next;
if (this.settings.currentConnId === id) {
if (this.connections.length > 0) {
this.settings.currentConnId = this.connections[0].id;
} else {
this.settings.currentConnId = '';
}
}
await this.save();
}
async setCurrent(id: string): Promise<void> {
this.settings.currentConnId = id;
await this.save();
}
getSettings(): AppSettings {
return this.settings;
}
async saveSettings(s: AppSettings): Promise<void> {
this.settings = s;
await this.save();
}
/** Persisted local device id (stable across restarts). */
getDeviceId(): string {
if (this.prefs === null) {
return '';
}
return this.prefs.getSync(KEY_DEVICE_ID, '') as string;
}
saveDeviceId(id: string): void {
if (this.prefs === null) {
return;
}
this.prefs.putSync(KEY_DEVICE_ID, id);
this.prefs.flush();
}
/**
* 取本机设备 ID没有就地生成并持久化。
*
* 聊天发送必须带 device_id后端 handleChat 只有拿到它才会把来源编码成
* webui/<device_id> 并往 stageCtx 注入"当前输入来自设备[...]"
* 否则 source 恒为 "webui"agent 会以为消息是网页端发的。
* 之前只有设备页在用时才生成 ID聊天页拿到空串 → 身份丢失。
*/
ensureDeviceId(): string {
const cur: string = this.getDeviceId();
if (cur.length > 0) {
return cur;
}
const id: string = 'ohos-' + Date.now().toString(36);
this.saveDeviceId(id);
return id;
}
/** 设备显示名:优先用户自定义,否则按机型自动生成("HUAWEI Mate 60 (HarmonyOS)")。 */
getDeviceName(): string {
if (this.prefs !== null) {
const saved: string = this.prefs.getSync(KEY_DEVICE_NAME, '') as string;
if (saved.length > 0) {
return saved;
}
}
return ConnStore.autoDeviceName();
}
saveDeviceName(name: string): void {
if (this.prefs === null) {
return;
}
this.prefs.putSync(KEY_DEVICE_NAME, name);
this.prefs.flush();
}
/** 由 deviceInfo 拼一个人看得懂的机器名;静态方法内不能用 this。 */
private static autoDeviceName(): string {
let name: string = '';
try {
const brand: string = deviceInfo.brand;
const model: string = deviceInfo.productModel;
if (model.length > 0) {
name = brand.length > 0 && model.indexOf(brand) < 0 ? brand + ' ' + model : model;
}
} catch (e) {
name = '';
}
if (name.length === 0) {
name = 'HarmonyOS 设备';
}
return name;
}
/** Local authorization flag reported via hello; server stores nothing. */
getDeviceAuth(): boolean {
if (this.prefs === null) {
return false;
}
return this.prefs.getSync(KEY_DEVICE_AUTH, false) as boolean;
}
saveDeviceAuth(on: boolean): void {
if (this.prefs === null) {
return;
}
this.prefs.putSync(KEY_DEVICE_AUTH, on);
this.prefs.flush();
}
private async save(): Promise<void> {
if (this.prefs === null) {
return;
}
await this.prefs.put(KEY_CONNECTIONS, JSON.stringify(this.connections));
await this.prefs.put(KEY_SETTINGS, JSON.stringify(this.settings));
await this.prefs.flush();
}
}
export const connStore: ConnStore = new ConnStore();

View File

@ -0,0 +1,328 @@
/**
* Design tokens — mirror of gui/renderer/style.css `:root` (dark theme).
* All values copied 1:1 from the web GUI to keep visual identity identical.
*
* Theme system:
* - ThemePalette holds every color token used by the app.
* - DARK_PALETTE mirrors style.css `:root`; LIGHT_PALETTE mirrors
* `[data-theme="light"][data-color="sakura"]`.
* - themeStore keeps a global AppStorage("themeIsDark") flag that every page
* reads through @StorageProp, so the whole UI re-renders on switch.
*/
// ===== network =====
export const DEFAULT_API_TIMEOUT: number = 120000;
export const SSE_RECONNECT_DELAY: number = 5000;
export const DEFAULT_WS_PORT: number = 9890;
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
export const CHAT_PAGE_SIZE: number = 40;
// ===== sakura / frost palette (style.css :root) =====
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';
export const COLOR_SAKURA_300: string = '#5B93F8';
export const COLOR_SAKURA_400: string = '#0A59F7';
export const COLOR_SAKURA_500: string = '#0A59F7';
export const COLOR_SAKURA_600: string = '#0A59F7';
export const COLOR_SAKURA_700: string = '#0846C2';
export const COLOR_FROST_300: string = '#4A90D9';
export const COLOR_FROST_400: string = '#3A78B5';
export const COLOR_FROST_500: string = '#2C5E8C';
export const COLOR_SUCCESS: string = '#30B260';
export const COLOR_WARNING: string = '#D99A2B';
export const COLOR_ERROR: string = '#E84026';
export const COLOR_INFO: string = '#3F6EF5';
export const COLOR_CYAN: string = '#2DD4BF';
export const COLOR_VIOLET: string = '#A78BFA';
export const COLOR_EMERALD: string = '#34D399';
export const COLOR_AMBER: string = '#FBBF24';
export const COLOR_BLUE: string = '#60A5FA';
// ===== 宽屏(平板 / 折叠展开 / 分屏)适配 =====
/**
* 宽屏断点:窗口宽度 >= 600vp 视为宽屏。
* 600vp = Navigation 分栏所需的 minNavBarWidth(240) + minContentWidth(360)
* 与系统 NavigationMode.Auto 的切换阈值保持一致,避免自判与系统行为脱节。
*/
export const WIDE_MIN_WIDTH: number = 600;
/** 宽屏下左侧一级界面栏宽度vp左边一级界面右边二级界面 */
export const WIDE_NAV_BAR_WIDTH: number = 420;
/** 宽屏下右侧二级界面的最小宽度vp不足时左栏被压缩 */
export const WIDE_MIN_CONTENT: number = 380;
/** 悬浮导航胶囊的最大宽度vp宽屏下避免被拉成长条 */
export const NAV_PILL_MAX_WIDTH: number = 620;
/** Every color token the app renders with. */
export interface ThemePalette {
bgPrimary: string;
bgSecondary: string;
bgCard: string;
bgInput: string;
bgHover: string;
glassBgStrong: string;
glassBorder: string;
border: string;
kvBorder: string;
btnGhostBorder: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
textTertiary: string;
accent: string;
accentBg: string;
preBg: string;
preText: string;
msgUserBg: string;
msgUserText: string;
msgAssistantBg: string;
msgAssistantText: string;
msgBubbleBg: string;
msgBubbleText: string;
msgBubbleBorder: string;
msgUserBubbleBg: string;
msgUserBubbleBorder: string;
msgAssistantBubbleBg: string;
msgAssistantBubbleBorder: string;
toastBg: string;
toastText: string;
toastErrorBg: string;
toastErrorText: string;
toastWarnBg: string;
toastWarnText: string;
successSoftBg: string;
errorSoftBg: string;
frostSoftBg: string;
navBarBg: string;
navBarBorder: string;
navBarGradientStart: string;
navBarGradientEnd: string;
shadow: string;
gradA: string; // sakura top-right glow (start color)
gradB: string; // frost mid-left glow
gradC: string; // sakura bottom glow
gradAEnd: string; // transparent end color for radial fade
gradBEnd: string;
gradCEnd: string;
}
/** Dark palette — HarmonyOS system-app style neutral dark. */
export const DARK_PALETTE: ThemePalette = {
bgPrimary: '#000000',
bgSecondary: 'rgba(28, 28, 30, 0.72)',
bgCard: 'rgba(28, 28, 30, 0.6)',
bgInput: 'rgba(44, 44, 46, 0.92)',
bgHover: 'rgba(255, 255, 255, 0.08)',
glassBgStrong: 'rgba(28, 28, 30, 0.82)',
glassBorder: 'rgba(255, 255, 255, 0.1)',
border: 'rgba(255, 255, 255, 0.1)',
kvBorder: 'rgba(255, 255, 255, 0.08)',
btnGhostBorder: 'rgba(255, 255, 255, 0.18)',
textPrimary: '#FFFFFF',
textSecondary: '#D1D1D6',
textMuted: '#98989F',
textTertiary: '#8E8E93',
accent: '#0A59F7',
accentBg: 'rgba(10, 89, 247, 0.18)',
preBg: 'rgba(20, 20, 22, 0.9)',
preText: '#D6E4FF',
msgUserBg: 'rgba(10, 89, 247, 0.2)',
msgUserText: '#9DC0FC',
msgAssistantBg: 'rgba(120, 120, 128, 0.24)',
msgAssistantText: '#E5E5EA',
msgBubbleBg: '#1C1C1E',
msgBubbleText: '#F2F2F7',
msgBubbleBorder: 'rgba(255, 255, 255, 0.12)',
msgUserBubbleBg: 'rgba(10, 89, 247, 0.18)',
msgUserBubbleBorder: 'rgba(10, 89, 247, 0.25)',
msgAssistantBubbleBg: 'rgba(58, 58, 60, 0.72)',
msgAssistantBubbleBorder: 'rgba(255, 255, 255, 0.1)',
toastBg: 'rgba(48, 178, 96, 0.18)',
toastText: '#4CD47A',
toastErrorBg: 'rgba(232, 64, 38, 0.2)',
toastErrorText: '#FF6B4A',
toastWarnBg: 'rgba(255, 159, 10, 0.16)',
toastWarnText: '#FFCC66',
successSoftBg: 'rgba(48, 178, 96, 0.16)',
errorSoftBg: 'rgba(232, 64, 38, 0.18)',
frostSoftBg: 'rgba(74, 144, 217, 0.14)',
navBarBg: 'rgba(24, 24, 26, 0.45)',
navBarBorder: 'rgba(255, 255, 255, 0.14)',
navBarGradientStart: 'rgba(40, 40, 44, 0.35)',
navBarGradientEnd: 'rgba(20, 20, 22, 0.5)',
shadow: 'rgba(0, 0, 0, 0.5)',
gradA: 'rgba(10, 89, 247, 0.1)',
gradB: 'rgba(74, 144, 217, 0.07)',
gradC: 'rgba(94, 92, 230, 0.06)',
gradAEnd: 'rgba(10, 89, 247, 0)',
gradBEnd: 'rgba(74, 144, 217, 0)',
gradCEnd: 'rgba(94, 92, 230, 0)',
};
/** Light palette — HarmonyOS system-app style neutral light. */
export const LIGHT_PALETTE: ThemePalette = {
bgPrimary: '#F1F3F5',
bgSecondary: 'rgba(255, 255, 255, 0.85)',
bgCard: 'rgba(255, 255, 255, 0.95)',
bgInput: 'rgba(118, 118, 128, 0.28)',
bgHover: 'rgba(0, 0, 0, 0.05)',
glassBgStrong: 'rgba(255, 255, 255, 0.92)',
glassBorder: 'rgba(60, 60, 67, 0.12)',
border: 'rgba(60, 60, 67, 0.12)',
kvBorder: 'rgba(60, 60, 67, 0.1)',
btnGhostBorder: 'rgba(60, 60, 67, 0.2)',
textPrimary: '#191919',
textSecondary: '#494949',
textMuted: '#777779',
textTertiary: '#8A8A8E',
accent: '#0A59F7',
accentBg: 'rgba(10, 89, 247, 0.1)',
preBg: 'rgba(118, 118, 128, 0.1)',
preText: '#3C3C43',
msgUserBg: 'rgba(10, 89, 247, 0.12)',
msgUserText: '#0A59F7',
msgAssistantBg: '#FFFFFF',
msgAssistantText: '#333333',
msgBubbleBg: '#FFFFFF',
msgBubbleText: '#191919',
msgBubbleBorder: 'rgba(60, 60, 67, 0.12)',
msgUserBubbleBg: 'rgba(10, 89, 247, 0.1)',
msgUserBubbleBorder: 'rgba(10, 89, 247, 0.18)',
msgAssistantBubbleBg: 'rgba(255, 255, 255, 0.88)',
msgAssistantBubbleBorder: 'rgba(60, 60, 67, 0.1)',
toastBg: 'rgba(48, 178, 96, 0.14)',
toastText: '#157347',
toastErrorBg: 'rgba(232, 64, 38, 0.12)',
toastErrorText: '#C0361F',
toastWarnBg: 'rgba(255, 159, 10, 0.14)',
toastWarnText: '#8F5A00',
successSoftBg: 'rgba(48, 178, 96, 0.12)',
errorSoftBg: 'rgba(232, 64, 38, 0.1)',
frostSoftBg: 'rgba(74, 144, 217, 0.12)',
navBarBg: 'rgba(250, 250, 252, 0.55)',
navBarBorder: 'rgba(60, 60, 67, 0.15)',
navBarGradientStart: 'rgba(255, 255, 255, 0.35)',
navBarGradientEnd: 'rgba(240, 240, 245, 0.5)',
shadow: 'rgba(0, 0, 0, 0.1)',
gradA: 'rgba(10, 89, 247, 0.06)',
gradB: 'rgba(74, 144, 217, 0.05)',
gradC: 'rgba(94, 92, 230, 0.04)',
gradAEnd: 'rgba(10, 89, 247, 0)',
gradBEnd: 'rgba(74, 144, 217, 0)',
gradCEnd: 'rgba(94, 92, 230, 0)',
};
/**
* Legacy single-theme constants kept for incremental migration.
* New code should use tp() / ThemePalette instead.
*/
export const COLOR_BG_PRIMARY: string = '#000000';
export const COLOR_BG_SECONDARY: string = 'rgba(17, 24, 44, 0.72)';
export const COLOR_BG_CARD: string = 'rgba(17, 24, 44, 0.6)';
export const COLOR_BG_INPUT: string = 'rgba(13, 18, 34, 0.75)';
export const COLOR_BG_HOVER: string = 'rgba(255, 255, 255, 0.06)';
export const COLOR_GLASS_BG: string = 'rgba(13, 18, 34, 0.6)';
export const COLOR_GLASS_BG_STRONG: string = 'rgba(13, 18, 32, 0.82)';
export const COLOR_GLASS_BORDER: string = 'rgba(255, 255, 255, 0.08)';
export const COLOR_GLASS_HOVER: string = 'rgba(255, 255, 255, 0.05)';
export const COLOR_TEXT_PRIMARY: string = '#FFFFFF';
export const COLOR_TEXT_SECONDARY: string = 'rgba(255, 255, 255, 0.6)';
export const COLOR_TEXT_MUTED: string = 'rgba(255, 255, 255, 0.4)';
export const COLOR_TEXT_TERTIARY: string = 'rgba(255, 255, 255, 0.45)';
export const COLOR_ACCENT: string = '#0A59F7';
export const COLOR_ACCENT_BG: string = 'rgba(10, 89, 247, 0.14)';
export const COLOR_BORDER: string = 'rgba(255, 255, 255, 0.09)';
export const COLOR_KV_BORDER: string = 'rgba(255, 255, 255, 0.07)';
export const COLOR_BTN_GHOST_BORDER: string = 'rgba(255, 255, 255, 0.14)';
export const COLOR_SAVE_BTN_BORDER: string = '#D99A2B';
export const COLOR_TOAST_BG: string = 'rgba(23, 169, 100, 0.16)';
export const COLOR_TOAST_TEXT: string = '#6EE7A8';
export const COLOR_TOAST_ERROR_BG: string = 'rgba(232, 64, 38, 0.18)';
export const COLOR_TOAST_ERROR_TEXT: string = '#F0865B';
export const COLOR_TOAST_WARN_BG: string = 'rgba(217, 154, 43, 0.16)';
export const COLOR_TOAST_WARN_TEXT: string = '#FCD9A0';
// ===== radius (style.css --radius-*) =====
export const RADIUS_SM: number = 6;
export const RADIUS_MD: number = 10;
export const RADIUS_LG: number = 14;
export const RADIUS_PILL: number = 999;
// ===== misc states =====
export const COLOR_CONN_OFFLINE: string = '#77809A';
export const COLOR_DOT_GRAY: string = '#475569';
// =====================================================================
// Global reactive theme store.
//
// AppStorage keys:
// "themeIsDark" boolean — current resolved dark/light state
// "themeMode" string — 'system' | 'dark' | 'light'
//
// Pages read via @StorageProp("themeIsDark") and pick colors from
// tp() so the whole tree re-renders when the mode flips.
// EntryAbility seeds both on launch and updates "themeIsDark" on
// onConfigurationUpdate (system dark-mode change).
// =====================================================================
const KEY_THEME_IS_DARK: string = 'themeIsDark';
const KEY_SYSTEM_IS_DARK: string = 'systemIsDark';
const KEY_THEME_MODE: string = 'themeMode';
export function seedTheme(isDark: boolean): void {
if (!AppStorage.has(KEY_THEME_IS_DARK)) {
AppStorage.setOrCreate(KEY_THEME_IS_DARK, isDark);
} else {
AppStorage.set(KEY_THEME_IS_DARK, isDark);
}
}
/**
* Record the OS dark/light state. UI never binds to this directly, but the
* 'system' mode resolves against it, so EntryAbility updates it on config
* change and then flips themeIsDark when mode === 'system'.
*/
export function seedSystemIsDark(isDark: boolean): void {
if (!AppStorage.has(KEY_SYSTEM_IS_DARK)) {
AppStorage.setOrCreate(KEY_SYSTEM_IS_DARK, isDark);
} else {
AppStorage.set(KEY_SYSTEM_IS_DARK, isDark);
}
}
/**
* Resolve a stored theme mode ('system'|'dark'|'light') against the current
* system color mode. 'system' falls back to the systemIsDark flag.
*/
export function resolveIsDark(mode: string, sysDark: boolean): boolean {
if (mode === 'dark') {
return true;
}
if (mode === 'light') {
return false;
}
return sysDark;
}
/**
* Apply a stored theme mode: persist the mode token and immediately flip the
* reactive themeIsDark flag so the whole UI re-renders.
*/
export function applyThemeMode(mode: string): void {
const sysDark: boolean = AppStorage.get<boolean>(KEY_SYSTEM_IS_DARK) ?? true;
AppStorage.set(KEY_THEME_MODE, mode);
seedTheme(resolveIsDark(mode, sysDark));
}
/** Current resolved palette for @Builder / build() usage. */
export function tp(): ThemePalette {
const dark: boolean = AppStorage.get<boolean>(KEY_THEME_IS_DARK) ?? true;
return dark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** Raw read for non-UI code. */
export function themeIsDark(): boolean {
return AppStorage.get<boolean>(KEY_THEME_IS_DARK) ?? true;
}

View File

@ -0,0 +1,394 @@
import { webSocket } from '@kit.NetworkKit';
import { DeviceInfo } from '../model/Model';
import { CapResult } from './BridgeCaps';
// ===== 协议消息(与 remotedevice 插件对齐)=====
interface HelloDeviceInfo {
hostname: string;
platform: string;
arch: string;
os_release: string;
version: string;
cpus: number;
}
interface HelloDevice {
device_id: string;
name: string;
kind: string;
authorized: boolean;
caps: string[];
info: HelloDeviceInfo;
}
interface HelloMessage {
op: string;
device: HelloDevice;
}
interface BindMessage {
op: string;
device_id: string;
token: string;
}
interface CmdMessage {
op: string;
req_id: string;
command: string;
cmd_type: string;
}
export interface CmdReply {
op: string; // 'cmd_result'
req_id: string;
status: string;
output: string;
error: string;
}
interface DataStartMessage {
op: string;
req_id: string;
kind: string;
mime: string;
total: number;
chunk_size: number;
}
interface DataEndMessage {
op: string;
req_id: string;
status: string;
total?: number;
error?: string;
}
const CHUNK_SIZE: number = 8192;
// ===== 命令处理器回调 =====
// 返回 CapResult二进制大结果通过 dataHandler 分块回传。
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
export class DeviceBridgeClient {
private ws: webSocket.WebSocket = webSocket.createWebSocket();
private url: string = '';
private token: string = '';
private deviceId: string = '';
private name: string = 'HomeAgent OHOS';
private kind: string = 'phone';
private caps: string[] = [];
private hostname: string = 'ohos';
private connected: boolean = false;
private everConnected: boolean = false;
private manualClose: boolean = false;
private reconnectTimer: number = -1;
private cmdHandler: BridgeCmdHandler | null = null;
private onStateChange: ((open: boolean) => void) | null = null;
isConnected(): boolean {
return this.connected;
}
getDeviceId(): string {
return this.deviceId;
}
setCmdHandler(handler: BridgeCmdHandler): void {
this.cmdHandler = handler;
}
setStateListener(listener: (open: boolean) => void): void {
this.onStateChange = listener;
}
async connect(url: string, token: string, deviceId: string,
caps: string[], hostname: string,
authorized: boolean, name: string): Promise<void> {
this.url = url;
this.token = token;
this.deviceId = deviceId;
this.caps = caps;
this.hostname = hostname;
this.name = name;
this.manualClose = false;
await this.openAndRegister(authorized);
}
private async openAndRegister(authorized: boolean): Promise<void> {
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
try {
this.ws.off('open');
this.ws.off('message');
this.ws.off('close');
this.ws.off('error');
} catch (e) {
// ignore
}
this.ws = webSocket.createWebSocket();
this.bindWsEvents(authorized);
// 鉴权必须走请求头,不能拼 ?token=
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里,
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer
// 查询参数一律视为未授权 → 握手被 401 顶掉,
// 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。
// 2) 反代到 remotedevice 时会自行注入网关的 ws_token
// 如果我们再带 ?token=<webui apiKey>remotedevice 的 ServeWS
// 会拿它和 ws_token 比对并 401。留空反而放行。
const opts: webSocket.WebSocketRequestOptions = {
header: this.authHeader(),
};
try {
await this.ws.connect(this.url, opts);
} catch (e) {
this.connected = false;
this.scheduleReconnect();
}
}
/** 握手请求头X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
private authHeader(): Record<string, string> {
const h: Record<string, string> = {};
if (this.token.length > 0) {
h['X-API-Key'] = this.token;
h['Authorization'] = 'Bearer ' + this.token;
}
return h;
}
private bindWsEvents(authorized: boolean): void {
this.ws.on('open', (err: Error, value: Object) => {
this.connected = true;
this.everConnected = true;
this.cancelReconnect();
this.sendHello(authorized);
this.sendBind();
if (this.onStateChange !== null) {
this.onStateChange(true);
}
});
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
if (typeof value === 'string') {
this.handleTextFrame(value);
}
});
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
this.connected = false;
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
});
this.ws.on('error', (err: Error) => {
this.connected = false;
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
});
}
private scheduleReconnect(): void {
if (this.manualClose || this.reconnectTimer >= 0) {
return;
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = -1;
if (this.manualClose || this.url.length === 0) {
return;
}
this.openAndRegister(this.lastAuthorized);
}, 5000);
}
private lastAuthorized: boolean = false;
private cancelReconnect(): void {
if (this.reconnectTimer >= 0) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = -1;
}
}
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
updateAuthorized(authorized: boolean): void {
this.lastAuthorized = authorized;
if (this.connected) {
this.sendHello(authorized);
}
}
disconnect(): void {
this.manualClose = true;
this.cancelReconnect();
this.connected = false;
try {
this.ws.off('open');
this.ws.off('message');
this.ws.off('close');
this.ws.off('error');
this.ws.close().catch(() => {
// ignore
});
} catch (e) {
// ignore
}
if (this.onStateChange !== null) {
this.onStateChange(false);
}
}
private sendHello(authorized: boolean): void {
this.lastAuthorized = authorized;
const info: HelloDeviceInfo = {
hostname: this.hostname,
platform: 'OpenHarmony',
arch: '',
os_release: '',
version: '1.1.0',
cpus: 0,
};
const device: HelloDevice = {
device_id: this.deviceId,
name: this.name,
kind: this.kind,
authorized: authorized,
caps: this.caps,
info: info,
};
const hello: HelloMessage = { op: 'hello', device: device };
this.send(JSON.stringify(hello));
}
private sendBind(): void {
const bind: BindMessage = {
op: 'bind',
device_id: this.deviceId,
token: this.token,
};
this.send(JSON.stringify(bind));
}
// ===== 命令处理 =====
private handleTextFrame(text: string): void {
let obj: Record<string, Object>;
try {
obj = JSON.parse(text) as Record<string, Object>;
} catch (e) {
return;
}
const op: string = obj['op'] as string ?? '';
if (op === 'cmd') {
const reqId: string = obj['req_id'] as string ?? '';
const command: string = obj['command'] as string ?? '';
if (reqId.length === 0 || command.length === 0) {
return;
}
if (!this.lastAuthorized) {
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
return;
}
this.dispatchCommand(reqId, command);
} else if (op === 'hello_ack' || op === 'bind_ack') {
if (this.onAck !== null) {
this.onAck(op);
}
}
}
onAck: ((op: string) => void) | null = null;
private dispatchCommand(reqId: string, command: string): void {
if (this.cmdHandler === null) {
this.sendResult(reqId, 'error', '', 'no capability handler registered');
return;
}
const handler: BridgeCmdHandler = this.cmdHandler;
handler(reqId, command).then((res: CapResult) => {
this.sendResult(reqId, res.status, res.output, res.error);
}).catch((e: Object) => {
const msg: string = e instanceof Error ? e.message : String(e);
this.sendResult(reqId, 'error', '', msg);
});
}
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
const result: CmdReply = {
op: 'cmd_result',
req_id: reqId,
status: status,
output: output,
error: errMsg,
};
this.send(JSON.stringify(result));
}
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
const startMsg: DataStartMessage = {
op: 'cmd_data_start',
req_id: reqId,
kind: kind,
mime: mime,
total: bytes.byteLength,
chunk_size: CHUNK_SIZE,
};
this.send(JSON.stringify(startMsg));
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
const view: Uint8Array = bytes.slice(off, end);
const ab: ArrayBuffer = view.buffer as ArrayBuffer;
try {
this.ws.send(ab).catch(() => {
// ignore per-chunk failure; end frame reports error below
});
} catch (e) {
break;
}
}
const endMsg: DataEndMessage = {
op: 'cmd_data_end',
req_id: reqId,
status: 'ok',
};
this.send(JSON.stringify(endMsg));
}
sendEvent(eventType: string, detail: string): void {
const payload: Record<string, string> = { 'detail': detail };
const msg: Record<string, Object> = {
'op': 'event',
'device_id': this.deviceId,
'type': eventType,
'payload': payload,
};
this.send(JSON.stringify(msg));
}
sendStatus(status: string): void {
const msg: Record<string, Object> = {
'op': 'status',
'device_id': this.deviceId,
'status': status,
};
this.send(JSON.stringify(msg));
}
send(text: string): void {
if (!this.connected) {
return;
}
this.ws.send(text).catch(() => {
// ignore
});
}
}
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
return [];
}

View File

@ -0,0 +1,55 @@
/**
* 导航栏显隐控制器(跨页面共享单例)。
* 规则:任何页面滚动中隐藏底部导航,滚动停止(松手/fling 结束)后重新显示。
*/
type NavListener = (visible: boolean) => void;
export class NavBarController {
private static instance: NavBarController | null = null;
private visible: boolean = true;
private listeners: NavListener[] = [];
static shared(): NavBarController {
if (NavBarController.instance === null) {
NavBarController.instance = new NavBarController();
}
return NavBarController.instance;
}
isVisible(): boolean {
return this.visible;
}
setVisible(v: boolean): void {
if (this.visible !== v) {
this.visible = v;
AppStorage.setOrCreate<boolean>('navVisible', v);
this.notify();
}
}
addListener(l: NavListener): void {
this.listeners.push(l);
}
private notify(): void {
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i](this.visible);
}
}
}
export const navBar: NavBarController = NavBarController.shared();
/**
* 页面 Scroll.onDidScroll 的统一处理:
* Scroll/Fling 状态(手指拖动或惯性滚动)隐藏导航栏,
* Idle松手或惯性结束重新显示。
*/
export function handleNavOnScroll(state: ScrollState): void {
if (state === ScrollState.Idle) {
navBar.setVisible(true);
} else {
navBar.setVisible(false);
}
}

View File

@ -0,0 +1,51 @@
/**
* 各主 Tab 页面的二级导航栈登记处。
*
* 为什么需要它:四个一级页面各自持有一个 Navigation宽屏要"左一级右二级"
* 所以栈必须是页面局部的,不能提到 Index 里去)。但四个 Navigation 同时
* 挂在 Swiper 里都是活的,系统返回事件落到哪一个并不确定 —— 表现就是
* "有的页面返回手势能用、有的不能"。
*
* 解决办法:页面把自己的栈按 Tab 序号登记进来,@Entry 页在 onBackPress 里
* 按当前 Tab 精确地 pop 对应的栈。这样返回手势/三键返回/无障碍返回
* 在每个页面上的行为都是确定的。
*/
interface StackEntry {
stack: NavPathStack;
/** pop 之后页面要同步自己的选中态宽屏高亮、activeXxx 等) */
onPopped: () => void;
}
const registry: Map<number, StackEntry> = new Map<number, StackEntry>();
/** 页面 aboutToAppear 时登记;同一 Tab 重复登记以最后一次为准。 */
export function registerNavStack(tab: number, stack: NavPathStack, onPopped: () => void): void {
registry.set(tab, { stack: stack, onPopped: onPopped });
}
export function unregisterNavStack(tab: number): void {
registry.delete(tab);
}
/**
* 返回键/返回手势的统一处理。
* 返回 true 表示已消费弹出了一层二级页面false 表示交回系统(退出应用)。
*
* 宽屏 Split 模式下右栏常驻,返回不该把它清空,所以那时直接不消费。
*/
export function handleBackPress(tab: number, isWide: boolean): boolean {
if (isWide) {
return false;
}
const e: StackEntry | undefined = registry.get(tab);
if (e === undefined) {
return false;
}
if (e.stack.size() <= 0) {
return false;
}
e.stack.pop();
e.onPopped();
return true;
}

View File

@ -0,0 +1,258 @@
import { http } from '@kit.NetworkKit';
import { ConnectionConfig } from '../model/Model';
export interface SseEvent {
event: string;
data: string;
id: string;
}
export type SseHandler = (ev: SseEvent) => void;
export type SseCloseHandler = () => void;
export type SseOpenHandler = () => void;
function decodeUtf8(bytes: Uint8Array): string {
let result: string = '';
let i: number = 0;
while (i < bytes.length) {
const b: number = bytes[i];
if (b < 0x80) {
result += String.fromCharCode(b);
i++;
} else if (b < 0xC0) {
i++;
} else if (b < 0xE0) {
if (i + 1 < bytes.length) {
result += String.fromCharCode(((b & 0x1F) << 6) | (bytes[i + 1] & 0x3F));
i += 2;
} else {
i++;
}
} else if (b < 0xF0) {
if (i + 2 < bytes.length) {
result += String.fromCharCode(
((b & 0x0F) << 12) | ((bytes[i + 1] & 0x3F) << 6) | (bytes[i + 2] & 0x3F),
);
i += 3;
} else {
i++;
}
} else {
if (i + 3 < bytes.length) {
const cp: number =
((b & 0x07) << 18) |
((bytes[i + 1] & 0x3F) << 12) |
((bytes[i + 2] & 0x3F) << 6) |
(bytes[i + 3] & 0x3F);
const adjusted: number = cp - 0x10000;
result += String.fromCharCode(0xD800 + (adjusted >> 10));
result += String.fromCharCode(0xDC00 + (adjusted & 0x3FF));
i += 4;
} else {
i++;
}
}
}
return result;
}
export class SseClient {
private httpRequest: http.HttpRequest | null = null;
private buffer: string = '';
private lastEventId: string = '';
private closed: boolean = false;
private opened: boolean = false;
private onEvent: SseHandler | null = null;
private onClose: SseCloseHandler | null = null;
private onOpen: SseOpenHandler | null = null;
setLastEventId(id: string): void {
this.lastEventId = id;
}
getLastEventId(): string {
return this.lastEventId;
}
async connect(conn: ConnectionConfig, path: string,
onEvent: SseHandler, onClose: SseCloseHandler,
onOpen: SseOpenHandler | null = null): Promise<void> {
this.onEvent = onEvent;
this.onClose = onClose;
this.onOpen = onOpen;
this.closed = false;
this.opened = false;
this.buffer = '';
this.curEvent = '';
this.curData = '';
this.curId = '';
const base: string = conn.url.replace(/\/+$/, '');
const url: string = base + '/api/v1' + path;
const headers: Record<string, string> = {
'Accept': 'text/event-stream',
'Cache-Control': 'no-store',
};
if (conn.apiKey.length > 0) {
// 后端 validAPIKey 两种都认;有些反代只放行 Authorization两个都带更稳
headers['X-API-Key'] = conn.apiKey;
headers['Authorization'] = 'Bearer ' + conn.apiKey;
}
if (this.lastEventId.length > 0) {
headers['Last-Event-ID'] = this.lastEventId;
}
const req: http.HttpRequest = http.createHttp();
this.httpRequest = req;
req.on('headersReceive', (header: Object) => {
const ct: string = this.getHeaderValue(header, 'content-type');
if (ct.indexOf('text/event-stream') >= 0) {
this.markOpened();
} else {
this.finish();
}
});
req.on('dataReceive', (chunk: ArrayBuffer) => {
if (this.closed) {
return;
}
this.markOpened();
const bytes: Uint8Array = new Uint8Array(chunk);
const text: string = decodeUtf8(bytes);
this.buffer += text;
this.processBuffer();
});
req.on('dataEnd', () => {
this.finish();
});
const options: http.HttpRequestOptions = {
method: http.RequestMethod.GET,
header: headers,
expectDataType: http.HttpDataType.ARRAY_BUFFER,
usingCache: false,
readTimeout: 3600000,
connectTimeout: 15000,
};
try {
await req.requestInStream(url, options);
} catch (e) {
this.finish();
}
}
private getHeaderValue(header: Object, name: string): string {
try {
const rec: Record<string, string> = header as Record<string, string>;
const lower: string = name.toLowerCase();
const keys: string[] = Object.keys(rec);
for (let i = 0; i < keys.length; i++) {
if (keys[i].toLowerCase() === lower) {
const val: string | undefined = rec[keys[i]];
return val !== undefined ? val : '';
}
}
} catch (e) {
// ignore
}
return '';
}
/**
* 帧解析状态必须【跨 chunk 保持】。
*
* 之前把 eventType/data/id 作为 processBuffer 的局部变量,
* 而 TCP 分片完全可能切在帧内部的换行处(服务端 16ms 批量 flush 时
* 一次写入几十帧,尾部被切开是常态):
* chunk1 = "...event: content_delta\n"
* chunk2 = "data: {...}\n\n"
* 于是 chunk1 解析出的 event 被丢掉chunk2 只剩 data 而没有事件名,
* 整帧被静默丢弃 —— 表现就是"工具调用和思考不显示、也不是流式"。
*/
private curEvent: string = '';
private curData: string = '';
private curId: string = '';
private processBuffer(): void {
const lines: string[] = this.buffer.split('\n');
this.buffer = lines.pop() ?? '';
for (let i = 0; i < lines.length; i++) {
// 兼容 CRLF\r 会污染事件名与 JSON 尾部
let line: string = lines[i];
if (line.length > 0 && line.charAt(line.length - 1) === '\r') {
line = line.substring(0, line.length - 1);
}
if (line.startsWith(':')) {
// 注释行(心跳),忽略
continue;
}
if (line.startsWith('id:')) {
this.curId = line.substring(3).trim();
if (this.curId.length > 0) {
this.lastEventId = this.curId;
}
} else if (line.startsWith('event:')) {
this.curEvent = line.substring(6).trim();
} else if (line.startsWith('data:')) {
// SSE 规范data: 后的单个空格属于分隔符,其余原样保留;
// 多行 data 用换行拼接。
let chunk: string = line.substring(5);
if (chunk.startsWith(' ')) {
chunk = chunk.substring(1);
}
this.curData = this.curData.length > 0 ? this.curData + '\n' + chunk : chunk;
} else if (line === '') {
if (this.curEvent.length > 0 && this.curData.length > 0) {
const ev: SseEvent = { event: this.curEvent, data: this.curData, id: this.curId };
if (this.onEvent !== null) {
this.onEvent(ev);
}
}
this.curEvent = '';
this.curData = '';
this.curId = '';
}
}
}
close(): void {
this.closed = true;
this.opened = false;
if (this.httpRequest !== null) {
try {
this.httpRequest.off('dataReceive');
this.httpRequest.off('dataEnd');
this.httpRequest.off('headersReceive');
this.httpRequest.destroy();
} catch (e) {
// ignore
}
this.httpRequest = null;
}
}
private markOpened(): void {
if (this.opened || this.closed) {
return;
}
this.opened = true;
if (this.onOpen !== null) {
this.onOpen();
}
}
private finish(): void {
if (this.closed) {
return;
}
this.close();
if (this.onClose !== null) {
this.onClose();
}
}
}

View File

@ -0,0 +1,289 @@
import { apiClient } from './ApiClient';
import { userMessage, noConnectionMessage } from './UserError';
/**
* 运行状态数据源(单例)。
*
* 状态页已并入设置页:设置一级页顶部嵌一张摘要卡,明细走二级页。
* 摘要卡与明细页是两个独立组件,但必须显示同一份数据、只请求一次,
* 所以把请求与解析收拢到这里,标量通过 AppStorage 广播给两边。
*
* 字段口径与 WebGUI 的 概览/内核 两页一致:
* - GET /status → status / version / startedAt / agents
* - GET /kernel → agent_id / plugins / tools / llm / memory / documents /
* text_memory / knowledge / runtime
* 后端不提供 token 用量、配额、错误列表,所以这里也没有。
*/
/** 一行明细 */
export interface StatField {
label: string;
value: string;
}
/** 一组明细卡 */
export interface StatGroup {
title: string;
fields: StatField[];
}
// ===== AppStorage 键:摘要卡与明细页共用 =====
export const K_UP: string = 'statUp';
export const K_VERSION: string = 'statVersion';
export const K_STARTED: string = 'statStartedAt';
export const K_AGENTS: string = 'statAgents';
export const K_PLUGINS: string = 'statPlugins';
export const K_TOOLS: string = 'statTools';
export const K_ERR: string = 'statErr';
export const K_LOADING: string = 'statLoading';
export const K_REV: string = 'statRev';
class StatusStore {
/** 明细分组:只有明细页读它,不进 AppStorage数组同步语义太脆 */
private groups: StatGroup[] = [];
init(): void {
AppStorage.setOrCreate<boolean>(K_UP, false);
AppStorage.setOrCreate<string>(K_VERSION, '-');
AppStorage.setOrCreate<string>(K_STARTED, '');
AppStorage.setOrCreate<number>(K_AGENTS, 0);
AppStorage.setOrCreate<number>(K_PLUGINS, 0);
AppStorage.setOrCreate<number>(K_TOOLS, 0);
AppStorage.setOrCreate<string>(K_ERR, '');
AppStorage.setOrCreate<boolean>(K_LOADING, false);
AppStorage.setOrCreate<number>(K_REV, 0);
}
getGroups(): StatGroup[] {
return this.groups;
}
isUp(): boolean {
return AppStorage.get<boolean>(K_UP) ?? false;
}
async refresh(): Promise<void> {
if (!apiClient.hasConnection()) {
this.fail(noConnectionMessage());
return;
}
AppStorage.setOrCreate<boolean>(K_LOADING, true);
AppStorage.setOrCreate<string>(K_ERR, '');
try {
const resp = await apiClient.getWithTimeout('/status', 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const version: string = obj['version'] as string ?? '未知';
const status: string = obj['status'] as string ?? 'unknown';
const uptimeStr: string = obj['uptime'] as string ?? '';
const agents: number = obj['agents'] as number ?? 0;
const startedAt: string = obj['startedAt'] as string ?? '';
AppStorage.setOrCreate<boolean>(K_UP, true);
AppStorage.setOrCreate<string>(K_VERSION, version);
AppStorage.setOrCreate<number>(K_AGENTS, agents);
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
const systemFields: StatField[] = [
{ label: '版本', value: version },
{ label: '运行状态', value: statusText(status) },
{ label: '运行时长', value: uptimeStr.length > 0 ? uptimeStr : '-' },
];
if (startedAt.length > 0) {
systemFields.push({ label: '启动时间', value: formatTime(startedAt) });
}
const groups: StatGroup[] = [
{ title: '系统概览', fields: systemFields },
];
await this.collectKernel(groups);
this.groups = groups;
this.bump();
} catch (e) {
this.fail(userMessage('status.refresh', e));
}
AppStorage.setOrCreate<boolean>(K_LOADING, false);
}
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
private async collectKernel(groups: StatGroup[]): Promise<void> {
try {
const kResp = await apiClient.getWithTimeout('/kernel', 8000);
const k: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
const agentId: string = k['agent_id'] as string ?? 'main';
const startTime: string = k['start_time'] as string ?? '';
const pluginsArr: Object[] | undefined = k['plugins'] as Object[];
const toolsArr: Object[] | undefined = k['tools'] as Object[];
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
AppStorage.setOrCreate<number>(K_PLUGINS, pluginCount);
AppStorage.setOrCreate<number>(K_TOOLS, toolCount);
const kernelFields: StatField[] = [
{ label: 'Agent ID', value: agentId },
{ label: '已加载插件', value: pluginCount.toString() },
{ label: '工具数', value: toolCount.toString() },
];
if (startTime.length > 0) {
kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
}
groups.push({ title: '内核', fields: kernelFields });
// LLMprovider / 可用源 / 是否可用
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
if (llm !== undefined && llm !== null) {
const provider: string = llm['provider'] as string ?? '';
const sources: number = llm['sources'] as number ?? 0;
const available: boolean = llm['available'] as boolean ?? false;
groups.push({
title: '模型',
fields: [
{ label: 'Provider', value: provider.length > 0 ? provider : '未配置' },
{ label: '可用源', value: sources.toString() },
{ label: '状态', value: available ? '运行中' : '不可用' },
],
});
}
// 记忆:图 / 文档 / 文本 / 知识库
const memFields: StatField[] = [];
const mem: Record<string, Object> | undefined = k['memory'] as Record<string, Object>;
if (mem !== undefined && mem !== null) {
const ok: boolean = mem['available'] as boolean ?? false;
const ent: number = mem['entity_count'] as number ?? 0;
const rel: number = mem['relation_count'] as number ?? 0;
memFields.push({
label: '图记忆',
value: ok ? ent.toString() + ' 实体 · ' + rel.toString() + ' 关系' : '未初始化',
});
}
const docs: Record<string, Object> | undefined = k['documents'] as Record<string, Object>;
if (docs !== undefined && docs !== null) {
const ok: boolean = docs['available'] as boolean ?? false;
const n: number = docs['doc_count'] as number ?? 0;
memFields.push({ label: '文档记忆', value: ok ? n.toString() + ' 文档' : '未初始化' });
}
const tm: Record<string, Object> | undefined = k['text_memory'] as Record<string, Object>;
if (tm !== undefined && tm !== null) {
const ok: boolean = tm['available'] as boolean ?? false;
const n: number = tm['file_count'] as number ?? 0;
memFields.push({ label: '文本记忆', value: ok ? n.toString() + ' 文件' : '未初始化' });
}
const kb: Record<string, Object> | undefined = k['knowledge'] as Record<string, Object>;
if (kb !== undefined && kb !== null) {
const ok: boolean = kb['available'] as boolean ?? false;
const n: number = kb['item_count'] as number ?? 0;
memFields.push({ label: '知识库', value: ok ? n.toString() + ' 项' : '未初始化' });
}
if (memFields.length > 0) {
groups.push({ title: '记忆', fields: memFields });
}
// 运行时goroutine / 内存 / Go 版本
const rt: Record<string, Object> | undefined = k['runtime'] as Record<string, Object>;
if (rt !== undefined && rt !== null) {
const g: number = rt['goroutines'] as number ?? 0;
const mb: number = rt['memory_mb'] as number ?? 0;
const gov: string = rt['go_version'] as string ?? '';
groups.push({
title: '运行时',
fields: [
{ label: 'Goroutines', value: g.toString() },
{ label: '内存占用', value: mb.toString() + ' MB' },
{ label: 'Go 版本', value: gov.length > 0 ? gov : '-' },
],
});
}
// 工具清单:名称 → 归属插件
if (toolsArr !== undefined && toolsArr.length > 0) {
const toolFields: StatField[] = [];
for (let i = 0; i < toolsArr.length; i++) {
const t: Record<string, Object> = toolsArr[i] as Record<string, Object>;
const name: string = t['name'] as string ?? '';
const plugin: string = t['plugin'] as string ?? '';
if (name.length > 0) {
toolFields.push({ label: name, value: plugin });
}
}
if (toolFields.length > 0) {
groups.push({ title: '可用工具', fields: toolFields });
}
}
} catch (e) {
// /kernel 不可用时只保留 /status 的概览分组
}
}
private fail(msg: string): void {
AppStorage.setOrCreate<string>(K_ERR, msg);
AppStorage.setOrCreate<boolean>(K_UP, false);
AppStorage.setOrCreate<boolean>(K_LOADING, false);
this.groups = [];
this.bump();
}
/** 明细数组不进 AppStorage用一个自增版本号触发订阅组件重取 */
private bump(): void {
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
AppStorage.setOrCreate<number>(K_REV, cur + 1);
}
}
export const statusStore: StatusStore = new StatusStore();
export function statusText(s: string): string {
if (s === 'running') {
return '运行中';
}
if (s === 'stopped') {
return '已停止';
}
if (s === 'starting') {
return '启动中';
}
return s;
}
export function formatTime(iso: string): string {
const t: number = new Date(iso).getTime();
if (isNaN(t) || t <= 0) {
return iso;
}
const d = new Date(t);
return d.getFullYear().toString() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
/**
* 紧凑运行时长:环心里只有 96vp 单行,必须用 12h48m 这种记法,
* 「10时 49分 16秒」会折行。
*/
export function compactDuration(startedAt: string): string {
if (startedAt.length === 0) {
return '-';
}
const t: number = new Date(startedAt).getTime();
const now: number = Date.now();
if (isNaN(t) || t <= 0 || now <= t) {
return '-';
}
const sec: number = Math.floor((now - t) / 1000);
const days: number = Math.floor(sec / 86400);
const hours: number = Math.floor((sec % 86400) / 3600);
const mins: number = Math.floor((sec % 3600) / 60);
if (days > 0) {
return days.toString() + 'd ' + hours.toString() + 'h';
}
if (hours > 0) {
return hours.toString() + 'h ' + mins.toString() + 'm';
}
return mins.toString() + 'm ' + (sec % 60).toString() + 's';
}
function pad2(n: number): string {
return n < 10 ? '0' + n.toString() : n.toString();
}

View File

@ -0,0 +1,178 @@
import { hilog } from '@kit.PerformanceAnalysisKit';
import { ApiError } from './ApiClient';
/**
* 面向用户的错误文案统一出口。
*
* 问题背景:之前各页面直接把 `e.message` 拼进 UI于是屏幕上出现
* "获取状态失败: Failed to connect to the server."、原始 JSON 报错体、
* 甚至后端堆栈。这类文本对用户没有意义,还会泄露内网地址与实现细节。
*
* 约定:
* - UI 只显示 userMessage() 返回的短句(人话、可行动、不含技术细节);
* - 技术细节(原始 message / HTTP 状态码 / 响应体)只写进 hilog
* 通过 `hdc shell hilog | grep HomeAgent` 排查,不进 UI。
*/
const DOMAIN: number = 0xA0A0;
const TAG: string = 'HomeAgent';
/** 网络类错误的统一提示不暴露主机名、端口、curl 错误码。 */
const MSG_UNREACHABLE: string = '连接不上后端服务,请检查网络与服务地址';
const MSG_TIMEOUT: string = '后端响应超时,请稍后重试';
const MSG_AUTH: string = 'API Key 无效或已过期,请在设置里更新';
const MSG_FORBIDDEN: string = '没有访问权限,请检查 API Key 的权限范围';
const MSG_NOT_FOUND: string = '后端没有这个接口,可能版本不匹配';
const MSG_SERVER: string = '后端服务内部出错,请查看服务端日志';
const MSG_BAD_DATA: string = '后端返回的数据无法解析';
const MSG_TLS: string = '证书校验失败,请检查 HTTPS 配置';
const MSG_GENERIC: string = '操作失败,请稍后重试';
const MSG_NO_CONN: string = '尚未配置后端连接,请先在设置里添加';
/** 一眼判定是否"网络层根本没连上",用于页面显示离线态而不是报错态。 */
export function isOffline(e: Object): boolean {
const code: number = businessCode(e);
if (code === 2300006 || code === 2300007 || code === 2300005 ||
code === 2300052 || code === 2300056 || code === 2300028) {
return true;
}
if (e instanceof ApiError) {
return e.status < 0;
}
return false;
}
/**
* 是否为超时。聊天场景里超时不算失败——请求已经到后端,
* 只是回复还没生成完UI 要显示"等待回复"而不是报错。
*/
export function isTimeout(e: Object): boolean {
const code: number = businessCode(e);
if (code === 2300028) {
return true;
}
if (e instanceof ApiError && (e.status === 408 || e.status === 504)) {
return true;
}
const raw: string = rawMessage(e);
return raw.indexOf('timeout') >= 0 || raw.indexOf('超时') >= 0;
}
/**
* 把任意异常翻译成一句用户能看懂、且不含技术细节的话。
* 同时把原始信息写入 hilogscene 用于定位是哪个调用点)。
*/
export function userMessage(scene: string, e: Object): string {
logDetail(scene, e);
if (e instanceof ApiError) {
const st: number = e.status;
if (st === 401) {
return MSG_AUTH;
}
if (st === 403) {
return MSG_FORBIDDEN;
}
if (st === 404) {
return MSG_NOT_FOUND;
}
if (st === 408 || st === 504) {
return MSG_TIMEOUT;
}
if (st >= 500) {
return MSG_SERVER;
}
if (st < 0) {
return MSG_NO_CONN;
}
if (st >= 400) {
// 4xx 里后端通常给了业务原因,但不保证是人话,也可能带内部路径。
// 只在明显短且不含技术噪音时透传,否则退回通用文案。
return safeBackendReason(e.message);
}
}
const code: number = businessCode(e);
if (code === 2300028) {
return MSG_TIMEOUT;
}
if (code === 2300005 || code === 2300006 || code === 2300007 ||
code === 2300052 || code === 2300055 || code === 2300056) {
return MSG_UNREACHABLE;
}
if (code === 2300058 || code === 2300059 || code === 2300060 || code === 2300077) {
return MSG_TLS;
}
if (code === 2300001 || code === 2300003) {
return '服务地址格式不正确,请在设置里检查';
}
if (code === 2300009 || code === 2300094) {
return MSG_AUTH;
}
if (code === 2300997 || code === 2300998) {
return '系统禁止访问该地址,请改用 HTTPS 或放开域名白名单';
}
if (e instanceof SyntaxError) {
// JSON.parse 失败
return MSG_BAD_DATA;
}
const raw: string = rawMessage(e);
if (raw.indexOf('timeout') >= 0 || raw.indexOf('超时') >= 0) {
return MSG_TIMEOUT;
}
return MSG_GENERIC;
}
/** 未配置连接时的统一文案,页面不要各写一份。 */
export function noConnectionMessage(): string {
return MSG_NO_CONN;
}
/**
* 4xx 的后端说明只在"看起来是给人看的"时才透传:
* 短、无换行、不含路径/括号异常/HTML/JSON 花括号。
*/
function safeBackendReason(msg: string): string {
const s: string = msg.trim();
if (s.length === 0 || s.length > 60) {
return MSG_GENERIC;
}
if (s.indexOf('\n') >= 0 || s.indexOf('{') >= 0 || s.indexOf('<') >= 0 ||
s.indexOf('/') >= 0 || s.indexOf('0x') >= 0 || s.indexOf('Exception') >= 0 ||
s.indexOf('Error:') >= 0 || s.indexOf('panic') >= 0) {
return MSG_GENERIC;
}
return s;
}
/** 取 BusinessError.codeArkTS 不允许 in / 索引访问,用可选字段读取)。 */
function businessCode(e: Object): number {
const be = e as BusinessErrorLike;
const c: number | undefined = be.code;
return c !== undefined ? c : 0;
}
function rawMessage(e: Object): string {
if (e instanceof Error) {
return e.message;
}
return String(e);
}
function logDetail(scene: string, e: Object): void {
const code: number = businessCode(e);
let detail: string = rawMessage(e);
if (e instanceof ApiError) {
detail = 'HTTP ' + e.status.toString() + ' ' + detail + ' body=' + e.body;
}
hilog.error(DOMAIN, TAG, '%{public}s failed: code=%{public}d detail=%{private}s',
scene, code, detail);
}
/** BusinessError 的最小结构(避免为了读 code 而 import 整个 kit。 */
interface BusinessErrorLike {
code?: number;
message?: string;
}

View File

@ -0,0 +1,442 @@
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { apiClient } from '../common/ApiClient';
import { userMessage } from '../common/UserError';
import { ChatAttachment } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM } from '../common/Constants';
import { COLOR_ERROR } from '../common/Constants';
import { PlainCard } from './SubPage';
/**
* 附件解析与展示。
*
* 后端 Attachment 只有四个字段type / url / size / name
* internal/plugins/webui/handler.go没有 mime、没有像素尺寸、没有本地路径。
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
*
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
* 这两条路由在后端是 requireWeb但对 API Key 客户端同等放行,
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
*/
/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */
export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
const o: Record<string, Object> = raw as Record<string, Object>;
const url: string = o['url'] as string ?? '';
if (url.length === 0) {
return undefined;
}
const t: string = o['type'] as string ?? 'file';
const a: ChatAttachment = {
type: t === 'image' ? 'image' : 'file',
url: url,
size: o['size'] as number ?? 0,
name: o['name'] as string ?? fileNameOf(url),
};
return a;
}
/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */
export function attachmentFromChannelOutput(
outputType: string, url: string, size: number): ChatAttachment | undefined {
if (url.length === 0) {
return undefined;
}
if (outputType !== 'image' && outputType !== 'file') {
return undefined;
}
const a: ChatAttachment = {
type: outputType,
url: url,
size: size,
name: fileNameOf(url),
};
return a;
}
/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */
export function fileNameOf(url: string): string {
let s: string = url;
const q: number = s.indexOf('?');
if (q >= 0) {
s = s.substring(0, q);
}
const i: number = s.lastIndexOf('/');
const name: string = i >= 0 ? s.substring(i + 1) : s;
return name.length > 0 ? name : '附件';
}
/** 人类可读字节数,口径对齐后端 formatBytesKB 以上保留一位小数)。 */
export function formatBytes(n: number): string {
if (n <= 0) {
return '';
}
if (n < 1024) {
return n.toString() + ' B';
}
const kb: number = n / 1024;
if (kb < 1024) {
return oneDecimal(kb) + ' KB';
}
const mb: number = kb / 1024;
if (mb < 1024) {
return oneDecimal(mb) + ' MB';
}
return oneDecimal(mb / 1024) + ' GB';
}
function oneDecimal(v: number): string {
return (Math.round(v * 10) / 10).toString();
}
/** 由文件名后缀猜测类型标签。后端不返回 mime只能这样标注。 */
export function extLabel(name: string): string {
const i: number = name.lastIndexOf('.');
if (i < 0 || i === name.length - 1) {
return '未知类型';
}
return name.substring(i + 1).toUpperCase();
}
/**
* 气泡内的附件卡:图片显示缩略图,文件显示一枚文件条。
* 点击进入附件详情二级页面WebGUI 是新开标签页,移动端改为二级页)。
*/
@Component
export struct AttachmentCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop att: ChatAttachment;
@Prop mine: boolean = false;
onTap?: () => void;
@State private pixel: image.PixelMap | undefined = undefined;
@State private failed: boolean = false;
aboutToAppear(): void {
if (this.att.type === 'image') {
this.loadThumb();
}
}
private async loadThumb(): Promise<void> {
const pm: image.PixelMap | undefined = await loadPixelMap(this.att.url);
if (pm === undefined) {
this.failed = true;
return;
}
this.pixel = pm;
}
build() {
Column({ space: 6 }) {
Text(this.mine ? '你发送的' : '小宅发送的')
.fontSize(11)
.fontColor(this.palette().textMuted)
if (this.att.type === 'image') {
if (this.pixel !== undefined) {
Image(this.pixel)
.width('100%')
.constraintSize({ maxHeight: 220 })
.objectFit(ImageFit.Cover)
.borderRadius(RADIUS_MD)
.draggable(false)
} else if (this.failed) {
Row({ space: 6 }) {
Image($r('app.media.ic_error'))
.width(14)
.height(14)
.fillColor(COLOR_ERROR)
.draggable(false)
Text('图片加载失败')
.fontSize(12)
.fontColor(COLOR_ERROR)
}
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
} else {
Row() {
LoadingProgress()
.width(20)
.height(20)
.color(this.palette().accent)
}
.width(120)
.height(80)
.justifyContent(FlexAlign.Center)
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
}
} else {
Row({ space: 8 }) {
Image($r('app.media.ic_file'))
.width(18)
.height(18)
.fillColor(this.palette().accent)
.draggable(false)
Column({ space: 2 }) {
Text(this.att.name)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.att.size > 0) {
Text(formatBytes(this.att.size))
.fontSize(10)
.fontColor(this.palette().textMuted)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Image($r('app.media.ic_chevron_right'))
.width(14)
.height(14)
.fillColor(this.palette().textMuted)
.draggable(false)
}
.constraintSize({ minWidth: 180 })
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.mine ? this.palette().accentBg : this.palette().bgHover)
.alignItems(VerticalAlign.Center)
}
}
.alignItems(HorizontalAlign.Start)
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 附件详情二级页面内容:大图预览 / 文件信息 + 保存到本地。
*
* 后端不提供尺寸与 mime图片尺寸由本地解码得到类型标签由后缀推断
* 界面上如实标注它们的来源,不谎称是服务端元数据。
*/
@Component
export struct AttachmentDetailContent {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop att: ChatAttachment;
@State private pixel: image.PixelMap | undefined = undefined;
@State private pxWidth: number = 0;
@State private pxHeight: number = 0;
@State private loading: boolean = true;
@State private err: string = '';
@State private savedPath: string = '';
aboutToAppear(): void {
if (this.att.type === 'image') {
this.loadFull();
} else {
this.loading = false;
}
}
private async loadFull(): Promise<void> {
this.loading = true;
this.err = '';
try {
const abs: string = apiClient.absoluteUrl(this.att.url);
const resp = await apiClient.getBinary(abs, 20000);
const src: image.ImageSource = image.createImageSource(resp.data);
const info: image.ImageInfo = await src.getImageInfo();
this.pxWidth = info.size.width;
this.pxHeight = info.size.height;
this.pixel = await src.createPixelMap();
await src.release();
} catch (e) {
this.err = userMessage('attachment.load', e);
}
this.loading = false;
}
/** 保存到应用沙箱 files 目录(不申请媒体库权限,避免为了看一张图要授权相册)。 */
private async saveToSandbox(): Promise<void> {
try {
const abs: string = apiClient.absoluteUrl(this.att.url);
const resp = await apiClient.getBinary(abs, 30000);
const ctx = getContext(this) as common.UIAbilityContext;
const dest: string = ctx.filesDir + '/' + sanitize(this.att.name);
const f = fileIo.openSync(dest,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
fileIo.writeSync(f.fd, resp.data);
fileIo.closeSync(f);
this.savedPath = dest;
} catch (e) {
this.err = userMessage('attachment.save', e);
}
}
build() {
Column() {
if (this.err.length > 0) {
Row({ space: 8 }) {
Image($r('app.media.ic_error'))
.width(16)
.height(16)
.fillColor(COLOR_ERROR)
.draggable(false)
Text(this.err)
.fontSize(13)
.fontColor(COLOR_ERROR)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(VerticalAlign.Top)
.margin({ bottom: 14 })
}
// 预览:图片铺满宽度可缩放查看;文件给一个大图标占位
Column() {
if (this.att.type === 'image') {
if (this.loading) {
Row() {
LoadingProgress()
.width(28)
.height(28)
.color(this.palette().accent)
}
.width('100%')
.height(200)
.justifyContent(FlexAlign.Center)
} else if (this.pixel !== undefined) {
Image(this.pixel)
.width('100%')
.constraintSize({ maxHeight: 420 })
.objectFit(ImageFit.Contain)
.borderRadius(RADIUS_MD)
.draggable(false)
}
} else {
Column({ space: 10 }) {
Image($r('app.media.ic_file'))
.width(46)
.height(46)
.fillColor(this.palette().accent)
.draggable(false)
Text(extLabel(this.att.name))
.fontSize(12)
.fontColor(this.palette().textSecondary)
}
.width('100%')
.padding({ top: 26, bottom: 26 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
.width('100%')
.padding(12)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.margin({ bottom: 14 })
PlainCard({ caption: '信息' }) {
this.kv('文件名', this.att.name)
this.kv('类型', this.att.type === 'image' ? '图片' : '文件')
this.kv('格式', extLabel(this.att.name))
this.kv('大小', this.att.size > 0 ? formatBytes(this.att.size) : '未知(远程链接)')
if (this.pxWidth > 0 && this.pxHeight > 0) {
// 后端不返回像素尺寸,这一行来自本地解码
this.kv('像素', this.pxWidth.toString() + ' × ' + this.pxHeight.toString())
}
this.kv('来源', this.att.url.startsWith('http') ? '远程链接' : '服务端中转')
}
PlainCard({ caption: '操作' }) {
Button('保存到应用目录')
.width('100%')
.height(38)
.fontSize(13)
.backgroundColor(this.palette().accent)
.fontColor('#FFFFFF')
.onClick(() => {
this.saveToSandbox();
})
if (this.savedPath.length > 0) {
Text('已保存:' + this.savedPath)
.fontSize(11)
.fontColor(this.palette().textMuted)
.width('100%')
.margin({ top: 8 })
}
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
kv(label: string, value: string) {
Row() {
Text(label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
Text(value)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 210 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/** 下载并解码成 PixelMap任何一步失败都返回 undefined调用方显示占位。 */
async function loadPixelMap(url: string): Promise<image.PixelMap | undefined> {
try {
// 本地待上传的图片:直接读沙箱文件,不走网络
if (url.startsWith('file://')) {
const path: string = url.substring(7);
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
const localSrc: image.ImageSource = image.createImageSource(f.fd);
const localPm: image.PixelMap = await localSrc.createPixelMap();
await localSrc.release();
fileIo.closeSync(f);
return localPm;
}
const abs: string = apiClient.absoluteUrl(url);
const resp = await apiClient.getBinary(abs, 15000);
const src: image.ImageSource = image.createImageSource(resp.data);
const pm: image.PixelMap = await src.createPixelMap();
await src.release();
return pm;
} catch (e) {
return undefined;
}
}
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
function sanitize(name: string): string {
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
if (s.length === 0) {
s = 'attachment';
}
return s;
}

View File

@ -0,0 +1,127 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 背景层:底色 + 三层径向渐变光斑(右上蓝 / 左中青 / 底部深),
* 对应 WebGUI 的 grad-a / grad-b / grad-c。
*
* 独立成文件的原因Navigation 的 NavDestination 在栈模式下会整屏盖住
* 一级内容,必须自带同款背景(否则透出下层列表形成重影)。
* 如果这个组件留在 Index.ets 里,二级页面 import 它会与
* Index -> SettingsPage -> Index 形成循环依赖。
*/
@Component
export struct GradientBackground {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp('bgImage') @Watch('onBgChanged') private bgImage: string = '';
@StorageProp('bgOpacity') private bgOpacity: number = 0.25;
/**
* 背景图解码后的位图。
*
* 为什么不把路径字符串直接交给 Image
* 把沙箱路径交给 Image 要同时满足"带 file:// 协议头"和"带可识别的图片后缀"
* 两个隐含前提,任何一条不满足都只是静默不显示 —— 前两轮"背景不生效"就是
* 卡在这里,而且没有任何可观测的失败点。
* 这里改成自己用 ImageSource 解码(与聊天图片附件同一条已验证通路),
* 成功与失败都能落 hilogImage 只负责画一张现成的 PixelMap。
*/
@State private bgPixel: image.PixelMap | undefined = undefined;
aboutToAppear(): void {
this.decodeBg();
}
private onBgChanged(): void {
this.decodeBg();
}
/** 解码沙箱/网络背景图为 PixelMap失败时清空只留渐变底。 */
private async decodeBg(): Promise<void> {
const src: string = this.bgImage;
if (src.length === 0) {
this.bgPixel = undefined;
return;
}
// 只处理本地沙箱文件http(s) 背景图不是本应用的场景(设置页只给图库选择)
const path: string = src.startsWith('file://') ? src.substring(7) : src;
if (!path.startsWith('/')) {
hilog.error(0x0000, 'HomeAgent', 'bg: unsupported source');
this.bgPixel = undefined;
return;
}
try {
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
const srcObj: image.ImageSource = image.createImageSource(f.fd);
const pm: image.PixelMap = await srcObj.createPixelMap();
await srcObj.release();
fileIo.closeSync(f);
this.bgPixel = pm;
hilog.info(0x0000, 'HomeAgent', 'bg decoded ok');
} catch (e) {
// 失败必须留痕:否则表现就是"设置了但没生效",无从定位
const err = e as BusinessError;
hilog.error(0x0000, 'HomeAgent', 'bg decode failed code=%{public}d', err.code as number);
this.bgPixel = undefined;
}
}
build() {
Stack() {
// 底色
Column()
.width('100%')
.height('100%')
.backgroundColor(this.palette().bgPrimary)
// grad-a: 右上角蓝色光斑
Column()
.width('100%')
.height('100%')
.radialGradient({
center: ['88%', '-4%'],
radius: 520,
colors: [[this.palette().gradA, 0.0], [this.palette().gradAEnd, 1.0]],
})
// grad-b: 左中部霜冻青光斑
Column()
.width('100%')
.height('100%')
.radialGradient({
center: ['-6%', '38%'],
radius: 470,
colors: [[this.palette().gradB, 0.0], [this.palette().gradBEnd, 1.0]],
})
// grad-c: 底部深色光斑
Column()
.width('100%')
.height('100%')
.radialGradient({
center: ['50%', '110%'],
radius: 560,
colors: [[this.palette().gradC, 0.0], [this.palette().gradCEnd, 1.0]],
})
// 自定义背景图(可选):叠在渐变光斑之上、页面内容之下
if (this.bgPixel !== undefined) {
Image(this.bgPixel)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.opacity(this.bgOpacity)
.draggable(false)
}
}
.width('100%')
.height('100%')
}
private palette(): ThemePalette {
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}

View File

@ -0,0 +1,155 @@
import { MarkdownStream, StreamingMarkdown } from '@ycj3/streaming-markdown';
import type { StreamingMarkdownConfig } from '@ycj3/streaming-markdown';
import { StaticMarkdownView } from './StaticMarkdown';
import { DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
/**
* MarkdownView — unified markdown renderer for chat messages.
*
* - isStreaming=true → uses @ycj3/streaming-markdown live StreamingMarkdown
* (incremental, animated — ideal for SSE output).
* - isStreaming=false → uses StaticMarkdownView (parses once, no timers,
* instant render — ideal for history / finalized messages).
*/
@Component
export struct MarkdownView {
@Prop content: string = '';
@Prop isStreaming: boolean = false;
@Prop isDark: boolean = true;
private stream: MarkdownStream = new MarkdownStream({ mode: 'word', interval: 18 });
private seeded: boolean = false;
private pushedLen: number = 0;
aboutToAppear(): void {
if (this.isStreaming) {
this.seedStream();
this.seeded = true;
}
}
// Triggered by @Prop changes (streaming updates)
private onContentChange(): void {
if (this.isStreaming && this.seeded) {
const c: string = this.content;
const prev: number = this.pushedLen;
if (c.length > prev) {
const delta: string = c.substring(prev);
this.stream.append(delta);
this.pushedLen = c.length;
return;
}
// non-prefix change → reseed
this.seedStream();
}
}
private seedStream(): void {
this.stream.reset();
this.pushedLen = 0;
const c: string = this.content;
if (c.length > 0) {
this.stream.append(c);
this.pushedLen = c.length;
}
// Streaming message stays open; finish happens when isStreaming flips false.
}
/**
* When a streaming message finalizes, mark the stream complete.
*/
private onStreamingEnd(): void {
if (this.seeded) {
this.stream.finish();
}
}
private getConfig(): StreamingMarkdownConfig {
const p = this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
const config: StreamingMarkdownConfig = {
heading: {
sizes: [28, 24, 20, 18, 16, 14],
color: p.textPrimary,
topSpacing: 8,
bottomSpacing: 6,
},
paragraph: {
fontSize: 15,
lineHeight: 24,
color: p.msgBubbleText,
bottomSpacing: 6,
},
list: {
fontSize: 15,
lineHeight: 24,
itemBottomSpacing: 3,
color: p.msgBubbleText,
},
blockquote: {
textColor: p.textTertiary,
bgColor: p.bgHover,
borderColor: p.kvBorder,
bottomSpacing: 6,
},
codeBlock: {
borderColor: p.kvBorder,
radius: 10,
topSpacing: 8,
bottomSpacing: 8,
},
table: {
headerBgColor: p.bgHover,
borderColor: p.kvBorder,
stripeBgColor: p.bgHover,
cellFontSize: 13,
topSpacing: 6,
bottomSpacing: 6,
},
horizontalRule: {
color: p.kvBorder,
topSpacing: 8,
bottomSpacing: 8,
},
inline: {
linkColor: p.accent,
codeTextColor: p.preText,
codeBgColor: p.preBg,
mathTextColor: p.accent,
mathBgColor: p.bgHover,
monoFontFamily: 'monospace',
},
layout: {
contentPadding: { left: 0, right: 0, top: 0, bottom: 0 },
},
};
return config;
}
build() {
// 布局说明(已由 uitest dumpLayout 实测确认):
// 父气泡 BubbleBody 为内容自适应宽度constraintSize maxWidth 78%)且带 12vp 左右 padding。
// 在这种"内容自适应 + padding"的父节点下,后代节点的 width('100%') 会被解析成父气泡的
// 外框宽度而不是内容框宽度,于是 markdown 内容整体右溢出 12vp 并被气泡 clip 裁掉。
// 解决办法:用 Row + layoutWeight(1) 代替百分比宽度。layoutWeight 走的是"剩余约束分配"
// 而不是百分比解析,能拿到正确的内容框宽度,再把这个确定宽度传给 StaticMarkdown
// 其内部各层的 width('100%') 就有了正确的解析基准。
Row() {
Column() {
if (this.isStreaming) {
StreamingMarkdown({
stream: this.stream,
config: this.getConfig(),
})
} else {
StaticMarkdownView({
content: this.content,
isDark: this.isDark,
})
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Top)
}
}

View File

@ -0,0 +1,208 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
/**
* 顶栏高度vp。页面 padding-top 应略大于此值,避免内容被标题压住,
* 同时不要留过多空白。
*/
export const TOP_BAR_HEIGHT: number = 68;
/**
* 页面顶栏遮罩:仅标题文字,无按钮无状态。
* 浮在滚动区之上(页面用 PageTopBarLayer 置顶),背景为 不透明 -> 透明 的线性渐变,
* 滚动内容从其下方穿过时逐渐淡出,形成"逐渐加深"的柔化边界,而不是硬截断。
*
* 关键:自定义组件被外部施加 .position() 时ArkUI 会生成一个 __Common__ 包裹节点,
* 该节点默认铺满父约束并使用默认命中测试 —— 会吞掉整页触摸事件。
* 因此必须在【调用点】同时显式给出尺寸与 hitTestBehavior(None)
* 统一封装在 PageTopBarLayer 里,页面不要再手写 .position()。
*/
@Component
export struct PageTopBar {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop title: string = '';
build() {
// 外层撑满整页并顶部对齐,替代调用点的 .position()
// 自定义组件一旦在调用点被施加 .position()ArkUI 会生成铺满父约束的
// __Common__ 包裹节点,该节点使用默认命中测试,会吞掉整页的滚动与点击。
// 这里改为自身撑满 + 全链路 HitTestMode.None触摸完全穿透到下层滚动区。
Column() {
Column() {
Text(this.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.margin({ left: 16, top: 10 })
.hitTestBehavior(HitTestMode.None)
}
.width('100%')
.height(TOP_BAR_HEIGHT)
.alignItems(HorizontalAlign.Start)
.linearGradient({
direction: GradientDirection.Bottom,
colors: [
[this.opaqueBg(), 0.0],
[this.opaqueBg(), 0.45],
[this.transparentBg(), 1.0],
],
})
.hitTestBehavior(HitTestMode.None)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Start)
.hitTestBehavior(HitTestMode.None)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private opaqueBg(): string {
const bg: string = this.palette().bgPrimary;
return '#FF' + bg.substring(1);
}
private transparentBg(): string {
const bg: string = this.palette().bgPrimary;
return '#00' + bg.substring(1);
}
}
/**
* 独立气态玻璃节点:单个悬浮组件,与底部导航同款玻璃(半透明底 + 高光渐变)。
* 尺寸自适应内容(宽度随内容,高度/圆角参数化),视觉上独立、不与其他组件共框。
* 用于承载状态胶囊、图标按钮等单个悬浮元素。
*/
@Component
export struct GlassShell {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop nodeHeight: number = 32;
@Prop nodeRadius: number = 16;
@BuilderParam content: () => void;
build() {
Row() {
this.content()
}
.height(this.nodeHeight)
.padding({ left: 8, right: 8 })
.alignItems(VerticalAlign.Center)
.backgroundColor(this.palette().navBarBg)
.borderRadius(this.nodeRadius)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({
radius: 16,
color: this.palette().shadow,
offsetY: 5,
})
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 悬浮区圆形图标按钮的直径vp。徽标不使用它——徽标是竖向窄条。
*/
export const FLOAT_NODE_SIZE: number = 42;
/**
* 悬浮图标按钮:与底部导航同一套玻璃语言的圆形按钮。
* 统一 42vp 直径、同款玻璃底/描边/投影,图标 18vp。
*/
@Component
export struct FloatIconButton {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop icon: Resource | undefined = undefined;
@Prop accent: boolean = false;
onTap?: () => void;
build() {
Button() {
Image(this.icon)
.width(18)
.height(18)
.fillColor(this.accent ? this.palette().accent : this.palette().textSecondary)
}
.width(FLOAT_NODE_SIZE)
.height(FLOAT_NODE_SIZE)
.type(ButtonType.Circle)
.backgroundColor(this.palette().navBarBg)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({
radius: 24,
color: this.palette().shadow,
offsetY: 8,
})
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 悬浮区的"一行":宽度自适应内容,内部组件从右向左排列(右边缘与导航栏平齐)。
* 放在 NavFloatOverlay 内部使用;声明顺序越靠前越贴近导航栏,后声明的行叠在其上方。
*/
@Component
export struct NavFloatRow {
@BuilderParam content: () => void;
build() {
Row({ space: 8 }) {
this.content()
}
.alignItems(VerticalAlign.Center)
}
}
/**
* 底部悬浮容器:透明、无任何背景/边框(视觉上不包裹任何东西,组件彼此独立),
* 仅负责 右侧对齐(与导航栏同宽 88%)、自下而上分行堆叠 与 随滚动渐隐/滑出动画。
*
* 布局约定:直接子节点是"一行"(通常是自适应宽度的 Row
* 行按【从上到下】声明:先声明的行在上方,最后声明的行贴住导航栏,
* 即"一行放不下时向上面再开一行"(把新行写在前面)。
*
* 关键:这里必须用 Column 而不是 Flex —— Flex 在 ArkUI 中默认铺满父约束,
* 会形成一个覆盖整页的默认命中测试节点,吞掉页面滚动与点击;
* Column 高度自适应内容。再加 HitTestMode.Transparent
* 让每行右侧之外的空白区域触摸穿透到下层滚动区。
*/
@Component
export struct NavFloatOverlay {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp('navVisible') private navVisible: boolean = true;
@StorageProp('currentTab') private currentTab: number = 0;
@Prop tab: number = 0;
@Prop alignEnd: boolean = true;
@BuilderParam content: () => void;
build() {
Column({ space: 8 }) {
this.content()
}
.width('88%')
.alignItems(this.alignEnd ? HorizontalAlign.End : HorizontalAlign.Start)
.hitTestBehavior(HitTestMode.Transparent)
.margin({ bottom: 94 })
.translate({ y: (!this.navVisible || this.currentTab !== this.tab) ? 140 : 0 })
.opacity((!this.navVisible || this.currentTab !== this.tab) ? 0 : 1)
.animation({ duration: 400, curve: Curve.EaseOut })
}
}

View File

@ -0,0 +1,379 @@
import { apiClient } from '../common/ApiClient';
import { userMessage, noConnectionMessage } from '../common/UserError';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { RADIUS_SM } from '../common/Constants';
import { COLOR_ERROR } from '../common/Constants';
/**
* 可复用的后端配置编辑器(按 key 前缀取一段配置并就地编辑)。
*
* 抽出来的原因:配置项不应该全部堆在「设置 → 后端配置」一个平铺列表里。
* - core.* 各分类归到「核心」下一级;
* - plugin.<name>.* 属于插件本身,放进该插件的详情页 —— 就是这个组件的用处。
*
* 后端接口:
* - GET /settings?prefix=<prefix> → { settings: {k:v}, meta: {k:{...}} }
* - PUT /settings → { key, value }value 一律字符串)
*/
interface SettingMetaRaw {
key: string;
type: string;
displayName: string;
description: string;
category: string;
options: string[];
}
interface EditorEntry {
key: string;
displayName: string;
description: string;
type: string;
options: string[];
value: string;
dirty: boolean;
}
interface SaveBody {
key: string;
value: string;
}
@Component
export struct SettingsEditor {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** key 前缀,例如 'plugin.ai_image.' 或 'core.llm.' */
@Prop @Watch('onPrefixChanged') prefix: string = '';
/** 空列表时的提示语 */
@Prop emptyHint: string = '暂无可配置项';
@State private entries: EditorEntry[] = [];
@State private loading: boolean = false;
@State private err: string = '';
@State private toast: string = '';
@State private toastErr: boolean = false;
private original: Record<string, string> = {};
aboutToAppear(): void {
this.load();
}
/** 宽屏下同一个组件实例会被复用(切换插件只改 prefix必须重新拉取 */
private onPrefixChanged(): void {
this.entries = [];
this.load();
}
/**
* 查询用前缀。
*
* 后端 GET /settings?prefix= 对 plugin.* 走的是另一条分支:
* 它把 prefix 当作 "plugin." + 插件名 来切表,再自己拼 prefix + "." + key。
* 所以传 'plugin.browser.'(带尾点)会被解析成插件名 "browser.",查不到表,
* settings 返回空 —— 这就是插件详情页配置卡片空白的原因。
* 查询必须去掉尾点,返回的 key 仍是 'plugin.browser.timeout' 这种全名,
* 所以本地过滤/短标签依旧用带尾点的 this.prefix。
*/
private queryPrefix(): string {
const p: string = this.prefix;
return p.endsWith('.') ? p.substring(0, p.length - 1) : p;
}
private async load(): Promise<void> {
if (!apiClient.hasConnection()) {
this.err = noConnectionMessage();
return;
}
if (this.prefix.length === 0) {
this.entries = [];
return;
}
this.loading = true;
this.err = '';
try {
const resp = await apiClient.getWithTimeout('/settings?prefix=' + this.queryPrefix(), 15000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const metaStore: Record<string, SettingMetaRaw> = {};
const rawMeta: Object | undefined = obj['meta'];
if (rawMeta !== undefined && rawMeta !== null) {
const mObj: Record<string, Object> = rawMeta as Record<string, Object>;
for (const mk of Object.keys(mObj)) {
const item: Record<string, Object> = mObj[mk] as Record<string, Object>;
const opts: string[] = [];
const optsRaw: Object | undefined = item['options'];
if (optsRaw !== undefined && optsRaw !== null) {
const oa: Object[] = optsRaw as Object[];
for (let i = 0; i < oa.length; i++) {
const s: string = oa[i] as string ?? '';
if (s.length > 0) {
opts.push(s);
}
}
}
const entryMeta: SettingMetaRaw = {
key: item['key'] as string ?? mk,
type: item['type'] as string ?? 'string',
displayName: item['display_name'] as string ?? '',
description: item['description'] as string ?? '',
category: item['category'] as string ?? '',
options: opts,
};
// 两个索引都建:
// - mk 是 map 的键,插件分支里后端会把它拼成 'plugin.<name>.' + 完整 key
// 于是变成 'plugin.ai_image.plugin.ai_image.api_key' 这种双前缀,对不上;
// - entryMeta.key 是定义自带的真实全名('plugin.ai_image.api_key'),才是能匹配的那个。
// 只用 mk 索引就会让插件配置全部退化成"无显示名、类型按 string"。
metaStore[mk] = entryMeta;
if (entryMeta.key.length > 0) {
metaStore[entryMeta.key] = entryMeta;
}
}
}
const list: EditorEntry[] = [];
this.original = {};
const rawVals: Object | undefined = obj['settings'];
if (rawVals !== undefined && rawVals !== null) {
const vObj: Record<string, Object> = rawVals as Record<string, Object>;
const keys: string[] = Object.keys(vObj).filter((k: string): boolean => {
return k.startsWith(this.prefix);
});
keys.sort((a: string, b: string): number => a.localeCompare(b));
for (let i = 0; i < keys.length; i++) {
const k: string = keys[i];
const raw: Object = vObj[k];
let sv: string;
if (typeof raw === 'string') {
sv = raw as string;
} else if (typeof raw === 'boolean' || typeof raw === 'number') {
sv = String(raw);
} else {
sv = JSON.stringify(raw);
}
this.original[k] = sv;
const meta: SettingMetaRaw | undefined = metaStore[k];
list.push({
key: k,
displayName: meta !== undefined && meta.displayName.length > 0
? meta.displayName : shortLabel(k, this.prefix),
description: meta !== undefined ? meta.description : '',
type: meta !== undefined ? meta.type : 'string',
options: meta !== undefined ? meta.options : [],
value: sv,
dirty: false,
});
}
}
this.entries = list;
} catch (e) {
this.err = userMessage('settings.load', e);
}
this.loading = false;
}
private markEntry(key: string, value: string, dirty: boolean): void {
const next: EditorEntry[] = [];
for (let i = 0; i < this.entries.length; i++) {
const e: EditorEntry = this.entries[i];
if (e.key === key) {
next.push({
key: e.key,
displayName: e.displayName,
description: e.description,
type: e.type,
options: e.options,
value: value,
dirty: dirty,
});
} else {
next.push(e);
}
}
this.entries = next;
}
private onEdit(key: string, value: string): void {
const orig: string = this.original[key] ?? '';
this.markEntry(key, value, value !== orig);
}
private latestValue(key: string): string {
for (let i = 0; i < this.entries.length; i++) {
if (this.entries[i].key === key) {
return this.entries[i].value;
}
}
return '';
}
private async save(entry: EditorEntry, newValue: string): Promise<void> {
let payload: string = newValue.trim();
if (entry.type === 'bool') {
payload = newValue === 'true' ? 'true' : 'false';
}
try {
const body: SaveBody = { key: entry.key, value: payload };
await apiClient.request('/settings', 'PUT', JSON.stringify(body), 10000);
this.original[entry.key] = payload;
this.markEntry(entry.key, payload, false);
this.showToast('已保存', false);
} catch (e) {
this.showToast(userMessage('settings.save', e), true);
}
}
private showToast(msg: string, isErr: boolean): void {
this.toast = msg;
this.toastErr = isErr;
setTimeout(() => {
this.toast = '';
}, 2000);
}
build() {
Column() {
if (this.loading && this.entries.length === 0) {
Row() {
LoadingProgress()
.width(22)
.height(22)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 14, bottom: 14 })
}
if (this.err.length > 0) {
Text(this.err)
.fontSize(12)
.fontColor(COLOR_ERROR)
.width('100%')
}
if (!this.loading && this.err.length === 0 && this.entries.length === 0) {
Text(this.emptyHint)
.fontSize(12)
.fontColor(this.palette().textMuted)
.width('100%')
}
ForEach(this.entries, (entry: EditorEntry) => {
this.EntryRow(entry)
}, (entry: EditorEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c'))
if (this.toast.length > 0) {
Text(this.toast)
.fontSize(11)
.fontColor(this.toastErr ? COLOR_ERROR : this.palette().accent)
.margin({ top: 4 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
EntryRow(entry: EditorEntry) {
Column() {
Text(entry.displayName)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(entry.key)
.fontSize(10)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (entry.description.length > 0) {
Text(entry.description)
.fontSize(11)
.fontColor(this.palette().textSecondary)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
}
if (entry.type === 'bool') {
Row() {
Text(entry.value === 'true' ? '已开启' : '已关闭')
.fontSize(12)
.fontColor(entry.value === 'true' ? this.palette().accent : this.palette().textMuted)
Blank()
Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' })
.selectedColor(this.palette().accent)
.onChange((on: boolean) => {
this.save(entry, on ? 'true' : 'false');
})
}
.width('100%')
.margin({ top: 6 })
} else if (entry.type === 'select' && entry.options.length > 0) {
Flex({
direction: FlexDirection.Row,
justifyContent: FlexAlign.Start,
alignItems: ItemAlign.Center,
wrap: FlexWrap.Wrap,
}) {
ForEach(entry.options, (opt: string) => {
Button(opt)
.height(26)
.fontSize(11)
.margin({ right: 6, bottom: 6 })
.backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover)
.fontColor(entry.value === opt ? Color.White : this.palette().textSecondary)
.onClick(() => {
this.save(entry, opt);
})
}, (opt: string) => opt)
}
.width('100%')
.margin({ top: 6 })
} else {
Row() {
TextInput({ text: entry.value })
.height(36)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.type(entry.type === 'password' ? InputType.Password : InputType.Normal)
.layoutWeight(1)
.onChange((v: string) => {
this.onEdit(entry.key, v);
})
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent)
.fontColor(Color.White)
.margin({ left: 8 })
.onClick(() => {
this.save(entry, this.latestValue(entry.key));
})
}
.width('100%')
.margin({ top: 6 })
}
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
.alignItems(HorizontalAlign.Start)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/** 去掉前缀后的短标签plugin.ai_image.model → model */
function shortLabel(key: string, prefix: string): string {
const s: string = key.startsWith(prefix) ? key.substring(prefix.length) : key;
return s.length > 0 ? s : key;
}

View File

@ -0,0 +1,604 @@
/**
* Static Markdown → ArkUI renderer for COMPLETE (non-streaming) chat messages.
*
* Parses once in aboutToAppear, builds a component tree with no timers — instant rendering.
* Uses Span children inside Text for inline bold/italic/code/link formatting.
*
* Covers: headings, paragraphs, code fences, unordered/ordered lists,
* blockquotes, horizontal rules, tables, and inline bold/italic/code/links.
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { RADIUS_SM } from '../common/Constants';
// ── Types ──────────────────────────────────────────────────────────────────────
export interface MdBlock {
type: string; // 'heading' | 'code' | 'list' | 'ol' | 'blockquote' | 'hr' | 'table' | 'para'
level?: number;
items?: string[];
text?: string;
lang?: string;
codeLines?: string[];
headers?: string[];
rows?: string[][];
}
export interface MdSpan {
text: string;
bold?: boolean;
italic?: boolean;
code?: boolean;
link?: boolean;
linkUrl?: string;
}
// ── Inline parser ──────────────────────────────────────────────────────────────
export function parseInline(text: string): MdSpan[] {
const spans: MdSpan[] = [];
let i: number = 0;
while (i < text.length) {
// Inline code (backtick)
if (text[i] === '`') {
const end: number = text.indexOf('`', i + 1);
if (end > i) {
spans.push({ text: text.substring(i + 1, end), code: true });
i = end + 1;
continue;
}
}
// Bold: **text**
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '*') {
const end: number = text.indexOf('**', i + 2);
if (end > i + 1) {
spans.push({ text: text.substring(i + 2, end), bold: true });
i = end + 2;
continue;
}
}
// Italic: *text* (single asterisk)
if (text[i] === '*' && (i + 1 >= text.length || text[i + 1] !== '*')) {
const end: number = text.indexOf('*', i + 1);
if (end > i) {
spans.push({ text: text.substring(i + 1, end), italic: true });
i = end + 1;
continue;
}
}
// Link: [text](url)
if (text[i] === '[') {
const cb: number = text.indexOf(']', i + 1);
if (cb > i && cb + 1 < text.length && text[cb + 1] === '(') {
const cp: number = text.indexOf(')', cb + 2);
if (cp > cb + 1) {
spans.push({ text: text.substring(i + 1, cb), link: true, linkUrl: text.substring(cb + 2, cp) });
i = cp + 1;
continue;
}
}
}
// Plain run
let j: number = i + 1;
while (j < text.length && text[j] !== '`' && text[j] !== '*' && text[j] !== '[') {
j++;
}
spans.push({ text: text.substring(i, j) });
i = j;
}
return spans;
}
// ── Block parser helpers ───────────────────────────────────────────────────────
function isHr(line: string): boolean {
if (line.length < 3) {
return false;
}
const ch: string = line[0];
if (ch !== '-' && ch !== '*' && ch !== '_') {
return false;
}
for (let k = 0; k < line.length; k++) {
if (line[k] !== ch) {
return false;
}
}
return true;
}
function isOlStart(line: string): boolean {
if (line.length < 3) {
return false;
}
let k: number = 0;
while (k < line.length && line[k] >= '0' && line[k] <= '9') {
k++;
}
return k > 0 && k + 1 < line.length && line[k] === '.' && line[k + 1] === ' ';
}
function isTableSep(line: string): boolean {
if (!line.includes('-')) {
return false;
}
for (let k = 0; k < line.length; k++) {
const c: string = line[k];
if (c !== '|' && c !== '-' && c !== ':' && c !== ' ' && c !== '\t') {
return false;
}
}
return true;
}
// ── Block parser ───────────────────────────────────────────────────────────────
export function parseBlocks(content: string): MdBlock[] {
if (content.length === 0) {
return [];
}
const lines: string[] = content.split('\n');
const blocks: MdBlock[] = [];
let i: number = 0;
while (i < lines.length) {
const line: string = lines[i];
// Empty line
if (line.trim().length === 0) {
i++;
continue;
}
// Code fence
if (line.startsWith('```')) {
const langEnd: number = line.indexOf('`', 3);
const lang: string = langEnd > 3 ? line.substring(3, langEnd).trim() : '';
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) {
i++;
}
blocks.push({ type: 'code', lang: lang, codeLines: codeLines });
continue;
}
// Heading
if (line.startsWith('#')) {
let level: number = 0;
while (level < line.length && line[level] === '#') {
level++;
}
if (level <= 6 && level < line.length && line[level] === ' ') {
blocks.push({ type: 'heading', level: level, text: line.substring(level + 1).trim() });
i++;
continue;
}
}
// Horizontal rule
if (isHr(line.trim())) {
blocks.push({ type: 'hr' });
i++;
continue;
}
// Unordered list
if ((line.startsWith('- ') || line.startsWith('* ')) && !line.startsWith('- [')) {
const items: string[] = [];
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* ')) && !lines[i].startsWith('- [')) {
items.push(lines[i].substring(2));
i++;
}
blocks.push({ type: 'list', items: items });
continue;
}
// Ordered list
if (isOlStart(line)) {
const items: string[] = [];
while (i < lines.length && isOlStart(lines[i])) {
const dotIdx: number = lines[i].indexOf('. ');
items.push(lines[i].substring(dotIdx + 2));
i++;
}
blocks.push({ type: 'ol', items: items });
continue;
}
// Blockquote
if (line.startsWith('> ')) {
const qLines: string[] = [];
while (i < lines.length && lines[i].startsWith('> ')) {
qLines.push(lines[i].substring(2));
i++;
}
blocks.push({ type: 'blockquote', text: qLines.join('\n') });
continue;
}
// Table
if (line.trimStart().startsWith('|') && !isTableSep(line)) {
const tLines: string[] = [];
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
tLines.push(lines[i]);
i++;
}
if (tLines.length >= 2) {
const parseRow = (row: string): string[] => {
const cells: string[] = [];
const parts: string[] = row.split('|');
for (let p = 0; p < parts.length; p++) {
const c: string = parts[p].trim();
if (c.length > 0) {
cells.push(c);
}
}
return cells;
};
const headers: string[] = parseRow(tLines[0]);
const rows: string[][] = [];
for (let k = 1; k < tLines.length; k++) {
if (!isTableSep(tLines[k].trim())) {
rows.push(parseRow(tLines[k]));
}
}
if (headers.length > 0) {
blocks.push({ type: 'table', headers: headers, rows: rows });
}
}
continue;
}
// Paragraph: collect consecutive non-special lines
{
const paraLines: string[] = [];
while (i < lines.length) {
const ln: string = lines[i];
if (ln.trim().length === 0) {
break;
}
if (ln.startsWith('```') || ln.startsWith('#') || isHr(ln.trim())) {
break;
}
if (ln.startsWith('- ') || ln.startsWith('* ') || isOlStart(ln) || ln.startsWith('> ')) {
break;
}
if (ln.trimStart().startsWith('|') && !isTableSep(ln)) {
break;
}
paraLines.push(ln);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: 'para', text: paraLines.join('\n') });
}
}
}
return blocks;
}
// ── Component ──────────────────────────────────────────────────────────────────
@Component
export struct StaticMarkdownView {
@Prop content: string = '';
@Prop isDark: boolean = true;
private blocks: MdBlock[] = [];
aboutToAppear(): void {
this.blocks = parseBlocks(this.content);
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private headingSize(level: number): number {
if (level === 1) {
return 22;
}
if (level === 2) {
return 19;
}
if (level === 3) {
return 17;
}
if (level === 4) {
return 15.5;
}
return 14;
}
// ── Block builders ─────────────────────────────────────────────────────────
@Builder
ParaBlock(text: string) {
Column({ space: 1 }) {
ForEach(this.splitNewlines(text), (ln: string, idx: number) => {
// Render inline spans inside this line
Text() {
ForEach(parseInline(ln), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(15)
.fontColor(sp.link === true ? this.palette().accent : sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
.fontFamily(sp.code === true ? 'monospace' : '-')
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
.borderRadius(3)
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
.decoration(sp.link === true ? { type: TextDecorationType.Underline } : undefined)
}, (sp: MdSpan, si: number) => idx.toString() + '_' + si.toString())
}
.fontSize(15)
.lineHeight(24)
.fontColor(this.palette().msgBubbleText)
.width('100%')
.wordBreak(WordBreak.BREAK_ALL)
.textAlign(TextAlign.Start)
}, (ln: string, idx: number) => 'p' + idx.toString())
}
.width('100%')
.margin({ top: 2, bottom: 4 })
}
@Builder
HeadingBlock(block: MdBlock) {
Text(block.text ?? '')
.fontSize(this.headingSize(block.level ?? 1))
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.lineHeight(this.headingSize(block.level ?? 1) + 8)
.width('100%')
.margin({ top: 6, bottom: 4 })
}
@Builder
CodeBlock(block: MdBlock) {
Column() {
Row() {
Text(block.lang !== undefined && block.lang.length > 0 ? block.lang : 'code')
.fontSize(10)
.fontColor(this.palette().textMuted)
.fontFamily('monospace')
Blank()
}
.width('100%')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.palette().bgHover)
// Code body
Column() {
ForEach(block.codeLines ?? [], (ln: string, idx: number) => {
Text(ln.length > 0 ? ln : ' ')
.fontSize(12.5)
.lineHeight(19)
.fontFamily('monospace')
.fontColor(this.palette().preText)
.width('100%')
.textAlign(TextAlign.Start)
.wordBreak(WordBreak.BREAK_ALL)
}, (ln: string, idx: number) => 'c' + idx.toString())
}
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
}
.width('100%')
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().preBg)
.border({ width: 1, color: this.palette().kvBorder })
.clip(true)
.margin({ top: 4, bottom: 6 })
}
@Builder
ListBlock(block: MdBlock) {
Column({ space: 2 }) {
ForEach(block.items ?? [], (item: string, idx: number) => {
Row({ space: 6 }) {
Text('•')
.fontSize(15)
.fontColor(this.palette().accent)
.fontWeight(FontWeight.Bold)
.margin({ top: 1 })
Text() {
ForEach(parseInline(item), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(15)
.fontColor(sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
.fontFamily(sp.code === true ? 'monospace' : '-')
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
.borderRadius(3)
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
}, (sp: MdSpan, si: number) => 'li' + idx.toString() + '_' + si.toString())
}
.fontSize(15)
.lineHeight(23)
.fontColor(this.palette().msgBubbleText)
.layoutWeight(1)
.wordBreak(WordBreak.BREAK_ALL)
.width('100%')
}
.width('100%')
.alignItems(VerticalAlign.Top)
}, (item: string, idx: number) => idx.toString())
}
.width('100%')
.margin({ top: 2, bottom: 4 })
}
@Builder
OlBlock(block: MdBlock) {
Column({ space: 2 }) {
ForEach(block.items ?? [], (item: string, idx: number) => {
Row({ space: 6 }) {
Text((idx + 1).toString() + '.')
.fontSize(15)
.fontColor(this.palette().accent)
.fontWeight(FontWeight.Medium)
.margin({ top: 1 })
Text() {
ForEach(parseInline(item), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(15)
.fontColor(sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
.fontFamily(sp.code === true ? 'monospace' : '-')
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
.borderRadius(3)
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
}, (sp: MdSpan, si: number) => 'oli' + idx.toString() + '_' + si.toString())
}
.fontSize(15)
.lineHeight(23)
.fontColor(this.palette().msgBubbleText)
.layoutWeight(1)
.wordBreak(WordBreak.BREAK_ALL)
.width('100%')
}
.width('100%')
.alignItems(VerticalAlign.Top)
}, (item: string, idx: number) => idx.toString())
}
.width('100%')
.margin({ top: 2, bottom: 4 })
}
@Builder
BlockquoteBlock(text: string) {
Column() {
ForEach(this.splitNewlines(text), (ln: string, idx: number) => {
Text() {
ForEach(parseInline(ln), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(14)
.fontColor(this.palette().textTertiary)
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
}, (sp: MdSpan, si: number) => 'bq' + idx.toString() + '_' + si.toString())
}
.fontSize(14)
.lineHeight(22)
.width('100%')
.wordBreak(WordBreak.BREAK_ALL)
}, (ln: string, idx: number) => 'bq' + idx.toString())
}
.width('100%')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.margin({ top: 3, bottom: 5 })
.borderRadius({ topLeft: 0, topRight: 6, bottomRight: 6, bottomLeft: 0 })
.backgroundColor(this.palette().bgHover)
.border({
width: { left: 3, top: 0, right: 0, bottom: 0 },
color: { left: this.palette().accent, top: Color.Transparent, right: Color.Transparent, bottom: Color.Transparent },
})
}
@Builder
HrBlock() {
Row()
.width('100%')
.height(1)
.backgroundColor(this.palette().kvBorder)
.margin({ top: 6, bottom: 6 })
}
@Builder
TableBlock(block: MdBlock) {
Column() {
// Header
Row() {
ForEach(block.headers ?? [], (h: string, hi: number) => {
Text() {
ForEach(parseInline(h), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(12.5)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
}, (sp: MdSpan, si: number) => 'th' + hi.toString() + '_' + si.toString())
}
.fontSize(12.5)
.fontColor(this.palette().textPrimary)
.layoutWeight(1)
.padding({ left: 6, right: 6, top: 5, bottom: 5 })
}, (h: string, hi: number) => 'th' + hi.toString())
}
.width('100%')
.backgroundColor(this.palette().bgHover)
// Body
ForEach(block.rows ?? [], (row: string[], ri: number) => {
Row() {
ForEach(row, (cell: string, ci: number) => {
Text() {
ForEach(parseInline(cell), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(12.5)
.fontColor(this.palette().msgBubbleText)
.fontFamily(sp.code === true ? 'monospace' : '-')
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
}, (sp: MdSpan, si: number) => 'td' + ri.toString() + '_' + ci.toString() + '_' + si.toString())
}
.fontSize(12.5)
.fontColor(this.palette().msgBubbleText)
.layoutWeight(1)
.padding({ left: 6, right: 6, top: 4, bottom: 4 })
.wordBreak(WordBreak.BREAK_ALL)
.width('100%')
}, (cell: string, ci: number) => 'td' + ri.toString() + '_' + ci.toString())
}
.width('100%')
}, (row: string[], ri: number) => 'tr' + ri.toString())
}
.width('100%')
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().kvBorder })
.clip(true)
.margin({ top: 4, bottom: 6 })
}
// ── Helpers ────────────────────────────────────────────────────────────────
private splitNewlines(text: string): string[] {
if (text.length === 0) {
return [];
}
return text.split('\n');
}
// ── Build ──────────────────────────────────────────────────────────────────
build() {
Column() {
ForEach(this.blocks, (b: MdBlock, idx: number) => {
if (b.type === 'code') {
this.CodeBlock(b)
} else if (b.type === 'heading') {
this.HeadingBlock(b)
} else if (b.type === 'list') {
this.ListBlock(b)
} else if (b.type === 'ol') {
this.OlBlock(b)
} else if (b.type === 'blockquote') {
this.BlockquoteBlock(b.text ?? '')
} else if (b.type === 'hr') {
this.HrBlock()
} else if (b.type === 'table') {
this.TableBlock(b)
} else {
this.ParaBlock(b.text ?? '')
}
}, (b: MdBlock, idx: number) => idx.toString() + b.type + ((b.text ?? '').substring(0, 12)))
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,307 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM } from '../common/Constants';
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
import {
statusStore, StatGroup, StatField, compactDuration,
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
} from '../common/StatusStore';
/**
* 运行状态摘要卡嵌在设置一级页顶部原「状态」Tab 已删除)。
*
* 只放一眼可读的东西:环形仪表 + 运行时长 + 三项 KPI + 版本,
* 明细(内核 / 模型 / 记忆 / 运行时 / 工具清单)走二级页面。
* 点击整卡进入明细,所以自身不放任何按钮,避免嵌套点击。
*/
@Component
export struct StatusSummaryCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp(K_UP) private up: boolean = false;
@StorageProp(K_VERSION) private version: string = '-';
@StorageProp(K_STARTED) private startedAt: string = '';
@StorageProp(K_AGENTS) private agents: number = 0;
@StorageProp(K_PLUGINS) private plugins: number = 0;
@StorageProp(K_TOOLS) private tools: number = 0;
@StorageProp(K_ERR) private err: string = '';
@StorageProp(K_LOADING) private loading: boolean = false;
/** 秒级刷新的运行时长文本:环心不能用 startedAt 直接算,否则不会自动跳秒 */
@State private uptime: string = '-';
private timerId: number = -1;
onTap?: () => void;
aboutToAppear(): void {
this.uptime = compactDuration(this.startedAt);
this.timerId = setInterval(() => {
this.uptime = compactDuration(this.startedAt);
}, 1000);
}
aboutToDisappear(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
build() {
Column() {
if (this.err.length > 0) {
// 连接失败:只显示一句人话,技术细节在 hilog 里
Row() {
Image($r('app.media.ic_error'))
.width(16)
.height(16)
.fillColor(COLOR_ERROR)
.draggable(false)
Text(this.err)
.fontSize(13)
.fontColor(COLOR_ERROR)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.alignItems(VerticalAlign.Top)
} else if (this.loading && !this.up) {
Row() {
LoadingProgress()
.width(26)
.height(26)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 20, bottom: 20 })
} else {
// 状态主行:一枚状态图标(运行=脉搏,离线=断开)+ 状态词 + 运行时长。
// 之前那个环形进度条表达的是"100% / 0%",而在线与否是布尔量,
// 用百分比环表示只会让人以为有什么在加载;架构是单 agent 单 session
// "Agent 数" 恒为 1也没有信息量一并去掉。
Row({ space: 12 }) {
Stack({ alignContent: Alignment.Center }) {
Image(this.up ? $r('app.media.ic_pulse') : $r('app.media.ic_offline'))
.width(24)
.height(24)
.fillColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
.draggable(false)
}
.width(48)
.height(48)
.borderRadius(RADIUS_SM)
.backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover)
Column({ space: 3 }) {
Text(this.up ? '运行中' : '离线')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
Text(this.up ? '已运行 ' + this.uptime : '未连接到后端服务')
.fontSize(12)
.fontColor(this.palette().textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
Row({ space: 10 }) {
this.kpiTile($r('app.media.ic_plug'), '插件', this.plugins, COLOR_ACCENT)
this.kpiTile($r('app.media.ic_tool'), '工具', this.tools, COLOR_CYAN)
}
.width('100%')
.margin({ top: 14 })
// 版本 + 「查看明细」提示,与 KPI 之间用分割线断开
Row() {
Text('版本')
.fontSize(12)
.fontColor(this.palette().textMuted)
Text(this.version)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 10 })
Text('明细')
.fontSize(12)
.fontColor(this.palette().accent)
Image($r('app.media.ic_chevron_right'))
.width(14)
.height(14)
.fillColor(this.palette().accent)
.draggable(false)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
.padding({ top: 11 })
.border({ width: { top: 1 }, color: this.palette().kvBorder })
}
}
.width('100%')
.padding(18)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 18 })
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
@Builder
kpiTile(icon: Resource, label: string, value: number, color: string) {
Row({ space: 8 }) {
Image(icon)
.width(16)
.height(16)
.fillColor(color)
.draggable(false)
Column({ space: 1 }) {
Text(value > 0 ? value.toString() : '-')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
Text(label)
.fontSize(11)
.fontColor(this.palette().textMuted)
}
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.alignItems(VerticalAlign.Center)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 运行状态明细:设置页「运行状态」二级页面的内容。
*
* 服务负载三条容量条 + 分组明细卡(系统概览 / 内核 / 模型 / 记忆 / 运行时 / 工具)。
* 分组数组不放 AppStorage数组同步语义不可靠改为订阅版本号 K_REV
* 版本变化时从 statusStore 重取一次快照。
*/
@Component
export struct StatusDetailContent {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp(K_UP) private up: boolean = false;
@StorageProp(K_AGENTS) private agents: number = 0;
@StorageProp(K_PLUGINS) private plugins: number = 0;
@StorageProp(K_TOOLS) private tools: number = 0;
@StorageProp(K_ERR) private err: string = '';
@StorageProp(K_LOADING) private loading: boolean = false;
@StorageProp(K_REV) @Watch('onRevChanged') private rev: number = 0;
@State groups: StatGroup[] = [];
aboutToAppear(): void {
this.groups = statusStore.getGroups();
}
private onRevChanged(): void {
this.groups = statusStore.getGroups();
}
build() {
Column() {
if (this.err.length > 0) {
Row() {
Image($r('app.media.ic_error'))
.width(16)
.height(16)
.fillColor(COLOR_ERROR)
.draggable(false)
Text(this.err)
.fontSize(13)
.fontColor(COLOR_ERROR)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.padding(16)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(VerticalAlign.Top)
.margin({ bottom: 14 })
}
if (this.loading && this.groups.length === 0) {
Row() {
LoadingProgress()
.width(28)
.height(28)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 40, bottom: 40 })
}
// 这里原本有一张"服务负载"卡,用两条容量条画插件/工具数量。
// 后端并不存在"插件上限/工具上限"这种容量概念,分母是两者取大值,
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
ForEach(this.groups, (g: StatGroup) => {
Column() {
Text(g.title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.margin({ bottom: 6 })
ForEach(g.fields, (f: StatField, idx: number) => {
Row() {
Text(f.label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(f.value.length > 0 ? f.value : '-')
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 200 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.border({
width: { bottom: idx < g.fields.length - 1 ? 1 : 0 },
color: this.palette().kvBorder,
})
}, (f: StatField) => g.title + f.label)
}
.width('100%')
.padding(18)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 14 })
}, (g: StatGroup) => g.title)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}

View File

@ -0,0 +1,333 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG } from '../common/Constants';
import { handleNavOnScroll } from '../common/NavBarController';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from './PageTopBar';
import { GradientBackground } from './GradientBackground';
/**
* 二级页面开关标志AppStorage
* Index 读取它来锁住 Swiper 左右滑动——否则在二级页面里横滑会误切换主 Tab。
* 由 Navigation.onNavBarStateChange 驱动,与真实导航栈严格同步。
*/
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
export function markSubPageOpen(open: boolean): void {
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
}
/** pushPathByName 的参数载体ArkTS 不允许把 string 断言成 object */
export interface SubPageParam {
id: string;
}
export function subPageParam(id: string): SubPageParam {
const p: SubPageParam = { id: id };
return p;
}
/**
* 二级页面内容层:放在 NavDestination 里使用。
*
* 为什么二级页面走 Navigation / NavDestination 而不是自己用 @State 切换:
* - NavDestination 由系统维护导航栈,侧滑返回、三键返回、
* 以及无障碍返回都会自动 pop不需要自己拦 onBackPress
* - 转场动画由系统提供,与其他系统应用一致。
*
* 视觉约定与一级页面完全一致:
* - 自带同款渐变背景NavDestination 会整屏盖住一级内容,
* 不铺背景会透出下层列表形成重影);
* - 顶栏只有标题,上边缘由不透明渐变到透明;
* - 返回按钮不放顶栏,而是作为独立悬浮组件放在底部悬浮区(拇指可达)。
*
* 注意:@BuilderParam 只能有一个trailing lambda 限制),
* 所以悬浮区的刷新按钮用 showRefresh + onRefresh 两个普通属性表达,
* 而不是第二个 @BuilderParam。
*/
@Component
export struct SubPageLayer {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 宽屏分栏时本层就是右侧栏,一级界面一直在左边可见,返回键无意义 */
@StorageProp('isWideScreen') private isWide: boolean = false;
@Prop title: string = '';
/** 所属主 Tab 序号,供底部悬浮区的显隐动画使用 */
@Prop tab: number = 0;
/** 悬浮区是否附带刷新按钮 */
@Prop showRefresh: boolean = false;
onBack?: () => void;
onRefresh?: () => void;
@BuilderParam content: () => void;
build() {
Stack({ alignContent: Alignment.Bottom }) {
// 二级页面自带背景Stack 模式下整屏覆盖,必须自己铺底;
// 宽屏分栏时右栏也需要同款背景,与左栏视觉连续。
GradientBackground()
Scroll() {
Column() {
this.content()
}
.width('100%')
// 宽屏右栏底部没有主导航胶囊,只保留悬浮键的空间
.padding({ left: 16, right: 16, top: 76, bottom: this.isWide ? 108 : 174 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.align(Alignment.Top)
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state);
})
PageTopBar({ title: this.title })
// 底部悬浮区:返回键(+ 可选刷新键)。
// 宽屏分栏时左栏常驻可见,一级页面自己的悬浮刷新键也还在屏幕上,
// 这里再挂一个就成了"两个重加载按钮"——所以宽屏下本层不出刷新键,
// 由一级页面那一个统一负责(它的回调会连带刷新右栏内容)。
NavFloatOverlay({ tab: this.tab }) {
NavFloatRow() {
if (!this.isWide) {
SubPageBackButton({
onTap: () => {
const cb = this.onBack;
if (cb !== undefined) {
cb();
}
},
})
if (this.showRefresh) {
FloatIconButton({
icon: $r('app.media.ic_refresh'),
onTap: () => {
const cb = this.onRefresh;
if (cb !== undefined) {
cb();
}
},
})
}
}
}
}
}
.width('100%')
.height('100%')
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 一级页面里的入口行:图标 + 标题 + 副标题 + 右侧摘要值 + 右尖角。
* 点击进入二级页面。
*/
@Component
export struct NavRow {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop icon: Resource | undefined = undefined;
@Prop title: string = '';
@Prop subtitle: string = '';
@Prop value: string = '';
@Prop tint: string = '';
@Prop showDivider: boolean = true;
/** 宽屏分栏时右栏正显示本行对应的二级页面 —— 高亮当前项 */
@Prop selected: boolean = false;
onTap?: () => void;
build() {
Row() {
if (this.icon !== undefined) {
Row() {
Image(this.icon)
.width(17)
.height(17)
.fillColor(this.iconColor())
.draggable(false)
}
.width(32)
.height(32)
.borderRadius(10)
.backgroundColor(this.iconBg())
.justifyContent(FlexAlign.Center)
.margin({ right: 12 })
}
Column({ space: 2 }) {
Text(this.title)
.fontSize(15)
.fontColor(this.selected ? this.palette().accent : this.palette().textPrimary)
.fontWeight(this.selected ? FontWeight.Medium : FontWeight.Normal)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.subtitle.length > 0) {
Text(this.subtitle)
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (this.value.length > 0) {
Text(this.value)
.fontSize(12)
.fontColor(this.palette().textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 130 })
.margin({ right: 8 })
}
Image($r('app.media.ic_chevron_right'))
.width(15)
.height(15)
.fillColor(this.selected ? this.palette().accent : this.palette().textMuted)
.draggable(false)
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.alignItems(VerticalAlign.Center)
.backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent)
.border({
width: { bottom: this.showDivider ? 1 : 0 },
color: this.palette().kvBorder,
})
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
private iconColor(): string {
return this.tint.length > 0 ? this.tint : this.palette().accent;
}
private iconBg(): string {
return this.tint.length > 0 ? this.palette().bgHover : this.palette().accentBg;
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 分组卡:一个标题 + 一组 NavRow圆角裁剪让行分割线不越出卡片。
*/
@Component
export struct NavGroup {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop caption: string = '';
@BuilderParam content: () => void;
build() {
Column() {
if (this.caption.length > 0) {
Text(this.caption)
.fontSize(12)
.fontColor(this.palette().textMuted)
.margin({ left: 4, bottom: 8 })
}
Column() {
this.content()
}
.width('100%')
.borderRadius(RADIUS_LG)
.clip(true)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 18 })
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 通用内容卡(二级页面里承载表单/明细的容器)。
*/
@Component
export struct PlainCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop caption: string = '';
@BuilderParam content: () => void;
build() {
Column() {
if (this.caption.length > 0) {
Text(this.caption)
.fontSize(12)
.fontColor(this.palette().textMuted)
.margin({ left: 4, bottom: 8 })
}
Column() {
this.content()
}
.width('100%')
.padding(16)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 18 })
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 二级页面的返回按钮:与悬浮图标按钮同款玻璃,放在底部悬浮区最左侧。
* 系统侧滑返回同样可用,这个按钮只是给单手拇指多一条路径。
*/
@Component
export struct SubPageBackButton {
@StorageProp('themeIsDark') private isDark: boolean = true;
onTap?: () => void;
build() {
Button() {
Row({ space: 4 }) {
Image($r('app.media.ic_arrow_back'))
.width(17)
.height(17)
.fillColor(this.palette().textSecondary)
.draggable(false)
}
}
.width(42)
.height(42)
.type(ButtonType.Circle)
.backgroundColor(this.palette().navBarBg)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}

View File

@ -0,0 +1,140 @@
import { UIAbility, AbilityConstant, Configuration, ConfigurationConstant, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient';
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
function storedThemeMode(): string {
try {
const s = connStore.getSettings();
if (typeof s.theme === 'string' && s.theme.length > 0) {
return s.theme;
}
} catch (e) {
// store not ready yet
}
return 'system';
}
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
console.info('[HomeAgent] ability onCreate');
const sysDark: boolean =
this.context.config?.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
// Seed the OS color state first, then resolve the persisted mode.
// connStore may not be initialized here (mode falls back to 'system');
// onWindowStageCreate re-applies the real stored mode after init.
seedSystemIsDark(sysDark);
seedTheme(resolveIsDark(storedThemeMode(), sysDark));
}
onDestroy(): void {
console.info('[HomeAgent] ability onDestroy');
}
/** System dark/light switch — re-resolve when mode is 'system'. */
onConfigurationUpdate(newConfig: Configuration): void {
try {
const sysDark: boolean = newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
seedSystemIsDark(sysDark);
const mode: string = storedThemeMode();
if (mode === 'system') {
AppStorage.set('themeIsDark', resolveIsDark('system', sysDark));
this.applySystemBar();
}
} catch (e) {
// ignore
}
}
private applySystemBar(): void {
// 状态栏与窗口底色跟随主题,消除浅色模式下的暗色割裂
const dark: boolean = themeIsDark();
const bg: string = dark ? '#000000' : '#F1F3F5';
const fg: string = dark ? '#FFFFFF' : '#191919';
try {
window.getLastWindow(this.context).then((win: window.Window) => {
win.setWindowSystemBarProperties({
statusBarColor: bg,
statusBarContentColor: fg,
navigationBarColor: bg,
});
win.setWindowBackgroundColor(bg);
}).catch(() => {
// ignore
});
} catch (e) {
// ignore
}
}
onWindowStageCreate(windowStage: window.WindowStage): void {
const startUI = (): void => {
windowStage.loadContent('pages/Index', () => {
console.info('[HomeAgent] main page loaded');
});
};
// 沉浸式窗口 + 主题化系统栏,消除真机顶部/底部白边
windowStage.getMainWindow((err: BusinessError, win: window.Window) => {
if (err.code !== 0) {
connStore.init(this.context).then(async () => {
await connStore.ensureDefaultConnection();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.reapplyStoredTheme();
startUI();
}).catch(() => {
startUI();
});
return;
}
try {
// 全屏沉浸:内容延伸到状态栏和导航栏区域
win.setWindowLayoutFullScreen(true);
} catch (e) {
console.warn('[HomeAgent] setWindowLayoutFullScreen failed: ' + (e as Error).message);
}
connStore.init(this.context).then(async () => {
await connStore.ensureDefaultConnection();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
console.info('[HomeAgent] connStore initialized');
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
this.reapplyStoredTheme();
this.applySystemBar();
startUI();
}).catch((e: Error) => {
console.error('[HomeAgent] connStore init failed: ' + e.message);
startUI();
});
});
}
/** After connStore.init, re-resolve themeIsDark from the persisted mode. */
private reapplyStoredTheme(): void {
try {
applyThemeMode(storedThemeMode());
} catch (e) {
// ignore
}
}
onWindowStageDestroy(): void {
console.info('[HomeAgent] ability onWindowStageDestroy');
}
onForeground(): void {
console.info('[HomeAgent] ability onForeground');
}
onBackground(): void {
console.info('[HomeAgent] ability onBackground');
}
}

View File

@ -0,0 +1,175 @@
export interface ConnectionConfig {
id: string;
name: string;
url: string;
apiKey: string;
type: string; // 'webui' | 'cli'
}
export interface ToolCallInfo {
name: string;
args: string;
result?: string;
plugin?: string;
status?: string; // 'ok' | 'error' | 'denied' | 'running'
open?: boolean;
}
export interface ChatMessage {
id: number;
role: string; // 'user' | 'assistant'
content: string;
reasoningContent?: string;
reasoningOpen?: boolean;
isStreaming?: boolean;
isFinal?: boolean;
toolCalls?: ToolCallInfo[];
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
source?: string;
/** 图片/文件附件(后端 ChatMsg.attachment */
attachment?: ChatAttachment;
}
export interface HistoryMessage {
role: string;
content: string;
source?: string;
timestamp?: string;
attachment?: ChatAttachment;
}
export interface AgentStatus {
version?: string;
uptime_ms?: number;
agent?: string;
provider?: string;
memory_events?: number;
}
export interface DeviceInfo {
deviceId: string;
name: string;
kind: string;
online: boolean;
authorized: boolean;
caps: string[];
hostname?: string;
platform?: string;
}
export interface PluginInfo {
name: string;
nameZh?: string;
nameEn?: string;
version?: string;
description?: string;
author?: string;
deprecated?: boolean;
builtin: boolean;
loaded: boolean;
disabled: boolean;
tools?: string[];
}
/**
* One row in the merged plugin list:
* kernel.plugins installed(/plugins) disabled(/plugins/disabled)
* 与 WebGUI renderPlugins 的合并口径一致。
*/
export interface PluginRow {
name: string;
/** /kernel.plugins 里出现且 loaded=true 才算已加载 */
loaded: boolean;
/** 出现在 /plugins/disabled 列表里 */
disabled: boolean;
/** 已安装的外部插件(/plugins 返回) */
external: boolean;
version?: string;
description?: string;
tools?: string[];
}
/**
* GET /plugins/{name} 返回的插件清单明细。
* 字段与后端 pluginmgr 的 pluginInfo 一一对应;
* WebGUI 只把它 JSON.stringify 到 <pre> 里,这里改为结构化展示。
*/
export interface PluginDetail {
name: string;
version: string;
description: string;
author: string;
license: string;
homepage: string;
repository: string;
entry: string;
minVersion: string;
tags: string[];
deprecated: boolean;
files: string[];
}
export function emptyPluginDetail(): PluginDetail {
const d: PluginDetail = {
name: '',
version: '',
description: '',
author: '',
license: '',
homepage: '',
repository: '',
entry: '',
minVersion: '',
tags: [],
deprecated: false,
files: [],
};
return d;
}
/**
* 聊天消息附件。字段与后端 webui 的 Attachment 严格一致,只有四个:
* type / url / size / name —— 后端不提供 mime、尺寸、本地路径。
*/
export interface ChatAttachment {
/** 'image' | 'file' */
type: string;
/** /files/<name>、/uploads/<name> 或远程 http(s) 地址 */
url: string;
/** 字节数;远程 URL 为 0 */
size: number;
/** 展示用文件名 */
name: string;
}
export interface AppSettings {
lang: string; // 'zh' | 'en'
theme: string; // 'dark' | 'light' | 'system'
currentConnId: string;
/** 自定义背景图路径(应用沙箱内文件路径),空串表示未设置 */
bgImage: string;
/** 背景图不透明度 0..1 */
bgOpacity: number;
}
export function emptySettings(): AppSettings {
const s: AppSettings = {
lang: 'zh',
theme: 'dark',
currentConnId: '',
bgImage: '',
bgOpacity: 0.25,
};
return s;
}
export function defaultConnection(): ConnectionConfig {
const c: ConnectionConfig = {
id: '',
name: 'HomeAgent',
url: 'http://192.168.2.60:8080',
apiKey: 'jinrui233719',
type: 'webui',
};
return c;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,652 @@
import { deviceBridge } from '../common/DeviceBridge';
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient';
import { noConnectionMessage } from '../common/UserError';
import { handleNavOnScroll } from '../common/NavBarController';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { DeviceInfo } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
import { common } from '@kit.AbilityKit';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应)
const LOCAL_CAPS: string[] = [
'status',
'deviceinfo',
'screensee',
'screensue',
'clipboardsee',
'clipboardsue',
'speakeruse',
];
/** 二级页面标识 */
const SUB_NONE: string = '';
const SUB_LOCAL: string = 'local';
const SUB_CAPS: string = 'caps';
const SUB_GATEWAY: string = 'gateway';
const SUB_LIST: string = 'list';
@Component
export struct DevicePage {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp('navVisible') private navVisible: boolean = true;
@StorageProp('currentTab') private currentTab: number = 0;
/** 宽屏:左边一级界面(含底部导航栏),右边二级界面 */
@StorageProp('isWideScreen') private isWide: boolean = false;
/** 当前右栏展示的二级页面 id用于宽屏下高亮左侧入口行 */
@State activeSub: string = SUB_NONE;
@State bridgeConnected: boolean = false;
@State bridgeUrl: string = '';
@State bridgeToken: string = '';
@State deviceId: string = '';
@State authorized: boolean = false;
@State devices: DeviceInfo[] = [];
@State loadingDevices: boolean = false;
@State lastError: string = '';
@State toastMsg: string = '';
@State toastIsError: boolean = false;
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack();
private autoConnectTried: boolean = false;
aboutToAppear(): void {
this.deviceId = deviceBridge.getDeviceId();
if (this.deviceId.length === 0) {
this.deviceId = connStore.getDeviceId();
}
if (this.deviceId.length === 0) {
this.deviceId = 'ohos-' + Date.now().toString(36);
try {
connStore.saveDeviceId(this.deviceId);
} catch (e) {
// ignore
}
}
this.authorized = connStore.getDeviceAuth();
// Gateway URL derives from current connection
const cur = connStore.getCurrentConnection();
if (cur !== null) {
this.bridgeUrl = this.gatewayUrlOf(cur.url);
this.bridgeToken = cur.apiKey;
}
installCmdRouter();
try {
setBridgeAppContext(getContext(this) as common.UIAbilityContext);
} catch (e) {
// ignore context errors
}
deviceBridge.setStateListener((open: boolean) => {
this.bridgeConnected = open;
if (open) {
this.lastError = '';
this.showToast('设备网关已连接', false);
this.refreshDevices();
}
});
this.refreshDevices();
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
registerNavStack(2, this.navStack, () => {
this.activeSub = SUB_NONE;
});
}
aboutToDisappear(): void {
unregisterNavStack(2);
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/**
* 把后端 HTTP 地址转成设备桥的 WebSocket 地址。
*
* 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头,
* 直接把 http:// 传进 connect() 会在 native 层报
* "protocol failed" + "ParseUrl failed"NETSTACK websocket_exec.cpp
* 表现为设备通道永远连不上。所以这里必须做协议替换。
*/
private gatewayUrlOf(base: string): string {
let trimmed: string = base.trim();
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
trimmed = trimmed.substring(0, trimmed.length - 1);
}
let scheme: string = 'ws://';
let rest: string = trimmed;
if (trimmed.startsWith('https://')) {
scheme = 'wss://';
rest = trimmed.substring('https://'.length);
} else if (trimmed.startsWith('http://')) {
scheme = 'ws://';
rest = trimmed.substring('http://'.length);
} else if (trimmed.startsWith('wss://')) {
scheme = 'wss://';
rest = trimmed.substring('wss://'.length);
} else if (trimmed.startsWith('ws://')) {
scheme = 'ws://';
rest = trimmed.substring('ws://'.length);
}
return scheme + rest + '/api/v1/device/ws';
}
/**
* 打开二级页面。
*
* 窄屏Stack 模式push 一层,整屏覆盖,系统侧滑返回可退。
* 宽屏Split 模式):左栏一级列表常驻,右栏只应有一页,
* 所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。
*/
private openSub(id: string): void {
this.activeSub = id;
if (this.isWide && this.navStack.size() > 0) {
this.navStack.replacePathByName(id, subPageParam(id), false);
} else {
this.navStack.pushPathByName(id, subPageParam(id), true);
}
}
/** 二级页面返回键宽屏下左栏常驻可见SubPageLayer 已隐藏返回键 */
private closeSub(): void {
this.navStack.pop();
this.activeSub = SUB_NONE;
}
/** Toggle local authorization flag; hello 同步到网关。 */
private toggleAuthorized(on: boolean): void {
this.authorized = on;
try {
connStore.saveDeviceAuth(on);
} catch (e) {
// ignore persist failure
}
deviceBridge.updateAuthorized(on);
if (!this.bridgeConnected) {
this.connectBridge();
}
this.showToast(on ? '已授权agent 可下发能力命令' : '已取消授权', false);
}
// ===================== gateway connection =====================
private async connectBridge(): Promise<void> {
if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) {
this.lastError = noConnectionMessage();
return;
}
this.lastError = '';
if (this.deviceId.length === 0) {
this.deviceId = 'ohos-' + Date.now().toString(36);
}
try {
connStore.saveDeviceId(this.deviceId);
} catch (e) {
// ignore
}
const name: string = 'HomeAgent OHOS';
await deviceBridge.connect(
this.bridgeUrl, this.bridgeToken, this.deviceId,
LOCAL_CAPS, 'ohos-phone', this.authorized, name);
}
/** 首次进入自动尝试连接(静默,失败不打扰)。 */
private maybeAutoConnect(): void {
if (this.autoConnectTried || this.bridgeConnected) {
return;
}
this.autoConnectTried = true;
if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) {
this.connectBridge();
}
}
private showToast(msg: string, isError: boolean): void {
this.toastMsg = msg;
this.toastIsError = isError;
setTimeout(() => {
this.toastMsg = '';
}, 2500);
}
/** 拉取网关在线设备列表(经 webui 反代)。 */
private async refreshDevices(): Promise<void> {
this.loadingDevices = true;
try {
// apiClient 已自动前置 /api/v1这里只写其后的部分
// 否则会拼成 /api/v1/api/v1/device/online 并 404。
const resp = await apiClient.get('/device/online');
if (resp.status >= 200 && resp.status < 300) {
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const devs: Object = parsed['devices'];
const list: DeviceInfo[] = [];
if (devs !== undefined && devs !== null) {
const arr: Object[] = devs as Object[];
for (let i = 0; i < arr.length; i++) {
const d: Record<string, Object> = arr[i] as Record<string, Object>;
const capsArr: Object = d['caps'];
const caps: string[] = [];
if (capsArr !== undefined && capsArr !== null) {
const cArr: Object[] = capsArr as Object[];
for (let j = 0; j < cArr.length; j++) {
caps.push(cArr[j] as string);
}
}
const info: DeviceInfo = {
deviceId: d['device_id'] as string ?? '',
name: d['name'] as string ?? '',
kind: d['kind'] as string ?? '',
online: true,
authorized: d['authorized'] as boolean ?? false,
caps: caps,
};
list.push(info);
}
}
this.devices = list;
} else {
this.devices = [];
}
} catch (e) {
// 网络失败保持静默,不打扰用户
}
this.loadingDevices = false;
}
build() {
// Navigation 提供真实导航栈:系统侧滑返回 / 三键返回都会自动 pop
Navigation(this.navStack) {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Scroll() {
Column() {
this.RootEntries()
}
.width('100%')
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.align(Alignment.Top)
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state);
})
.onAppear(() => {
this.maybeAutoConnect();
})
}
.width('100%')
.height('100%')
PageTopBar({ title: '设备' })
// 一级悬浮区:只留刷新(网关状态徽标按用户要求去掉,
// 状态已经在页面内的网关卡片里如实展示)
NavFloatOverlay({ tab: 2 }) {
NavFloatRow() {
FloatIconButton({
icon: $r('app.media.ic_refresh'),
onTap: () => {
this.refreshDevices();
},
})
}
}
this.Toast()
}
.width('100%')
.height('100%')
.backgroundColor(Color.Transparent)
}
.navDestination(this.SubDestination)
// 宽屏(>=600vp用 SplitnavBar一级列表 + 底部悬浮导航栏)常驻左栏,
// NavDestination二级页面渲染在右栏两栏同时可见。
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
.navBarPosition(NavBarPosition.Start)
.navBarWidth(WIDE_NAV_BAR_WIDTH)
.minContentWidth(WIDE_MIN_CONTENT)
.hideTitleBar(true)
.hideToolBar(true)
.hideBackButton(true)
.width('100%')
.height('100%')
// Split 模式下 navBar 常驻,此回调不再触发;宽屏一律锁住主 Tab 横滑
.onNavBarStateChange((isVisible: boolean) => {
markSubPageOpen(this.isWide || !isVisible);
})
// 模式初始化与切换Split 右栏不能空白,自动填入默认二级页面;
// 退回 Stack 时清栈,否则会残留一个整屏覆盖的二级页面。
.onNavigationModeChange((mode: NavigationMode) => {
if (mode === NavigationMode.Split) {
markSubPageOpen(true);
if (this.navStack.size() === 0) {
this.openSub(SUB_LOCAL);
}
} else {
this.navStack.clear(false);
this.activeSub = SUB_NONE;
markSubPageOpen(false);
}
})
}
/** 二级页面路由表 */
@Builder
SubDestination(name: string, param: object) {
NavDestination() {
if (name === SUB_LOCAL) {
SubPageLayer({
title: '本机设备',
tab: 2,
onBack: () => {
this.closeSub();
},
}) {
this.LocalDeviceContent()
}
} else if (name === SUB_CAPS) {
SubPageLayer({
title: '设备能力',
tab: 2,
onBack: () => {
this.closeSub();
},
}) {
this.CapsContent()
}
} else if (name === SUB_GATEWAY) {
SubPageLayer({
title: '设备通道',
tab: 2,
onBack: () => {
this.closeSub();
},
}) {
this.GatewayContent()
}
} else if (name === SUB_LIST) {
SubPageLayer({
title: '接入的设备',
tab: 2,
onBack: () => {
this.closeSub();
},
showRefresh: true,
onRefresh: () => {
this.refreshDevices();
},
}) {
this.OnlineDevicesContent()
}
}
}
.hideTitleBar(true)
.backgroundColor(Color.Transparent)
}
@Builder
Toast() {
if (this.toastMsg.length > 0) {
Row() {
Text(this.toastMsg)
.fontSize(13)
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 20 })
.margin({ bottom: 166 })
}
}
// ===================== 一级入口列表 =====================
@Builder
RootEntries() {
NavGroup({ caption: '本机' }) {
NavRow({
icon: $r('app.media.ic_phone'),
title: '本机设备',
subtitle: this.deviceId.length > 0 ? this.deviceId : '未注册',
value: this.bridgeConnected ? '在线' : '离线',
selected: this.isWide && this.activeSub === SUB_LOCAL,
onTap: () => {
this.openSub(SUB_LOCAL);
},
})
NavRow({
icon: $r('app.media.ic_bolt'),
title: '设备能力',
subtitle: this.capsCount().toString() + ' 项能力',
value: '',
showDivider: false,
selected: this.isWide && this.activeSub === SUB_CAPS,
onTap: () => {
this.openSub(SUB_CAPS);
},
})
}
NavGroup({ caption: '通道' }) {
NavRow({
icon: $r('app.media.ic_gateway'),
title: '设备通道',
subtitle: this.bridgeUrl.length > 0 ? '网关已配置' : '未配置',
value: this.bridgeConnected ? '已连接' : '未连接',
selected: this.isWide && this.activeSub === SUB_GATEWAY,
onTap: () => {
this.openSub(SUB_GATEWAY);
},
})
NavRow({
icon: $r('app.media.ic_devices_multi'),
title: '接入的设备',
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
value: this.devices.length.toString() + ' 台',
showDivider: false,
selected: this.isWide && this.activeSub === SUB_LIST,
onTap: () => {
this.openSub(SUB_LIST);
},
})
}
}
private capsCount(): number {
return LOCAL_CAPS.length;
}
// ===================== 二级:本机设备 =====================
@Builder
LocalDeviceContent() {
PlainCard({ caption: '基本信息' }) {
this.KvRow('设备 ID', this.deviceId.length > 0 ? this.deviceId : '未注册')
this.KvRow('名称', 'HomeAgent OHOS')
this.KvRow('类型', 'phone')
}
PlainCard({ caption: '权限控制' }) {
Row() {
Column({ space: 2 }) {
Text('允许 agent 控制本机')
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text('授权后 agent 可调用下方能力;截屏仅捕获本应用画面,剪贴板读取需系统弹窗确认。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: this.authorized })
.selectedColor(this.palette().accent)
.onChange((on: boolean) => {
this.toggleAuthorized(on);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Circle({ width: 8, height: 8 })
.fill(this.authorized ? '#17A964' : '#E84026')
.margin({ right: 8 })
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
.fontSize(12)
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
}
.width('100%')
.margin({ top: 12 })
.padding({ left: 4 })
}
}
// ===================== 二级:设备能力 =====================
@Builder
CapsContent() {
PlainCard({ caption: '能力清单' }) {
Text('agent 通过设备桥可调用的本机能力:')
.fontSize(12)
.fontColor(this.palette().textMuted)
.margin({ bottom: 10 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(LOCAL_CAPS, (cap: string) => {
Text(cap)
.fontSize(11)
.fontColor(this.palette().accent)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(999)
.backgroundColor(this.palette().bgHover)
.border({ width: 1, color: this.palette().glassBorder })
.margin({ right: 6, bottom: 6 })
}, (cap: string) => cap)
}
.width('100%')
}
}
// ===================== 二级:设备通道 =====================
@Builder
GatewayContent() {
PlainCard({ caption: '连接信息' }) {
this.KvRow('网关地址', this.bridgeUrl.length > 0 ? this.bridgeUrl : '-')
this.KvRow('Token', this.bridgeToken.length > 0 ? '已从连接继承' : '未配置')
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
}
if (this.lastError.length > 0) {
Text(this.lastError)
.fontSize(12)
.fontColor('#E84026')
.padding({ left: 4 })
}
PlainCard({ caption: '操作' }) {
Row() {
Button(this.bridgeConnected ? '断开' : '连接网关')
.height(34)
.fontSize(12)
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
.border({
width: this.bridgeConnected ? 1 : 0,
color: this.palette().btnGhostBorder,
})
.onClick(() => {
if (this.bridgeConnected) {
deviceBridge.disconnect();
} else {
this.connectBridge();
}
})
Blank()
Button('刷新设备')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.onClick(() => {
this.refreshDevices();
})
}
.width('100%')
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态bind 携带 Token 完成身份绑定。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 10 })
}
}
// ===================== 二级:接入的设备 =====================
@Builder
OnlineDevicesContent() {
if (this.devices.length === 0) {
Text('暂无其他设备。电脑 GUI 或 CLI 连接同一网关后会出现在这里。')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ left: 4 })
}
ForEach(this.devices, (dev: DeviceInfo) => {
PlainCard({ caption: '' }) {
Row() {
Circle({ width: 8, height: 8 })
.fill(dev.online ? '#17A964' : '#77809A')
.margin({ right: 10 })
Column() {
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
.fontSize(11)
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(dev.caps.length.toString() + ' 能力')
.fontSize(10)
.fontColor(this.palette().textMuted)
}
.width('100%')
}
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
}
// ===================== 通用 KV 行 =====================
@Builder
KvRow(k: string, v: string) {
Row() {
Text(k)
.fontSize(13)
.fontColor(this.palette().textSecondary)
Blank()
Text(v)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
}
}
const RADIUS_MD: number = 10;

View File

@ -0,0 +1,388 @@
import { ChatPage } from './ChatPage';
import { PluginsPage } from './PluginsPage';
import { DevicePage } from './DevicePage';
import { SettingsPage } from './SettingsPage';
import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient';
import { navBar } from '../common/NavBarController';
import { handleBackPress } from '../common/NavStackRegistry';
import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants';
import { GradientBackground } from '../components/GradientBackground';
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
import { window, display } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
interface TabItem {
icon: Resource;
label: string;
pageId: string;
}
/**
* 底部导航单项:矢量图标 + 文字。
* active 必须 @Prop 才能在父组件 currentTab 变化时刷新高亮。
*/
@Component
export struct RailTab {
icon: Resource = $r('app.media.ic_tab_chat');
@Prop label: string = '';
@Prop active: boolean = false;
@Prop accent: string = '';
@Prop muted: string = '';
build() {
Column({ space: 3 }) {
Image(this.icon)
.width(22)
.height(22)
.fillColor(this.active ? this.accent : this.muted)
.draggable(false)
Text(this.label)
.fontSize(10)
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.active ? this.accent : this.muted)
.maxLines(1)
}
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.width('100%')
.height(48)
.animation({ duration: 200, curve: Curve.EaseOut })
}
}
@Entry
@Component
struct Index {
@Watch('onTabChanged') @State currentTab: number = 0;
@StorageProp('navVisible') private navVisible: boolean = true;
/** 二级页面打开时锁住 Swiper 横滑,避免误切换主 Tab */
@StorageProp('subPageOpen') private subPageOpen: boolean = false;
/** 宽屏(>=600vp一级界面在左、二级界面在右导航栏跟随左侧一级界面 */
@StorageProp('isWideScreen') private isWide: boolean = false;
@State screensueVisible: boolean = false;
@State screensueText: string = '';
@State screensueCountdown: number = 0;
/** 状态栏避让高度vp从窗口 avoid area 动态获取;默认 44 兜底 */
@State topInset: number = 44;
/** 底部手势条高度vp */
@State bottomGesture: number = 16;
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
private swiper: SwiperController = new SwiperController();
private screensueTimer: number = -1;
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
// 「状态」Tab 已删除:运行状态并入设置页(顶部摘要卡 + 二级明细页),
// 底部导航只保留四项,胶囊内每项更宽、点按更好命中。
private tabs: TabItem[] = [
{ icon: $r('app.media.ic_tab_chat'), label: '聊天', pageId: 'chat' },
{ icon: $r('app.media.ic_tab_plugins'), label: '插件', pageId: 'plugins' },
{ icon: $r('app.media.ic_tab_devices'), label: '设备', pageId: 'devices' },
{ icon: $r('app.media.ic_tab_settings'), label: '设置', pageId: 'settings' },
];
aboutToAppear(): void {
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
// 种子化自定义背景图状态到 AppStorageGradientBackground 响应读取
const st = connStore.getSettings();
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
AppStorage.setOrCreate<number>('bgOpacity', st.bgOpacity ?? 0.25);
AppStorage.setOrCreate<boolean>('navVisible', true);
AppStorage.setOrCreate<boolean>('subPageOpen', false);
AppStorage.setOrCreate<boolean>('isWideScreen', false);
// 启动时按当前主题刷新一次系统栏EntryAbility 已设过,这里兜底对齐)
this.syncSystemBar();
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44
this.resolveSafeArea();
// 设备桥:注册命令路由与 screensue 悬浮层回调
installCmdRouter();
registerScreensueHandler((payload: ScreensuePayload) => {
this.showScreensue(payload);
});
}
/** agent 下发的 screensue 内容展示悬浮卡片倒计时自动关闭0=常驻)。 */
private showScreensue(payload: ScreensuePayload): void {
this.screensueText = payload.content;
this.screensueCountdown = payload.duration;
this.screensueVisible = true;
if (this.screensueTimer >= 0) {
clearInterval(this.screensueTimer);
this.screensueTimer = -1;
}
if (payload.duration > 0) {
this.screensueTimer = setInterval(() => {
if (this.screensueCountdown <= 1) {
clearInterval(this.screensueTimer);
this.screensueTimer = -1;
this.screensueVisible = false;
} else {
this.screensueCountdown = this.screensueCountdown - 1;
}
}, 1000);
}
}
private closeScreensue(): void {
if (this.screensueTimer >= 0) {
clearInterval(this.screensueTimer);
this.screensueTimer = -1;
}
this.screensueVisible = false;
}
/** 主题翻转时同步系统栏前景/背景色,避免浅色页面配黑状态栏。 */
private onThemeChanged(): void {
this.syncSystemBar();
}
/** 通过窗口 avoid area 计算状态栏与底部手势区高度vp失败时保留默认值。 */
private resolveSafeArea(): void {
try {
const ctx = getContext(this) as common.UIAbilityContext;
window.getLastWindow(ctx).then((win: window.Window) => {
const prop = win.getWindowProperties();
const isLayoutFull = prop.isLayoutFullScreen === true;
if (!isLayoutFull) {
return; // 非全屏模式下系统自动避让
}
const density = display.getDefaultDisplaySync().densityPixels > 0
? display.getDefaultDisplaySync().densityPixels : 3;
const topArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
const bottomArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
if (topArea.topRect.height > 0) {
this.topInset = Math.ceil(topArea.topRect.height / density);
}
// 底部手势条:有值时取其高度的一半作为悬浮导航的下边距基准,最小 8 最大 24
if (bottomArea.bottomRect.height > 0) {
const h: number = Math.ceil(bottomArea.bottomRect.height / density);
this.bottomGesture = Math.min(24, Math.max(8, h));
} else {
this.bottomGesture = 16;
}
}).catch(() => {
// ignore, keep defaults
});
} catch (e) {
// ignore
}
AppStorage.set<number>('currentTab', 0);
}
private onTabChanged(): void {
AppStorage.set<number>('currentTab', this.currentTab);
}
private syncSystemBar(): void {
const dark: boolean = this.isDark;
const bg: string = dark ? '#000000' : '#F1F3F5';
const fg: string = dark ? '#FFFFFF' : '#191919';
try {
window.getLastWindow(getContext(this) as common.UIAbilityContext).then((win: window.Window) => {
win.setWindowSystemBarProperties({
statusBarColor: bg,
statusBarContentColor: fg,
navigationBarColor: bg,
});
win.setWindowBackgroundColor(bg);
}).catch(() => {
// ignore
});
} catch (e) {
// ignore
}
}
private palette(): ThemePalette {
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** 宽屏判定:窗口宽度 >= 600vp与 Navigation Auto 模式的分栏阈值一致) */
private updateWideScreen(w: number): void {
const wide: boolean = w >= WIDE_MIN_WIDTH;
if (wide !== this.isWide) {
AppStorage.setOrCreate<boolean>('isWideScreen', wide);
}
}
/**
* 统一的返回处理:把返回事件精确派发给【当前 Tab】的那个 Navigation 栈。
*
* 四个页面各自持有 Navigation宽屏需要"左一级右二级"),它们在 Swiper 里
* 同时存活,系统返回落到哪个栈并不确定 —— 这就是"有的页面无法使用返回手势"
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
*/
onBackPress(): boolean {
return handleBackPress(this.currentTab, this.isWide);
}
build() {
// alignContent Bottom悬浮导航停靠底部而不是被 Stack 居中。
// 导航胶囊用左右 6% padding 保持"宽度占 88% 居中"的原样视觉;
// 宽屏下胶囊自然被限制在左侧一级界面栏内,不会横跨二级界面。
Stack({ alignContent: Alignment.Bottom }) {
// 渐变氛围背景
GradientBackground()
// 页面内容Swiper 支持左右滑动切换;$$ 双向同步索引
Swiper(this.swiper) {
ChatPage()
PluginsPage()
DevicePage()
SettingsPage()
}
.index(this.currentTab)
.indicator(false)
.loop(false)
.duration(300)
.curve(Curve.FastOutSlowIn)
.vertical(false)
.cachedCount(2)
.disableSwipe(this.subPageOpen)
.onChange((idx: number) => {
this.currentTab = idx;
navBar.setVisible(true);
})
.width('100%')
.height('100%')
// top 让出状态栏bottom所有页面统一为0导航栏浮在内容之上
.padding({
top: this.topInset,
bottom: 0
})
// 悬浮气态玻璃底部导航(三层:半透明底 -> 高光渐变 -> 内容)
// 外层 Row 负责水平定位:窄屏居中;宽屏靠左,只落在一级界面栏范围内。
Row() {
Stack() {
// 层1玻璃底 —— 半透明背景(无 backdropBlur 以避免矩形模糊伪影)
Column()
.width('100%')
.height('100%')
.backgroundColor(this.palette().navBarBg)
// 层2玻璃高光渐变模拟光从上方照射的质感
Row()
.width('100%')
.height('100%')
.linearGradient({
direction: GradientDirection.Top,
colors: [
[this.palette().navBarGradientStart, 0.0],
[this.palette().navBarGradientEnd, 1.0],
],
})
// 层3导航项
Row({ space: 2 }) {
ForEach(this.tabs, (tab: TabItem, index: number) => {
RailTab({
icon: tab.icon,
label: tab.label,
active: this.currentTab === index,
accent: this.palette().accent,
muted: this.palette().textMuted,
})
.layoutWeight(1)
.onClick(() => {
this.currentTab = index;
navBar.setVisible(true);
})
}, (item: TabItem, index: number) => item.pageId + index.toString())
}
.width('100%')
.height(62)
.padding({ left: 14, right: 14 })
.alignItems(VerticalAlign.Center)
}
// 宽屏:胶囊宽度锁在左侧一级界面栏内(左右各留 24vp 余量)
.width(this.isWide ? WIDE_NAV_BAR_WIDTH - 48 : '88%')
.height(62)
.borderRadius(28)
.clip(true)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({
radius: 24,
color: this.palette().shadow,
offsetY: 8,
})
}
.width('100%')
.height(62)
.padding({ left: this.isWide ? 24 : 0 })
.justifyContent(this.isWide ? FlexAlign.Start : FlexAlign.Center)
.margin({ bottom: this.bottomGesture + 6 })
// 所有页面滑动时隐藏导航栏
.translate({ y: !this.navVisible ? 130 : 0 })
.opacity(!this.navVisible ? 0 : 1)
.animation({ duration: 400, curve: Curve.EaseOut })
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
.hitTestBehavior(HitTestMode.Transparent)
// screensue 悬浮层agent 推送给用户看的内容(置顶展示)
if (this.screensueVisible) {
Column() {
Row() {
Circle({ width: 8, height: 8 })
.fill(this.palette().accent)
Text('agent 推送')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.margin({ left: 8 })
Blank()
if (this.screensueCountdown > 0) {
Text(this.screensueCountdown.toString() + 's')
.fontSize(11)
.fontColor(this.palette().textMuted)
}
Text('关闭')
.fontSize(12)
.fontColor(this.palette().accent)
.padding({ left: 10, right: 2, top: 4, bottom: 4 })
.onClick(() => {
this.closeScreensue();
})
}
.width('100%')
.margin({ bottom: 10 })
Scroll() {
Text(this.screensueText)
.fontSize(15)
.fontColor(this.palette().textPrimary)
.width('100%')
}
.constraintSize({ maxHeight: 320 })
.scrollBar(BarState.Auto)
.align(Alignment.Top)
}
.width('86%')
.padding(18)
.borderRadius(18)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 })
}
}
.width('100%')
.height('100%')
.id(snapshotComponentId())
// 实时跟随窗口宽度:平板旋转、折叠展开、分屏、自由窗口拖拽都会回调,
// 比启动时查一次 display 更可靠(不会漏掉运行中的尺寸变化)。
.onAreaChange((oldValue: Area, newValue: Area) => {
this.updateWideScreen(newValue.width as number);
})
}
}

View File

@ -0,0 +1,941 @@
import { apiClient } from '../common/ApiClient';
import { userMessage, noConnectionMessage } from '../common/UserError';
import { handleNavOnScroll } from '../common/NavBarController';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants';
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
import { SettingsEditor } from '../components/SettingsEditor';
interface InstallBody {
url: string;
}
/** 二级页面标识:插件详情 */
const SUB_NONE: string = '';
const SUB_DETAIL: string = 'detail';
@Component
export struct PluginsPage {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp('navVisible') private navVisible: boolean = true;
@StorageProp('currentTab') private currentTab: number = 0;
/** 宽屏:左边插件列表(含底部导航栏),右边插件详情 */
@StorageProp('isWideScreen') private isWide: boolean = false;
@State plugins: PluginRow[] = [];
@State loading: boolean = false;
@State showInstallForm: boolean = false;
@State installUrl: string = '';
@State toastMsg: string = '';
@State toastIsError: boolean = false;
/** 当前查看详情的插件名,用于宽屏下高亮左侧列表项 */
@State activeName: string = '';
/** 详情页数据GET /plugins/{name} */
@State detail: PluginDetail = emptyPluginDetail();
@State detailLoading: boolean = false;
@State detailError: string = '';
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack();
aboutToAppear(): void {
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
registerNavStack(1, this.navStack, () => {
this.activeName = '';
});
this.loadPlugins();
}
aboutToDisappear(): void {
unregisterNavStack(1);
}
private palette(): ThemePalette {
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/**
* 数据源对齐 WebGUI renderPlugins
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools按 plugin 归属)
* - GET /plugins → 已安装外部插件元数据version/description 等)
* - GET /plugins/disabled → {disabled:[{name,...}]}
* 三方按名称合并去重排序。
*/
private async loadPlugins(): Promise<void> {
if (!apiClient.hasConnection()) {
return;
}
this.loading = true;
try {
// ---- kernel: loaded plugins + tool ownership ----
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
const kpRaw: Object | undefined = kernelObj['plugins'];
if (kpRaw !== undefined && kpRaw !== null) {
const kpArr: Object[] = kpRaw as Object[];
for (let i = 0; i < kpArr.length; i++) {
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length === 0) {
continue;
}
loadedMap.set(n, item['loaded'] as boolean ?? true);
}
}
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
const tRaw: Object | undefined = kernelObj['tools'];
if (tRaw !== undefined && tRaw !== null) {
const tArr: Object[] = tRaw as Object[];
for (let i = 0; i < tArr.length; i++) {
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
const tn: string = item['name'] as string ?? '';
const owner: string = item['plugin'] as string ?? '';
if (tn.length === 0 || owner.length === 0) {
continue;
}
let list: string[] | undefined = toolsByPlugin.get(owner);
if (list === undefined) {
list = [];
toolsByPlugin.set(owner, list);
}
// 每插件最多展示 8 个工具名,避免卡片过长
if (list.length < 8) {
list.push(tn);
}
}
}
// ---- installed external plugins metadata ----
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
try {
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
const bodyTrim = pResp.body.trim();
let arr: Object[] = [];
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
arr = JSON.parse(pResp.body) as Object[];
} else {
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
const rawList: Object = obj['plugins'] ?? obj['data'];
if (rawList !== undefined && rawList !== null) {
arr = rawList as Object[];
}
}
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length > 0) {
externalMeta.set(n, item);
}
}
} catch (e) {
// 外部列表失败不阻塞内置展示
}
// ---- disabled list ----
const disabledNames: Set<string> = new Set<string>();
try {
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
const dArr: Object | undefined = dObj['disabled'];
if (dArr !== undefined && dArr !== null) {
const items: Object[] = dArr as Object[];
for (let di = 0; di < items.length; di++) {
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
const dn: string = dItem['name'] as string ?? '';
if (dn.length > 0) {
disabledNames.add(dn);
}
}
}
} catch (e) {
// disabled endpoint may not exist; ignore
}
// ---- merge: allNames sorted与 GUI 一致)----
const allNames: Set<string> = new Set<string>();
loadedMap.forEach((v: boolean, k: string) => {
allNames.add(k);
});
externalMeta.forEach((v: Record<string, Object>, k: string) => {
allNames.add(k);
});
disabledNames.forEach((n: string) => {
allNames.add(n);
});
const names: string[] = Array.from(allNames);
names.sort();
const rows: PluginRow[] = [];
for (let i = 0; i < names.length; i++) {
const name: string = names[i];
const meta: Record<string, Object> | undefined = externalMeta.get(name);
const tools: string[] | undefined = toolsByPlugin.get(name);
const row: PluginRow = {
name: name,
loaded: loadedMap.get(name) ?? false,
disabled: disabledNames.has(name),
external: externalMeta.has(name),
version: meta !== undefined ? meta['version'] as string ?? '' : '',
description: meta !== undefined ? meta['description'] as string ?? '' : '',
tools: tools,
};
rows.push(row);
}
this.plugins = rows;
} catch (e) {
this.showToast(userMessage('plugins.load', e), true);
}
this.loading = false;
}
/** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */
private statusOf(plugin: PluginRow): string {
if (plugin.loaded && !plugin.disabled) {
return 'loaded'; // 已加载
}
if (plugin.loaded && plugin.disabled) {
return 'pending'; // 运行中(禁用待生效)
}
if (plugin.disabled) {
return 'disabled'; // 已禁用
}
return 'notloaded'; // 未加载
}
private async togglePlugin(plugin: PluginRow): Promise<void> {
const name: string = plugin.name;
const action: string = plugin.disabled ? 'enable' : 'disable';
try {
await apiClient.post('/plugins/' + name + '/' + action, null);
this.showToast('已' + (plugin.disabled ? '启用' : '禁用') + '插件: ' + name, false);
this.loadPlugins();
} catch (e) {
this.showToast(userMessage('plugins.toggle', e), true);
}
}
private async removePlugin(plugin: PluginRow): Promise<void> {
const name: string = plugin.name;
try {
await apiClient.request('/plugins/' + name, 'DELETE', '', 8000);
this.showToast('已卸载插件: ' + name, false);
this.loadPlugins();
} catch (e) {
this.showToast(userMessage('plugins.remove', e), true);
}
}
private async installPlugin(): Promise<void> {
const url: string = this.installUrl.trim();
if (url.length === 0) {
return;
}
try {
const bodyObj: InstallBody = { url: url };
// 后端没有 /plugins/install 这个路由:安装就是 POST /pluginsbody 带 url。
await apiClient.post('/plugins', bodyObj);
this.showToast('安装请求已发送', false);
this.installUrl = '';
this.showInstallForm = false;
this.loadPlugins();
} catch (e) {
this.showToast(userMessage('plugins.install', e), true);
}
}
// ===================== 二级页面:插件详情 =====================
/**
* 打开插件详情。
*
* 窄屏Stackpush 一层整屏覆盖,系统侧滑返回可退。
* 宽屏Split左栏列表常驻右栏换页而不叠栈。
*/
private openDetail(name: string): void {
this.activeName = name;
this.loadDetail(name);
if (this.isWide && this.navStack.size() > 0) {
this.navStack.replacePathByName(SUB_DETAIL, subPageParam(SUB_DETAIL), false);
} else {
this.navStack.pushPathByName(SUB_DETAIL, subPageParam(SUB_DETAIL), true);
}
}
private closeDetail(): void {
this.navStack.pop();
this.activeName = '';
}
/**
* GET /plugins/{name} —— 后端返回插件清单字段。
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
*/
private async loadDetail(name: string): Promise<void> {
this.detailLoading = true;
this.detailError = '';
try {
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const d: PluginDetail = emptyPluginDetail();
d.name = o['name'] as string ?? name;
d.version = o['version'] as string ?? '';
d.description = o['description'] as string ?? '';
d.author = o['author'] as string ?? '';
d.license = o['license'] as string ?? '';
d.homepage = o['homepage'] as string ?? '';
d.repository = o['repository'] as string ?? '';
d.entry = o['entry'] as string ?? '';
d.minVersion = o['min_version'] as string ?? '';
d.deprecated = o['deprecated'] as boolean ?? false;
d.tags = this.strArray(o['tags']);
d.files = this.strArray(o['files']);
this.detail = d;
} catch (e) {
// 内置插件没有清单,属于预期情况,不当成错误刷红
const row: PluginRow | null = this.findRow(name);
const d: PluginDetail = emptyPluginDetail();
d.name = name;
if (row !== null) {
d.version = row.version ?? '';
d.description = row.description ?? '';
}
this.detail = d;
if (row !== null && row.external) {
this.detailError = userMessage('plugins.detail', e);
}
}
this.detailLoading = false;
}
private strArray(raw: Object | undefined): string[] {
const out: string[] = [];
if (raw === undefined || raw === null) {
return out;
}
const arr: Object[] = raw as Object[];
for (let i = 0; i < arr.length; i++) {
const s: string = arr[i] as string ?? '';
if (s.length > 0) {
out.push(s);
}
}
return out;
}
private findRow(name: string): PluginRow | null {
for (let i = 0; i < this.plugins.length; i++) {
if (this.plugins[i].name === name) {
return this.plugins[i];
}
}
return null;
}
/** 详情页正在展示的那一行(供二级页面里的操作按钮使用) */
private activeRow(): PluginRow | null {
return this.findRow(this.activeName);
}
private async reloadPlugins(): Promise<void> {
try {
await apiClient.post('/plugins/reload', null);
this.showToast('插件已重载', false);
this.loadPlugins();
} catch (e) {
this.showToast(userMessage('plugins.reload', e), true);
}
}
private showToast(msg: string, isError: boolean): void {
this.toastMsg = msg;
this.toastIsError = isError;
setTimeout(() => {
this.toastMsg = '';
}, 2500);
}
build() {
// 宽屏(>=600vp走 SplitnavBar左栏 = 插件列表 + 底部导航栏)常驻,
// NavDestination右栏 = 插件详情)与之并列;窄屏则是整屏覆盖的二级页面。
Navigation(this.navStack) {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Scroll() {
Column() {
this.ListStates()
this.PluginList()
}
.width('100%')
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.align(Alignment.Top)
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state);
})
}
.width('100%')
.height('100%')
// 顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None触摸完全穿透到滚动区
PageTopBar({ title: '插件' })
// 悬浮操作区:安装(悬浮主按钮)+ 重载。
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
NavFloatOverlay({ tab: 1 }) {
if (this.showInstallForm) {
this.InstallForm()
}
NavFloatRow() {
FloatIconButton({
icon: $r('app.media.ic_reload'),
onTap: () => {
this.reloadPlugins();
},
})
Button() {
Image($r('app.media.ic_plus'))
.width(20)
.height(20)
.fillColor(Color.White)
}
.width(50)
.height(50)
.type(ButtonType.Circle)
.backgroundColor(this.palette().accent)
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
.onClick(() => {
this.showInstallForm = !this.showInstallForm;
})
}
}
this.Toast()
}
.width('100%')
.height('100%')
.backgroundColor(Color.Transparent)
}
.navDestination(this.SubDestination)
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
.navBarPosition(NavBarPosition.Start)
.navBarWidth(WIDE_NAV_BAR_WIDTH)
.minContentWidth(WIDE_MIN_CONTENT)
.hideTitleBar(true)
.hideToolBar(true)
.hideBackButton(true)
.width('100%')
.height('100%')
// Split 模式下 navBar 常驻,此回调不再触发;宽屏一律锁住主 Tab 横滑
.onNavBarStateChange((isVisible: boolean) => {
markSubPageOpen(this.isWide || !isVisible);
})
// 模式初始化与切换Split 右栏不能空白,自动选中第一个插件;
// 退回 Stack 时清栈,否则会残留一个整屏覆盖的详情页。
.onNavigationModeChange((mode: NavigationMode) => {
if (mode === NavigationMode.Split) {
markSubPageOpen(true);
if (this.navStack.size() === 0 && this.plugins.length > 0) {
this.openDetail(this.plugins[0].name);
}
} else {
this.navStack.clear(false);
this.activeName = '';
markSubPageOpen(false);
}
})
}
/** 二级页面路由表:插件详情 */
@Builder
SubDestination(name: string, param: object) {
NavDestination() {
if (name === SUB_DETAIL) {
SubPageLayer({
title: this.activeName.length > 0 ? this.activeName : '插件详情',
tab: 1,
onBack: () => {
this.closeDetail();
},
showRefresh: true,
onRefresh: () => {
this.loadDetail(this.activeName);
},
}) {
this.DetailContent()
}
}
}
.hideTitleBar(true)
.backgroundColor(Color.Transparent)
}
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
@Builder
InstallForm() {
Row() {
TextInput({ placeholder: '.hmap 包下载 URL', text: this.installUrl })
.layoutWeight(1)
.height(36)
.fontSize(14)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.onChange((v: string) => {
this.installUrl = v;
})
Button('安装')
.height(36)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor('#FFFFFF')
.margin({ left: 6 })
.onClick(() => {
this.installPlugin();
})
}
.width('100%')
.padding(10)
.margin({ bottom: 10 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_MD)
.border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
.alignItems(VerticalAlign.Center)
}
/** 加载中 / 未配置 / 空列表三种占位态 */
@Builder
ListStates() {
if (this.loading && this.plugins.length === 0) {
LoadingProgress()
.width(32)
.height(32)
.color(this.palette().accent)
.margin({ top: 40 })
}
if (!apiClient.hasConnection()) {
Text(noConnectionMessage())
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
}
if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) {
Text('暂无已加载插件')
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
}
}
/**
* 一级列表:每个插件一张紧凑卡(名称 + 状态徽标 + 右尖角)。
* 描述、工具清单、启停/卸载操作全部下沉到详情页 —— 列表只负责选择。
*/
@Builder
PluginList() {
ForEach(this.plugins, (plugin: PluginRow) => {
Row() {
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(plugin.name)
.fontSize(15)
.fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.activeName === plugin.name
? this.palette().accent : this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (plugin.version !== undefined && plugin.version.length > 0) {
Text('v' + plugin.version)
.fontSize(10)
.fontColor(this.palette().textMuted)
}
}
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
Row({ space: 6 }) {
Circle({ width: 6, height: 6 })
.fill(this.statusDotColor(plugin))
Text(this.rowSubtitle(plugin))
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Image($r('app.media.ic_chevron_right'))
.width(15)
.height(15)
.fillColor(this.activeName === plugin.name
? this.palette().accent : this.palette().textMuted)
.draggable(false)
}
.width('100%')
.padding(14)
.borderRadius(RADIUS_LG)
.backgroundColor(this.activeName === plugin.name
? this.palette().accentBg : this.palette().bgCard)
.border({
width: 1,
color: this.activeName === plugin.name
? this.palette().accent : this.palette().glassBorder,
})
.margin({ bottom: 10 })
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.openDetail(plugin.name);
})
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
}
/**
* 二级页面:插件详情。
* WebGUI 这里只有一个 JSON.stringify 的 <pre>
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 操作。
*/
@Builder
DetailContent() {
if (this.detailLoading) {
Row() {
LoadingProgress()
.width(26)
.height(26)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 30, bottom: 30 })
}
if (this.detailError.length > 0) {
Text(this.detailError)
.fontSize(12)
.fontColor(COLOR_ERROR)
.padding({ left: 4, bottom: 12 })
}
// 概览卡:名称、版本、状态徽标、描述
PlainCard({ caption: '概览' }) {
Row({ space: 8 }) {
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.detail.version.length > 0) {
Text('v' + this.detail.version)
.fontSize(12)
.fontColor(this.palette().textSecondary)
}
}
.width('100%')
.margin({ bottom: 10 })
this.DetailBadges()
if (this.detail.description.length > 0) {
Text(this.detail.description)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.width('100%')
.margin({ top: 10 })
}
}
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
if (this.hasManifest()) {
PlainCard({ caption: '清单' }) {
this.KvRow('作者', this.detail.author)
this.KvRow('许可证', this.detail.license)
this.KvRow('主页', this.detail.homepage)
this.KvRow('仓库', this.detail.repository)
this.KvRow('入口', this.detail.entry)
this.KvRow('最低内核版本', this.detail.minVersion)
}
}
if (this.detail.tags.length > 0) {
PlainCard({ caption: '标签' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.tags, (t: string) => {
Text(t)
.fontSize(10)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (t: string) => t)
}
}
}
this.DetailTools()
// 插件配置plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
if (this.activeName.length > 0) {
PlainCard({ caption: '插件配置' }) {
SettingsEditor({
prefix: 'plugin.' + this.activeName + '.',
emptyHint: '该插件没有暴露可配置项',
})
}
}
if (this.detail.files.length > 0) {
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
ForEach(this.detail.files, (f: string) => {
Text(f)
.fontSize(12)
.fontColor(this.palette().textSecondary)
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 4 })
}, (f: string) => f)
}
}
this.DetailActions()
}
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
@Builder
DetailBadges() {
Row({ space: 6 }) {
Circle({ width: 7, height: 7 })
.fill(this.activeStatusColor())
Text(this.detailStatusLine())
.fontSize(12)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
private detailStatusLine(): string {
const parts: string[] = [];
parts.push(this.activeStatusText());
parts.push(this.activeIsBuiltin() ? '内置' : '外部');
if (this.detail.deprecated) {
parts.push('已废弃');
}
const n: number = this.activeTools().length;
if (n > 0) {
parts.push(n.toString() + ' 个工具');
}
return parts.join(' · ');
}
/** 工具清单来自一级列表已合并的 kernel.tools按 plugin 归属) */
@Builder
DetailTools() {
if (this.activeTools().length > 0) {
PlainCard({ caption: '注册的工具 (' + this.activeTools().length.toString() + ')' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.activeTools(), (tool: string) => {
Text(tool)
.fontSize(11)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (tool: string) => tool)
}
}
}
}
@Builder
DetailActions() {
if (this.activeName.length > 0) {
PlainCard({ caption: '操作' }) {
Row() {
Button(this.activeIsDisabled() ? '启用' : '禁用')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({
width: 1,
color: this.activeIsDisabled()
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
})
.fontColor(this.activeIsDisabled()
? this.palette().textSecondary : '#D99A2B')
.onClick(() => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.togglePlugin(r);
}
})
Blank()
if (!this.activeIsBuiltin()) {
Button('卸载')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor(COLOR_ERROR)
.onClick(() => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.removePlugin(r);
}
})
}
}
.width('100%')
}
}
}
// ---- 详情页取值助手ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
private hasManifest(): boolean {
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
}
private activeIsBuiltin(): boolean {
const r: PluginRow | null = this.activeRow();
return r !== null ? !r.external : false;
}
private activeIsDisabled(): boolean {
const r: PluginRow | null = this.activeRow();
return r !== null ? r.disabled : false;
}
private activeTools(): string[] {
const r: PluginRow | null = this.activeRow();
if (r === null) {
return [];
}
return r.tools ?? [];
}
private activeStatusText(): string {
const r: PluginRow | null = this.activeRow();
return r !== null ? this.statusBadgeText(r) : '未加载';
}
private activeStatusColor(): string {
const r: PluginRow | null = this.activeRow();
return r !== null ? this.statusBadgeColor(r) : this.palette().textMuted;
}
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
@Builder
KvRow(label: string, value: string) {
if (value.length > 0) {
Row() {
Text(label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
Text(value)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 220 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
}
@Builder
Toast() {
if (this.toastMsg.length > 0) {
Row() {
Text(this.toastMsg)
.fontSize(13)
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
.border({
width: 1,
color: this.toastIsError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 20 })
.margin({ bottom: 166 })
}
}
/** 状态点颜色:绿=已加载,黄=待生效,红=已禁用,灰=未加载 */
private statusDotColor(plugin: PluginRow): string {
return this.statusBadgeColor(plugin);
}
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
private rowSubtitle(plugin: PluginRow): string {
const parts: string[] = [];
parts.push(this.statusBadgeText(plugin));
parts.push(plugin.external ? '外部' : '内置');
if (plugin.tools !== undefined && plugin.tools.length > 0) {
parts.push(plugin.tools.length.toString() + ' 工具');
}
return parts.join(' · ');
}
private statusBadgeText(plugin: PluginRow): string {
const s: string = this.statusOf(plugin);
if (s === 'loaded') {
return '已加载';
}
if (s === 'pending') {
return '待生效';
}
if (s === 'disabled') {
return '已禁用';
}
return '未加载';
}
private statusBadgeColor(plugin: PluginRow): string {
const s: string = this.statusOf(plugin);
if (s === 'loaded') {
return '#17A964';
}
if (s === 'pending') {
return '#D99A2B';
}
if (s === 'disabled') {
return '#E84026';
}
return this.palette().textMuted;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
{
"module": {
"name": "entry",
"type": "entry",
"description": "HomeAgent client entry module",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone",
"tablet"
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.GET_NETWORK_INFO"
}
],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "HomeAgent main entry",
"icon": "$media:app_icon",
"label": "$string:app_name",
"startWindowIcon": "$media:app_icon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"action.system.home"
]
}
]
}
]
}
}

View File

@ -0,0 +1,8 @@
{
"color": [
{
"name": "start_window_background",
"value": "#0B1020"
}
]
}

View File

@ -0,0 +1,8 @@
{
"string": [
{
"name": "app_name",
"value": "HomeAgent"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>

After

Width:  |  Height:  |  Size: 177 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M11 21h-1l1-7H7.5c-.88 0-.33-.75-.31-.78C8.48 10.94 10.42 7.54 13.01 3h1l-1 7h3.51c.4 0 .62.19.4.66C12.97 17.55 11 21 11 21z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M9 16.2l-3.5-3.5L4 14.2 9 19.2 20 8.2l-1.4-1.4z"/></svg>

After

Width:  |  Height:  |  Size: 164 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>

After

Width:  |  Height:  |  Size: 162 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>

After

Width:  |  Height:  |  Size: 161 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>

After

Width:  |  Height:  |  Size: 218 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M4 6h18V4H4c-1.1 0-2 .9-2 2v11H0v3h14v-3H4V6zm19 2h-6c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h6c.55 0 1-.45 1-1V9c0-.55-.45-1-1-1zm-1 9h-4v-7h4v7z"/></svg>

After

Width:  |  Height:  |  Size: 258 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zm0 5v5m0 3v.5"/></svg>

After

Width:  |  Height:  |  Size: 220 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2.5L17.5 8H14V4.5zM8 13h8v1.6H8V13zm0 3.4h8V18H8v-1.6zM8 9.6h4v1.6H8V9.6z"/></svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 14H4v-2.6l3.6-3.6 3 3 4.2-4.2L20 15v3zm0-5.3-5.2-5.2-4.2 4.2-3-3L4 13.2V6h16v6.7zM8.4 9.9a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4z"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zm0 2a7 7 0 0 0-5.3 11.5L17.5 6.7A7 7 0 0 0 12 5zm0 14a7 7 0 0 0 5.3-11.5L6.5 17.3A7 7 0 0 0 12 19z"/></svg>

After

Width:  |  Height:  |  Size: 251 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M9 2v5H7V2h2zm8 0v5h-2V2h2zM5 9h14v3a7 7 0 0 1-6 6.9V22h-2v-3.1A7 7 0 0 1 5 12V9zm2 2v1a5 5 0 0 0 10 0v-1H7z"/></svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5z"/></svg>

After

Width:  |  Height:  |  Size: 151 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3 12h3.3l2.1-5.4a1 1 0 0 1 1.9.1l2.6 9.1 1.9-4.4a1 1 0 0 1 .9-.6H21v2h-4.6l-2.6 6a1 1 0 0 1-1.9-.1L9.3 9.7 7.9 13.4a1 1 0 0 1-.9.6H3v-2z"/></svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>

After

Width:  |  Height:  |  Size: 332 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>

After

Width:  |  Height:  |  Size: 154 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 2c.8 5.2 4.8 9.2 10 10-5.2.8-9.2 4.8-10 10-.8-5.2-4.8-9.2-10-10 5.2-.8 9.2-4.8 10-10z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M7 7h10c.55 0 1 .45 1 1v8c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1V8c0-.55.45-1 1-1z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12l-4-3-4 3V5h8v9z"/></svg>

After

Width:  |  Height:  |  Size: 174 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 1h-8C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h8c1.38 0 2.5-1.12 2.5-2.5v-17C18 2.12 16.88 1 15.5 1zm-4.5 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z"/></svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8 17H6v-6h2v6zm4 0h-2V9h2v8zm4 0h-2v-4h2v4z"/></svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10zm1-17.93c3.94.49 7 3.85 7 7.93s-3.05 7.44-7 7.93V4.07z"/></svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@ -0,0 +1,5 @@
{
"src": [
"pages/Index"
]
}

View File

@ -0,0 +1,14 @@
{
"network-security-config": {
"domain-config": [
{
"cleartextTraffic": true,
"domain": {
"include-domains": [
"*"
]
}
}
]
}
}

View File

@ -0,0 +1,7 @@
{
"modelVersion": "5.0.0",
"dependencies": {
"@ohos/hvigor": "file:/opt/huawei/command-line-tools/hvigor/hvigor",
"@ohos/hvigor-ohos-plugin": "file:/opt/huawei/command-line-tools/hvigor/hvigor-ohos-plugin"
}
}

View File

@ -0,0 +1 @@
export { appTasks } from '@ohos/hvigor-ohos-plugin';

View File

@ -0,0 +1,28 @@
{
"meta": {
"stableOrder": true,
"enableUnifiedLockfile": false
},
"lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": {
"@ohos/hypium@1.0.21": "@ohos/hypium@1.0.21",
"@ycj3/streaming-markdown@^2.1.1": "@ycj3/streaming-markdown@2.1.1"
},
"packages": {
"@ohos/hypium@1.0.21": {
"name": "@ohos/hypium",
"version": "1.0.21",
"integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har",
"registryType": "ohpm"
},
"@ycj3/streaming-markdown@2.1.1": {
"name": "@ycj3/streaming-markdown",
"version": "2.1.1",
"integrity": "sha512-YLofL0X0wcQ5ZWSO1/cHUjK5F4hQpE2atygm1hs+w3bsaaVae3dHte7OMbcyZ3zcwFPswSEOa3A4+geeIIMXAg==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@ycj3/streaming-markdown/-/streaming-markdown-2.1.1.har",
"registryType": "ohpm"
}
}
}

View File

@ -0,0 +1,16 @@
{
"name": "homeagent",
"version": "1.0.0",
"modelVersion": "5.0.0",
"description": "HomeAgent HarmonyOS Client",
"main": "",
"author": "",
"license": "Apache-2.0",
"dependencies": {
"@ycj3/streaming-markdown": "^2.1.1"
},
"devDependencies": {
"@ohos/hypium": "1.0.21"
},
"dynamicDependencies": {}
}

99
cmd/ohos/README.md Normal file
View File

@ -0,0 +1,99 @@
# HomeAgent 鸿蒙客户端
HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebView 套壳)。
功能与 WebUI 对齐SSE 流式对话、工具调用卡片、思考过程折叠、附件上传预览、
设备桥、插件管理、设置编辑、宽屏双栏、深浅色主题。
## 工程结构
```
HomeAgent/
├── AppScope/ 应用级配置与图标
├── entry/src/main/
│ ├── ets/
│ │ ├── common/ 通信与全局状态
│ │ │ ├── ApiClient.ets REST 客户端X-API-Key 鉴权、超时、二进制附件)
│ │ │ ├── SseClient.ets SSE 长连接Last-Event-ID 断线续传)
│ │ │ ├── DeviceBridge.ets 设备桥:把本机能力暴露给 agent
│ │ │ ├── BridgeRouter.ets 桥请求路由
│ │ │ ├── BridgeCaps.ets 能力声明
│ │ │ ├── ConnStore.ets 连接配置持久化
│ │ │ ├── StatusStore.ets 运行状态缓存
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
│ │ │ └── UserError.ets 错误转人类可读文案
│ │ ├── components/ 可复用组件
│ │ │ ├── MarkdownView.ets 流式 Markdown增量渲染
│ │ │ ├── StaticMarkdown.ets 静态 Markdown历史消息一次成型
│ │ │ ├── Attachment.ets 附件卡片 + 详情
│ │ │ ├── StatusCards.ets 状态卡片
│ │ │ ├── SettingsEditor.ets 配置编辑器
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
│ │ │ ├── SubPage.ets 二级页容器
│ │ │ └── GradientBackground.ets
│ │ ├── model/Model.ets 共享类型定义
│ │ ├── pages/ 页面
│ │ │ ├── Index.ets Tab 容器(入口)
│ │ │ ├── ChatPage.ets 对话
│ │ │ ├── DevicePage.ets 设备
│ │ │ ├── PluginsPage.ets 插件
│ │ │ └── SettingsPage.ets 设置
│ │ └── entryability/EntryAbility.ets
│ ├── module.json5 权限、能力声明
│ └── resources/ 字符串、颜色、图标、页面路由表
├── build-profile.json5.example 构建/签名配置模板(复制后填本机签名材料)
└── oh-package.json5 依赖
```
## 编译
需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。
本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0`
1. **准备签名配置**`build-profile.json5` 含密码明文,未入库):
```bash
cd cmd/ohos/HomeAgent
cp build-profile.json5.example build-profile.json5
```
把 `REPLACE_WITH_YOUR_*` 换成本机 DevEco 生成的调试签名材料,
默认在 `~/.ohos/config/` 下(`.cer` / `.p7b` / `.p12` 三件套 + 两个密码)。
用 DevEco Studio 打开工程会自动生成,命令行可参考 `deveco-cli` 生成签名材料。
2. **构建 HAP**
```bash
# hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的
/path/to/command-line-tools/bin/hvigorw \
--mode module -p module=entry@default assembleHap --no-daemon
```
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
3. **安装到设备**
```bash
hdc install entry/build/default/outputs/default/entry-default-signed.hap
```
## 连接 homed
首次启动在「设置」里填:
- **服务地址**`http://<homed 主机>:8080`WebUI 插件监听端口)
- **API Key**homed 的 `plugin.webui.api_key`
客户端所有请求走 `<服务地址>/api/v1/*`,带 `X-API-Key` 头。
附件路径 `/files/` `/uploads/` 不带 `/api/v1` 前缀,同样携带鉴权头。
设备桥需要 homed 启用 `remotedevice` 插件(默认 9890
在「设备」页填 ws token 后本机能力即可被 agent 调用。
## 注意事项
- **聊天历史分页**:首屏只拉最新 `CHAT_PAGE_SIZE`40向上滚动触顶自动加载更早的。
服务端 `/chat/history` 支持 `limit` / `before` 游标;工具调用详情与思考内容完整下发不裁剪。
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
- 项目代码部分由 AI 辅助生成,改动请自行评估。