refactor: 全面改名为 HologramAgent + 提示词技术内容下沉到 skill/工具描述 + 修 21 处乱码

## ① 全面改名 HomeAgent → HologramAgent

仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:

- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
  plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
  目录内的 module/app 描述同步;**证书路径原样保留**
  (default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author

★ 必须保持原样的两处(改了会坏):
  · legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
    —— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
  · 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
    —— 那是 SDK 契约里的值,属独立仓库,本仓不改

## ② 修 21 处乱码(U+FFFD)

这些是历史编辑留下的编码破坏,全在注释里,例如:
  「赌注重下���遍」→「赌注重下一遍」
  「「不��滤」」  →「「不过滤」」
  「**待��退场**」→「**待退场**」

★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
  U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。

## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)

实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:

  【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
  【三层记忆】   1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)

收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":

  工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
  三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
           关系用 memory_recall / 成文知识用 knowledge_search

判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。

## 判据

prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
  黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
  向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)

变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
This commit is contained in:
JianFeeeee
2026-10-22 20:49:44 +08:00
parent 6bea130639
commit f256098c9f
204 changed files with 310 additions and 252 deletions

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": "HologramAgent HarmonyOS client entry module",
"main": "",
"author": "",
"license": "Apache-2.0",
"dependencies": {},
"devDependencies": {
"@ohos/hypium": "1.0.21"
}
}

View File

@ -0,0 +1,221 @@
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;
}
clearConnection(): void {
this.conn = null;
}
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,19 @@
import { bundleManager } from '@kit.AbilityKit';
/**
* 应用版本号:从 bundle 元数据读取,而不是在 .ets 里再抄一份。
*
* AppScope/app.json5 是版本的唯一来源(由 deploy/scripts/sync-client-versions.sh
* 与内核 internal/meta.Version 对齐)。在代码里再写一个字面量就是第二份真相,
* 实测已经漂过:app.json5 写 1.1.1、设备桥上又是一份 1.1.1,而内核早已 1.4.0。
* 设备桥上报 / deviceinfo 回显的真实安装包版本,应当来自同一个来源。
*/
export function appVersion(): string {
try {
const info: bundleManager.BundleInfo =
bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT);
return info.versionName;
} catch (e) {
return '';
}
}

View File

@ -0,0 +1,37 @@
/**
* 附件的字节获取与解码(网络 / 沙箱 I/O)。
*
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
* 这两条路由在后端是 requireWeb,但对 API Key 客户端同等放行,
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
*
* 从 components/Attachment.ets 抽出:缩略图与详情大图都走同一条解码路径。
*/
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { apiClient } from './ApiClient';
/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */
export 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;
}
}

View File

@ -0,0 +1,103 @@
/**
* 附件的解析与格式化(纯函数,无 UI、无平台 I/O)。
*
* 后端 Attachment 只有四个字段:type / url / size / name
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
*
* 从 components/Attachment.ets 抽出:附件卡与附件详情都要用这几个函数,
* 放在 common 里两边共用,也不必让 UI 文件承担这段纯逻辑。
*/
import { ChatAttachment } from '../model/Model';
/** 从后端 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 : '附件';
}
/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */
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();
}
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
export function sanitize(name: string): string {
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
if (s.length === 0) {
s = 'attachment';
}
return s;
}

View File

@ -0,0 +1,428 @@
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 { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
import { camera, cameraPicker } from '@kit.CameraKit';
import { fileIo, fileUri } from '@kit.CoreFileKit';
import { appVersion } from './AppVersion';
// ===== 能力结果 =====
/** 与 BridgeRouter 实际支持的本机命令保持一一对应。 */
export const LOCAL_DEVICE_CAPS: string[] = [
'status',
'deviceinfo',
'screensee',
'screensue',
'clipboardsee',
'clipboardsue',
'speakeruse',
'camerasue',
];
export interface CapResult {
status: string; // 'ok' | 'error'
output: string;
error: string;
// chunked 为 true 时表示结果**已由能力内部经二进制分块回传**(如录像),
// DeviceBridge 不要再发 cmd_result;否则网关会把后续分块挂在一条已完成的
// 请求上,或先用 cmd_result 结束、再来的 cmd_data_start 找不到归属。
chunked?: boolean;
}
interface DeviceStatusPayload {
device_id: string;
status: string;
hostname: string;
platform: string;
arch: string;
uptime: number;
}
interface DeviceDetails {
hostname: string;
platform: string;
arch: string;
os_release: string;
version: string;
cpus: number;
brand: string;
manufacturer: string;
model: string;
series: string;
sdk_api_version: number;
security_patch: string;
abi_list: string;
device_type: string;
}
interface DeviceInfoPayload {
device_id: string;
name: string;
kind: string;
caps: string[];
info: DeviceDetails;
}
const APP_STARTED_AT: number = Date.now();
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;
}
/**
* 二进制分块发送回调。由 BridgeRouter 注入(它持有 deviceBridge + reqId),
* BridgeCaps 因此不必 import DeviceBridge —— 否则 DeviceBridge 为取 CapResult
* 而 import BridgeCaps,两边成环。分层也更干净:能力实现不碰 socket。
*/
export type DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => void;
// chunkedResult:结果已由能力自己分块发出,不再回 cmd_result。
function chunkedResult(output: string): CapResult {
const r: CapResult = { status: 'ok', output: output, error: '', chunked: true };
return r;
}
// ===== screensee:截取本应用当前画面(前台时为整屏可见内容)=====
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
/** 根组件 id(Index 的根 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> {
let full: image.PixelMap | null = null;
let packer: image.ImagePacker | null = null;
try {
full = await captureScreenPixelMap();
const info: image.ImageInfo = await full.getImageInfo();
const maxW: number = 420;
const maxH: number = 640;
let scale: number = 1;
if (info.size.width > maxW) {
scale = maxW / info.size.width;
}
if (info.size.height * scale > maxH) {
scale = maxH / info.size.height;
}
if (scale < 1) {
await full.scale(scale, scale);
}
packer = image.createImagePacker();
const opt: image.PackingOption = { format: 'image/jpeg', quality: 55 };
const packed: ArrayBuffer = await packer.packing(full, opt);
const helper: util.Base64Helper = new util.Base64Helper();
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
if (b64.length > 950000) {
return errResult('当前画面数据过大,请稍后重试');
}
return okResult('data:image/jpeg;base64,' + b64);
} catch (e) {
return errResult('无法读取当前应用画面,请保持应用在前台后重试');
} finally {
if (packer !== null) {
packer.release();
}
if (full !== null) {
full.release();
}
}
}
// ===== camerasue:系统相机抓拍 =====
//
// 与桌面/CLI 端的实现路径不同:鸿蒙三方应用不能无界面地直接驱动摄像头
// (CameraKit 需要预览 surface + CAMERA 权限,且后台采集受限),能拿到
// “用户正在拍的这一张”的合规路径是系统相机选择器 cameraPicker —— 由系统
// 相机完成采集,本应用只取回结果文件。语义与桌面端一致:现在给 agent 拍一张。
//
// 结果落在应用沙箱(saveUri 指向 filesDir),不写系统媒体库,也就不需要
// READ_IMAGEVIDEO 这类受限权限。
const CAMERASUE_MAX_B64: number = 950000;
/** 录像回传上限:与网关 mediaDir 落盘模式配合,避免把设备内存/WS 打爆。 */
const CAMERASUE_MAX_VIDEO: number = 64 * 1024 * 1024;
/**
* camerasue 实现。
*
* 参数语义与 homeagent-cmdrun 的说明一致:无参数 = 抓拍单张;
* `<N秒>` = 录 N 秒视频。
*
* 视频为什么要走二进制分块:一段 10s 录像动辄数 MB,base64 后还要再膨胀
* 1/3,既撑爆模型上下文也撑爆 WS 单帧。cameraPicker 本身支持 VIDEO
* 模式(系统相机会直接进录像界面),取回文件后用 sendDataChunked 按
* cmd_data_start/分块/cmd_data_end 回传——网关侧聚合后落盘成文件,agent 拿路径。
* 这与 GUI/CLI 客户端的 camerasue 录像路径一致。
*/
export async function capCamerasue(context: common.UIAbilityContext,
rawArgs: string,
sendChunked: DataChunkSender | null): Promise<CapResult> {
const raw: string = rawArgs.trim();
let videoSeconds: number = 0;
if (raw.length > 0) {
const digits: RegExp = new RegExp('^\\d+$');
if (!digits.test(raw)) {
return errResult('camerasue 参数只接受纯数字秒数,如 camerasue 5');
}
videoSeconds = parseInt(raw, 10);
if (videoSeconds <= 0 || videoSeconds > 300) {
return errResult('录像时长需在 1~300 秒之间');
}
}
const isVideo: boolean = videoSeconds > 0;
const ext: string = isVideo ? '.mp4' : '.jpg';
const filePath: string = context.filesDir + '/camerasue_' + Date.now().toString() + ext;
try {
// cameraPicker 要求 saveUri 指向的文件存在且可写,先建空文件占位
const f: fileIo.File = fileIo.openSync(filePath,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
fileIo.closeSync(f);
} catch (e) {
return errResult('无法创建相机输出文件');
}
try {
const profile: cameraPicker.PickerProfile = {
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK,
saveUri: fileUri.getUriFromPath(filePath),
};
if (isVideo) {
profile.videoDuration = videoSeconds;
}
const mediaType: cameraPicker.PickerMediaType = isVideo
? cameraPicker.PickerMediaType.VIDEO
: cameraPicker.PickerMediaType.PHOTO;
const res: cameraPicker.PickerResult =
await cameraPicker.pick(context, [mediaType], profile);
if (res.resultCode !== 0 || res.resultUri.length === 0) {
return errResult(isVideo ? '未获取到录像(可能被取消)' : '未获取到照片(可能被取消)');
}
} catch (e) {
return errResult('相机不可用或未授权,请确认应用在前台并允许使用相机');
}
if (isVideo) {
return readAndSendVideo(filePath, sendChunked);
}
return readPhotoAsBase64(filePath);
}
/** 读回录像并以二进制分块回传;网关聚合后落盘,agent 拿文件路径。 */
function readAndSendVideo(filePath: string, sendChunked: DataChunkSender | null): CapResult {
let fd: number = -1;
try {
const stat: fileIo.Stat = fileIo.statSync(filePath);
if (stat.size <= 0) {
return errResult('录像文件为空,请重试');
}
if (stat.size > CAMERASUE_MAX_VIDEO) {
return errResult('录像文件过大(超过 64MB),请缩短时长');
}
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
fd = rf.fd;
fileIo.readSync(fd, buf);
fileIo.closeSync(rf);
fd = -1;
if (sendChunked === null) {
return errResult('录像回传通道未就绪,请重试');
}
sendChunked('camera_video', 'video/mp4', new Uint8Array(buf));
// 分块已代表本次请求的完整结果,DeviceBridge 不再回 cmd_result。
return chunkedResult('录像已回传(' + stat.size.toString() + ' 字节)');
} catch (e) {
if (fd >= 0) {
try { fileIo.closeSync(fd); } catch (ignore) {}
}
return errResult('录像读取失败,请重试');
}
}
/** 照片仍走小体积 base64 内联(图片不大,不必分块)。 */
function readPhotoAsBase64(filePath: string): CapResult {
let fd: number = -1;
try {
const stat: fileIo.Stat = fileIo.statSync(filePath);
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
fd = rf.fd;
fileIo.readSync(fd, buf);
fileIo.closeSync(rf);
fd = -1;
const helper: util.Base64Helper = new util.Base64Helper();
const b64: string = helper.encodeToStringSync(new Uint8Array(buf));
if (b64.length > CAMERASUE_MAX_B64) {
return errResult('照片数据过大,请降低分辨率后重试');
}
return okResult('data:image/jpeg;base64,' + b64);
} catch (e) {
if (fd >= 0) {
try { fileIo.closeSync(fd); } catch (ignore) {}
}
return errResult('照片读取失败,请重试');
}
}
// ===== clipboardsee / clipboardsue =====
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
/**
* READ_PASTEBOARD 是 user_grant 权限:仅在 agent 真正请求 clipboardsee 时弹出系统授权,
* 不在应用启动时抢先索权。已授权时系统会直接返回,不会重复打扰用户。
*/
async function ensureClipboardPermission(context: common.UIAbilityContext): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const result: PermissionRequestResult =
await atManager.requestPermissionsFromUser(context, CLIPBOARD_PERMISSIONS);
return result.authResults.length > 0 && result.authResults[0] === 0;
} catch (e) {
return false;
}
}
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
const granted: boolean = await ensureClipboardPermission(context);
if (!granted) {
return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试');
}
try {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const has: boolean = await clip.hasData();
if (!has) {
return okResult('');
}
const data: pasteboard.PasteData = await clip.getData();
const txt: string = data.getPrimaryText();
return okResult(txt ?? '');
} catch (e) {
return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权');
}
}
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('clipboard written');
} catch (e) {
return errResult('剪贴板写入失败,请保持应用在前台后重试');
}
}
// ===== speakeruse:TTS 朗读 =====
class TtsSession {
private engine: textToSpeech.TextToSpeechEngine | null = null;
async speak(text: string): Promise<CapResult> {
if (text.length > 4000) {
return errResult('朗读内容过长,请缩短到 4000 字以内');
}
try {
if (this.engine === null) {
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) {
return errResult('语音服务暂时不可用,请稍后重试');
}
}
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);
}
export function shutdownSpeakerUse(): void {
ttsSession.shutdown();
}
// ===== status / deviceinfo =====
export function capStatus(deviceId: string): CapResult {
const payload: DeviceStatusPayload = {
device_id: deviceId,
status: 'online',
hostname: 'ohos-phone',
platform: 'OpenHarmony',
arch: deviceInfo.abiList,
uptime: Math.floor((Date.now() - APP_STARTED_AT) / 1000),
};
return okResult(JSON.stringify(payload));
}
export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
const details: DeviceDetails = {
hostname: 'ohos-phone',
platform: 'OpenHarmony',
arch: deviceInfo.abiList,
os_release: deviceInfo.osFullName,
version: appVersion(),
cpus: 0,
brand: deviceInfo.brand,
manufacturer: deviceInfo.manufacture,
model: deviceInfo.productModel,
series: deviceInfo.productSeries,
sdk_api_version: deviceInfo.sdkApiVersion,
security_patch: deviceInfo.securityPatchTag,
abi_list: deviceInfo.abiList,
device_type: deviceInfo.deviceType,
};
const payload: DeviceInfoPayload = {
device_id: deviceId,
name: deviceName,
kind: 'ohos-phone',
caps: LOCAL_DEVICE_CAPS,
info: details,
};
return okResult(JSON.stringify(payload));
}

View File

@ -0,0 +1,173 @@
/**
* 设备桥协议:消息结构、帧构造与分块切片。
*
* 与 homed 的 remotedevice 插件对齐(internal/plugins/remotedevice)。
* 从 common/DeviceBridge.ets 抽出:这里只有"协议形状"和"帧怎么拼",
* 没有任何 socket、状态机与重连逻辑 —— 那些留在 DeviceBridgeClient 里。
*
* 注意:DeviceBridgeClient 的对外方法名与语义不因本文件而改变,
* 各 send* 方法仍是"拼帧 + 发出去"两步,只是第一步搬到了这里。
*/
import { CapResult } from './BridgeCaps';
import { appVersion } from './AppVersion';
// ===== 协议消息(与 remotedevice 插件对齐)=====
export interface HelloDeviceInfo {
hostname: string;
platform: string;
arch: string;
os_release: string;
version: string;
cpus: number;
}
export interface HelloDevice {
device_id: string;
name: string;
kind: string;
authorized: boolean;
caps: string[];
info: HelloDeviceInfo;
}
export interface HelloMessage {
op: string;
device: HelloDevice;
}
export interface BindMessage {
op: string;
device_id: string;
token: string;
}
export interface CmdReply {
op: string; // 'cmd_result'
req_id: string;
status: string;
output: string;
error: string;
}
export interface DataStartMessage {
op: string;
req_id: string;
kind: string;
mime: string;
total: number;
chunk_size: number;
}
export interface DataEndMessage {
op: string;
req_id: string;
status: string;
total?: number;
error?: string;
}
export const CHUNK_SIZE: number = 8192;
// ===== 命令处理器回调 =====
// 返回 CapResult;二进制大结果通过 dataHandler 分块回传。
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
// ===== 帧构造 =====
export function bridgeHelloFrame(deviceId: string, name: string, kind: string,
caps: string[], hostname: string,
authorized: boolean): string {
const info: HelloDeviceInfo = {
hostname: hostname,
platform: 'OpenHarmony',
arch: '',
os_release: '',
version: appVersion(),
cpus: 0,
};
const device: HelloDevice = {
device_id: deviceId,
name: name,
kind: kind,
authorized: authorized,
caps: caps,
info: info,
};
const hello: HelloMessage = { op: 'hello', device: device };
return JSON.stringify(hello);
}
export function bridgeBindFrame(deviceId: string, token: string): string {
const bind: BindMessage = {
op: 'bind',
device_id: deviceId,
token: token,
};
return JSON.stringify(bind);
}
export function bridgeResultFrame(reqId: string, status: string,
output: string, errMsg: string): string {
const result: CmdReply = {
op: 'cmd_result',
req_id: reqId,
status: status,
output: output,
error: errMsg,
};
return JSON.stringify(result);
}
export function bridgeDataStartFrame(reqId: string, kind: string, mime: string,
total: number): string {
const startMsg: DataStartMessage = {
op: 'cmd_data_start',
req_id: reqId,
kind: kind,
mime: mime,
total: total,
chunk_size: CHUNK_SIZE,
};
return JSON.stringify(startMsg);
}
export function bridgeDataEndFrame(reqId: string): string {
const endMsg: DataEndMessage = {
op: 'cmd_data_end',
req_id: reqId,
status: 'ok',
};
return JSON.stringify(endMsg);
}
export function bridgeEventFrame(deviceId: string, eventType: string, detail: string): string {
const payload: Record<string, string> = { 'detail': detail };
const msg: Record<string, Object> = {
'op': 'event',
'device_id': deviceId,
'type': eventType,
'payload': payload,
};
return JSON.stringify(msg);
}
export function bridgeStatusFrame(deviceId: string, status: string): string {
const msg: Record<string, Object> = {
'op': 'status',
'device_id': deviceId,
'status': status,
};
return JSON.stringify(msg);
}
/** 按 CHUNK_SIZE 切二进制;切片顺序即发送顺序。 */
export function bridgeChunkSlices(bytes: Uint8Array): Uint8Array[] {
const out: Uint8Array[] = [];
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
out.push(bytes.slice(off, end));
}
return out;
}

View File

@ -0,0 +1,148 @@
import { deviceBridge } from './DeviceBridge';
import {
CapResult,
DataChunkSender,
capScreensee,
capCamerasue,
capClipboardSee,
capClipboardsue,
capSpeakerUse,
capDeviceInfo,
capStatus,
} from './BridgeCaps';
import { parseScreensue, ScreensuePayload } from './ScreensueHtml';
import { connStore } from './ConnStore';
import { common } from '@kit.AbilityKit';
// screensue 展示回调由根 UI 注册:窄屏整页,宽屏右侧内容栏。
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[] {
let start: number = 0;
while (start < command.length && isCommandSpace(command.charAt(start))) {
start = start + 1;
}
let cmd: string = command.substring(start);
if (cmd.startsWith('homeagent-')) {
cmd = cmd.substring('homeagent-'.length);
}
let idx: number = -1;
for (let i: number = 0; i < cmd.length; i++) {
if (isCommandSpace(cmd.charAt(i))) {
idx = i;
break;
}
}
if (idx < 0) {
return [cmd];
}
const out: string[] = [cmd.substring(0, idx), cmd.substring(idx + 1)];
return out;
}
function isCommandSpace(ch: string): boolean {
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
}
function hasArgs(args: string): boolean {
return args.trim().length > 0;
}
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] : '';
if (name === 'screensee') {
if (hasArgs(args)) {
return errRes('screensee 不接受额外参数');
}
return capScreensee();
}
if (name === 'screensue') {
if (!hasArgs(args)) {
return errRes('screensue 需要展示内容');
}
const payload: ScreensuePayload = parseScreensue(args);
if (payload.content.length === 0) {
return errRes('screensue 需要展示内容');
}
if (screensueHandler !== null) {
screensueHandler(payload);
return okRes('内容已显示');
}
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
}
if (name === 'camerasue') {
if (appContext === null) {
return errRes('相机能力尚未就绪,请保持应用在前台后重试');
}
// 录像走二进制分块:把「往本请求回传字节」的能力注入能力实现,
// 避免 BridgeCaps 反向 import DeviceBridge 形成循环依赖。
const sender: DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => {
deviceBridge.sendDataChunked(reqId, kind, mime, bytes);
};
return capCamerasue(appContext, args, sender);
}
if (name === 'clipboardsee') {
if (hasArgs(args)) {
return errRes('clipboardsee 不接受额外参数');
}
if (appContext === null) {
return errRes('应用界面尚未就绪,请保持应用在前台后重试');
}
return capClipboardSee(appContext);
}
if (name === 'clipboardsue') {
if (!hasArgs(args)) {
return errRes('clipboardsue 需要写入文字');
}
return capClipboardsue(args);
}
if (name === 'speakeruse') {
if (!hasArgs(args)) {
return errRes('speakeruse 需要朗读文字');
}
return capSpeakerUse(args);
}
if (name === 'status') {
if (hasArgs(args)) {
return errRes('status 不接受额外参数');
}
return capStatus(connStore.getDeviceId());
}
if (name === 'deviceinfo') {
if (hasArgs(args)) {
return errRes('deviceinfo 不接受额外参数');
}
return capDeviceInfo(connStore.getDeviceId(), connStore.getDeviceName());
}
return errRes('不支持的本机能力:' + 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,223 @@
/**
* 聊天页的纯格式化/判定逻辑(无 UI 依赖)。
*
* 从 pages/ChatPage.ets 抽出:这些函数只吃数据吐字符串/布尔,
* 抽出来后气泡、工具卡、渠道头像三个组件可以共用同一份口径。
*/
import { ChatMessage, ToolCallInfo } from '../model/Model';
/**
* 由文件名后缀推断 Content-Type。
* 后端按 multipart 部件的 Content-Type 判定 image/file,
* 给错会让图片被当成普通文件(缩略图就没了)。
*/
export function mimeOf(name: string, isImage: boolean): string {
const i: number = name.lastIndexOf('.');
const ext: string = i >= 0 ? name.substring(i + 1).toLowerCase() : '';
if (ext === 'png') {
return 'image/png';
}
if (ext === 'jpg' || ext === 'jpeg') {
return 'image/jpeg';
}
if (ext === 'webp') {
return 'image/webp';
}
if (ext === 'gif') {
return 'image/gif';
}
if (ext === 'bmp') {
return 'image/bmp';
}
if (ext === 'heic' || ext === 'heif') {
return 'image/heic';
}
if (isImage) {
return 'image/jpeg';
}
if (ext === 'pdf') {
return 'application/pdf';
}
if (ext === 'txt' || ext === 'log' || ext === 'md') {
return 'text/plain';
}
if (ext === 'json') {
return 'application/json';
}
return 'application/octet-stream';
}
/** payload 字段可能是字符串、对象或数组,统一转成可展示文本。 */
export function stringifyField(raw: Object | undefined): string {
if (raw === undefined || raw === null) {
return '';
}
if (typeof raw === 'string') {
return raw as string;
}
try {
return JSON.stringify(raw);
} catch (e) {
return String(raw);
}
}
/**
* ForEach 键:消息结构变化即换键 → 旧气泡销毁重建 → @Builder 里的
* if 分支重新求值。这是 ArkUI V1 渲染模型决定的:ForEach 对相同键
* 只更新 @Prop/@Link 绑定,不重新执行 @Builder 体,所以
* 「思考卡/工具卡/附件」这些用 if 包裹的条件分支在首次渲染后
* 永远不会再次求值——气泡里的这些面板就永远不出现。
*
* 反过来,content_delta 不进 structSig:正文文本靠 MarkdownView
* 的 @Prop content 响应式更新,不重建气泡 → 流式渲染平滑。
* 实测 SSE 里 reasoning_delta 与 content_delta 不交错(思考阶段
* 先于输出阶段),所以思考期间重建气泡不会打断正文流式动画。
*/
export function structSig(msg: ChatMessage): string {
let s: string = msg.id.toString();
const rc: string | undefined = msg.reasoningContent;
s += '_r' + (rc !== undefined ? rc.length.toString() : '0');
s += '_ro' + (msg.reasoningOpen === true ? '1' : '0');
const tcs: ToolCallInfo[] | undefined = msg.toolCalls;
if (tcs !== undefined) {
s += '_t' + tcs.length.toString();
for (let i = 0; i < tcs.length; i++) {
const tc: ToolCallInfo = tcs[i];
s += '_' + (tc.status ?? '');
s += '_' + (tc.open === true ? 'o' : 'c');
s += '_' + (tc.args !== undefined ? tc.args.length.toString() : '0');
s += '_' + (tc.result !== undefined ? tc.result.length.toString() : '0');
s += '_' + (tc.plugin ?? '');
}
} else {
s += '_t0';
}
s += '_a' + (msg.attachment !== undefined ? '1' : '0');
s += '_src' + (msg.source ?? '');
s += '_f' + (msg.isFinal === true ? '1' : '0');
s += '_s' + (msg.isStreaming === true ? '1' : '0');
return s;
}
/** 折叠时也要能看出思考在增长:显示字数 */
export function reasoningLenLabel(msg: ChatMessage): string {
const rc: string | undefined = msg.reasoningContent;
if (rc === undefined || rc.length === 0) {
return '';
}
return rc.length.toString() + ' 字';
}
/**
* 是否仍在执行。
* 判据是 status 而不是 result:后端 status=ok 的工具也可能返回空串,
* 用 result 判断会让这类调用永远显示"调用中"。
*/
export function tcRunning(tc: ToolCallInfo): boolean {
const s: string | undefined = tc.status;
return s === undefined || s.length === 0 || s === 'running';
}
export function tcError(tc: ToolCallInfo): boolean {
return tc.status === 'denied' || tc.status === 'error';
}
/** 工具卡左侧色条(accent 由调用方从 palette 取) */
export function tcLeftColor(tc: ToolCallInfo, accent: string): string {
if (tcError(tc)) {
return '#DB3694';
}
if (tcRunning(tc)) {
return accent;
}
return 'rgba(23, 169, 100, 0.8)';
}
/** 工具卡状态图标颜色 */
export function tcIcoColor(tc: ToolCallInfo, accent: string): string {
if (tcError(tc)) {
return '#DB3694';
}
if (tcRunning(tc)) {
return accent;
}
return 'rgba(23, 169, 100, 0.9)';
}
export function tcStateLabel(tc: ToolCallInfo): string {
if (tc.status === 'denied') {
return '已拒绝';
}
if (tcRunning(tc)) {
return '调用中';
}
return '完成';
}
export function tcStateColor(tc: ToolCallInfo): string {
if (tc.status === 'denied') {
return '#FF9EC6';
}
if (tcRunning(tc)) {
return '#A3B8FF';
}
return '#6EE7A8';
}
/**
* 气泡最大宽度(相对 BubbleSlot 的宽度,即扣掉头像与间距后的真实可用宽)。
* 纯文本 78% 好看;但工具卡/思考卡是"面板",78% 会把里面的状态文字和
* 参数/结果压成一团(还会被 clip 切掉),所以带卡片时放宽到 92%。
*/
export function bubbleMaxWidth(msg: ChatMessage): string {
const hasPanels: boolean =
(msg.toolCalls !== undefined && msg.toolCalls.length > 0) ||
(msg.reasoningContent !== undefined && msg.reasoningContent.length > 0);
return hasPanels ? '92%' : '78%';
}
/**
* 是否"别处来的"消息。对齐 GUI 的 source !== 'webui' 判定,但多减一项:
* 本机自己发的消息在后端会被写成 webui/<device_id>,那仍然是"我发的",
* 不能当成渠道消息挂上别人的头像。
*/
export function isChannelMsg(source: string, deviceId: string): boolean {
if (source.length === 0 || source === 'webui') {
return false;
}
return source !== 'webui/' + deviceId;
}
/** 自己发的消息(右对齐、"我"头像):渠道消息即使 role=user 也不算 */
export function isSelfMsg(role: string, channel: boolean): boolean {
return role === 'user' && !channel;
}
/** 渠道名展示:webui/<id> 只显示 <id>,其余原样。 */
export function chanLabel(src: string): string {
if (src.startsWith('webui/')) {
return src.substring(6);
}
return src;
}
/** 渠道首字母(大写),用作头像文字。 */
export function chanLetter(src: string): string {
const label: string = chanLabel(src);
if (label.length === 0) {
return '?';
}
return label.substring(0, 1).toUpperCase();
}
/** 由渠道名散列出稳定色,避免每次渲染换色。 */
export function chanColor(src: string): string {
const label: string = chanLabel(src);
let h: number = 0;
for (let i = 0; i < label.length; i++) {
h = (h * 31 + label.charCodeAt(i)) % 360;
}
return 'hsl(' + h.toString() + ', 52%, 46%)';
}

View File

@ -0,0 +1,119 @@
/**
* /chat/history 响应解析(无 UI 依赖)。
*
* 从 pages/ChatPage.ets 抽出:首屏与向上翻页共用同一套解析口径,
* 消息 id 由调用方提供的分配器给出(页面自己维护 id 计数器)。
*/
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
import { parseAttachment } from './AttachmentMeta';
import { stringifyField } from './ChatFormat';
/** 分页历史解析结果:消息列表 + 服务端分页元数据 */
export interface ParsedHistory {
msgs: ChatMessage[];
/** 本页首条在服务端全量历史中的下标,作为下次向上翻页的 before 游标 */
offset: number;
/** 服务端是否还有更早的历史 */
hasMore: boolean;
/**
* 服务端下发的增量游标(响应里的 last_seq)。
*
* 为什么必须带回来:/chat/history?after=<seq> 只回 seq 更大的消息,
* 客户端存下游标下次带上,才能只拿增量而不重新拉整页
* (jianf 说的“暴露数据查询 api,前端轮询后 patch 视图”那条路)。
* 缺了它就只能每次全量拉,也就无法发现“别人发来的新消息”。
*/
lastSeq: number;
}
/** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */
export function parseHistoryPayload(
obj: Record<string, Object>, alloc: () => number): ParsedHistory {
const rawList: Object | undefined = obj['messages'] as Object | undefined;
if (rawList === undefined || rawList === null) {
return { msgs: [], offset: 0, hasMore: false, lastSeq: 0 };
}
const arr: Object[] = rawList as Object[];
const msgs: ChatMessage[] = [];
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const role: string = item['role'] as string ?? '';
const content: string = item['content'] as string ?? '';
const att: ChatAttachment | undefined = parseAttachment(item['attachment']);
// 纯附件消息 content 可能为空,不能再按"无内容就丢弃"处理
if (role.length === 0 || (content.length === 0 && att === undefined)) {
continue;
}
const msg: ChatMessage = {
id: alloc(),
role: role,
content: content,
isFinal: true,
};
// seq:服务端单调递增序号,增量游标与 keyed 对账的定位符。
// 缺失(旧后端/本地乐观消息)时保持 undefined,不编造。
const seqVal: Object | undefined = item['seq'];
if (typeof seqVal === 'number' && (seqVal as number) > 0) {
msg.seq = seqVal as number;
}
if (att !== undefined) {
msg.attachment = att;
}
// 后端 handler.go 保证 history 不裁剪 reasoning_content / tool_calls,
// 这里必须还原,否则刷新后思考与工具卡就凭空消失。
const rc: string = item['reasoning_content'] as string ?? '';
if (rc.length > 0) {
msg.reasoningContent = rc;
}
const tcs: ToolCallInfo[] | undefined = parseHistoryToolCalls(item['tool_calls']);
if (tcs !== undefined) {
msg.toolCalls = tcs;
}
// 渠道/设备来源:后端 ChatMsg.source,用于区分 channel_output 等非 webui 消息
const src: string = item['source'] as string ?? '';
if (src.length > 0) {
msg.source = src;
}
msgs.push(msg);
}
const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0;
const hasMore: boolean = obj['has_more'] === true;
// last_seq:增量游标。缺失时回退到本页最大 seq,保证游标不会倒退。
let lastSeq: number = typeof obj['last_seq'] === 'number' ? obj['last_seq'] as number : 0;
for (let i = 0; i < msgs.length; i++) {
const sq: number | undefined = msgs[i].seq;
if (sq !== undefined && sq > lastSeq) {
lastSeq = sq;
}
}
return { msgs: msgs, offset: offset, hasMore: hasMore, lastSeq: lastSeq };
}
/** 后端 tool_calls 条目带 tool 和 name 两份;args/result 可能是对象也可能是字符串。 */
export function parseHistoryToolCalls(raw: Object | undefined): ToolCallInfo[] | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
const arr: Object[] = raw as Object[];
if (arr.length === 0) {
return undefined;
}
const tcs: ToolCallInfo[] = [];
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const name: string = (item['tool'] as string ?? '') || (item['name'] as string ?? '');
if (name.length === 0) {
continue;
}
const tc: ToolCallInfo = {
name: name,
args: stringifyField(item['args']),
result: stringifyField(item['result']),
status: item['status'] as string ?? undefined,
plugin: item['plugin'] as string ?? undefined,
};
tcs.push(tc);
}
return tcs.length > 0 ? tcs : undefined;
}

View File

@ -0,0 +1,206 @@
/**
* 发送 / 中断(无 UI 依赖)。
*
* 从 pages/ChatPage.ets 抽出:POST /chat 与 POST /chat/file 的请求体、
* 兜底消息合并、超时口径都收在这里,输入区组件只负责把文本/附件递进来。
*/
import { ChatMessage, ChatAttachment } from '../model/Model';
import { apiClient } from './ApiClient';
import { connStore } from './ConnStore';
import { userMessage, isTimeout } from './UserError';
import { chatStore } from './ChatStore';
import { parseAttachment } from './AttachmentMeta';
import { http } from '@kit.NetworkKit';
interface SendChatBody {
message: string;
client_msg_id: string;
/** 非空时后端编码 source = "webui/<device_id>",agent 可见来源设备 */
device_id?: string;
device_name?: string;
}
/** POST /chat/interrupt 的请求体。 */
interface InterruptBody {
/** true = 停止(立即结束当前推理 + 短路已排队消息);false/省略 = 普通中断。 */
stop: boolean;
/** 可选:中断时附带给模型的一句话;停止时为 undefined。 */
message?: string;
}
/**
* 纯文本发送:POST /chat。
* 带附件的情况走 sendChatFile(后端收下附件后自己写会话并触发 agent)。
*/
export async function sendChatText(text: string): Promise<void> {
const trimmed: string = text.trim();
if (chatStore.isLoading()) {
return;
}
const cur = connStore.getCurrentConnection();
if (cur === null) {
return;
}
if (trimmed.length === 0) {
return;
}
// 重置 SSE 标记
chatStore.setSseActive(false);
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: trimmed };
chatStore.pushNew(userMsg);
chatStore.setLoading(true);
chatStore.setStage('等待 AI 回复...');
chatStore.forceRefresh();
chatStore.requestScroll();
const bodyObj: SendChatBody = {
message: trimmed,
client_msg_id: Date.now().toString(36),
// 必须带设备身份:后端没有 device_id 就把来源编码成 webui,
// agent 会以为消息来自网页端。device_id 非空时后端编码
// source = "webui/<device_id>" 并注入设备上下文。
device_id: connStore.ensureDeviceId(),
device_name: connStore.getDeviceName(),
};
// 如果 SSE 已连接,POST 作为触发器(响应由 SSE 推送渲染);
// 仅在 SSE 未推送内容时才用 POST 响应兜底创建消息。
try {
const resp = await apiClient.postWithTimeout('/chat', bodyObj, 120000);
// SSE 已经处理了响应,跳过 POST 消息创建
if (chatStore.sseActive()) {
chatStore.setLoading(false);
chatStore.setStage('');
chatStore.forceRefresh();
chatStore.requestScroll();
return;
}
const parsed: Record<string, string> = JSON.parse(resp.body) as Record<string, string>;
const respText: string = parsed['response'] ?? '(无响应)';
const reasoning: string = parsed['reasoning_content'] ?? '';
const last: ChatMessage | null = chatStore.lastMessage();
if (last !== null && last.role === 'assistant' && !last.isFinal) {
last.content = respText;
last.isFinal = true;
last.isStreaming = false;
if (reasoning.length > 0 && last.reasoningContent === undefined) {
last.reasoningContent = reasoning;
}
} else if (last !== null && last.role === 'assistant' && last.isFinal) {
// 已有最终消息,合并(不应发生,但防御性处理)
if (respText.length > last.content.length) {
last.content = respText;
}
} else {
const msg: ChatMessage = {
id: chatStore.allocId(),
role: 'assistant',
content: respText,
isFinal: true,
};
if (reasoning.length > 0) {
msg.reasoningContent = reasoning;
}
chatStore.pushNew(msg);
}
chatStore.setLoading(false);
chatStore.setStage('');
chatStore.forceRefresh();
chatStore.requestScroll();
} catch (e) {
// 超时通常意味着后端仍在生成,不算失败;其余一律显示人话,
// 原始错误只进 hilog(之前把 e.message 拼进 chatStage 会把
// "Failed to connect to the server."、内网地址直接摆到聊天流里)。
if (isTimeout(e)) {
chatStore.setStage('请求已发送,等待回复...');
} else {
chatStore.setStage(userMessage('chat.send', e));
}
chatStore.forceRefresh();
chatStore.requestScroll();
}
}
/**
* 带附件发送:POST /chat/file(multipart),字段与 WebGUI 一致。
* 后端收下后自身会把用户消息与附件写进会话并触发 agent,
* 回复照常从 SSE 过来,所以这里不再走 /chat。
*/
export async function sendChatFile(text: string, path: string, name: string,
size: number, isImage: boolean, mime: string): Promise<void> {
if (connStore.getCurrentConnection() === null) {
return;
}
const att: ChatAttachment = {
type: isImage ? 'image' : 'file',
// 本地待上传:先用沙箱路径预览,上传成功后替换成服务端 URL
url: 'file://' + path,
size: size,
name: name,
};
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: text };
userMsg.attachment = att;
chatStore.pushNew(userMsg);
chatStore.setLoading(true);
chatStore.setStage('正在上传附件...');
chatStore.setSseActive(false);
chatStore.forceRefresh();
chatStore.requestScroll();
const parts: http.MultiFormData[] = [
{ name: 'file', contentType: mime, remoteFileName: name, filePath: path },
{ name: 'message', contentType: 'text/plain', data: text },
{ name: 'client_msg_id', contentType: 'text/plain', data: Date.now().toString(36) },
{ name: 'device_id', contentType: 'text/plain', data: connStore.ensureDeviceId() },
{ name: 'device_name', contentType: 'text/plain', data: connStore.getDeviceName() },
];
try {
const resp = await apiClient.postMultipart('/chat/file', parts, 180000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const uploaded: ChatAttachment | undefined = parseAttachment(obj['file']);
if (uploaded !== undefined) {
userMsg.attachment = uploaded;
}
chatStore.setStage('等待 AI 回复...');
} catch (e) {
chatStore.setStage(userMessage('chat.upload', e));
chatStore.setLoading(false);
}
chatStore.forceRefresh();
chatStore.requestScroll();
}
/**
* 停止当前生成(停止按钮)。
*
* 发送 **`stop: true`**,与「带一句话的中断」区分开:
* - stop:true(无 message)= ①立即结束当前 LLM 推理(不重试);
* ②对停止那一刻已排队的消息,后端在 pre-action 逐个短路。
* - message 非空 = 普通中断,模型看到被打断的上下文 + 新输入。
*
* 为什么必须带 stop:此前这里 POST 的是 null(空 body),后端把空内容当成
* “无事发生”直接丢掉了——接口回 200 但生成继续跑到自然结束,也就是“按了没反应”。
* 带中文字段比空 body 多不了几个字节,就把语义说清楚了。
*/
export async function interruptChat(): Promise<void> {
if (connStore.getCurrentConnection() === null) {
return;
}
try {
const body: InterruptBody = { stop: true };
await apiClient.post('/chat/interrupt', body);
} catch (e) {
// 停止是“减少工作”的指令,失败不需打断用户;但状态必须复位,
// 否则按钮会一直停在“停止”态,用户以为没生效。
}
// 即时反馈:不等 SSE 的终态事件,先把本地忙态清掉。
// 若后端稍后真的推来终态,SSE 处理器会再刷一次(幂等)。
chatStore.setLoading(false);
chatStore.setStage('已停止');
chatStore.forceRefresh();
}

View File

@ -0,0 +1,213 @@
/**
* SSE 事件 → 聊天流状态的翻译层(无 UI 依赖)。
*
* 从 pages/ChatPage.ets 抽出。这里只认「事件帧」与「往状态里写什么」,
* 具体状态由 ChatStreamSink 提供 —— 这样本文件不必 import ChatStore,
* 两边不会形成 ArkTS 里被拒绝的模块循环依赖。
*/
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
import { attachmentFromChannelOutput } from './AttachmentMeta';
import { SseEvent } from './SseClient';
import { stringifyField } from './ChatFormat';
import { stageTrail } from './StageTrail';
import { hilog } from '@kit.PerformanceAnalysisKit';
/** 聊天流状态机对外暴露的最小写入面(由 common/ChatStore.ets 实现) */
export interface ChatStreamSink {
allocId(): number;
lastMessage(): ChatMessage | null;
/** 取/建"未定稿的助手消息" */
ensureAssistant(): ChatMessage;
/** 取/建同名未完成工具卡 */
ensureToolCall(name: string): ToolCallInfo;
/** 追加一条新消息并播入场动画 */
pushNew(msg: ChatMessage): void;
setLoading(v: boolean): void;
setStage(s: string): void;
setSseActive(v: boolean): void;
/** 防抖刷新(合并高频 delta) */
refresh(): void;
/** 请求滚到底 */
requestScroll(): void;
/** sync_required:补拉历史 */
reloadHistory(): void;
}
export function applyChatSse(ev: SseEvent, sink: ChatStreamSink): void {
try {
// 服务端 data 字段是完整 sdk.Event:{type, source, payload, timestamp}
// 业务字段全部在 payload 之下,历史实现直接读顶层导致流式/思考/工具调用全部失效。
const frame: Record<string, Object> = JSON.parse(ev.data) as Record<string, Object>;
const inner: Object | undefined = frame['payload'];
const payload: Record<string, Object> =
inner !== undefined && inner !== null ? inner as Record<string, Object> : frame;
const frameType: string = frame['type'] as string ?? '';
const type: string = ev.event.length > 0 ? ev.event : frameType;
// 诊断只记事件类型(内容可能含隐私,不落盘)
hilog.debug(0x0000, 'HologramAgent', 'sse %{public}s', type);
if (type === 'agent_output') {
const content: string = payload['content'] as string ?? '';
// channel_output 携带图片/文件:url/size/output_type 三个字段在 payload 顶层,
// 它是一条独立的附件消息,不能合并进上一条文本气泡。
const kind: string = payload['kind'] as string ?? '';
if (kind === 'channel_output') {
const att: ChatAttachment | undefined = attachmentFromChannelOutput(
payload['output_type'] as string ?? '',
payload['url'] as string ?? '',
payload['size'] as number ?? 0);
if (att !== undefined) {
const amsg: ChatMessage = {
id: sink.allocId(),
role: 'assistant',
content: content,
isFinal: true,
source: 'channel',
attachment: att,
};
sink.pushNew(amsg);
sink.setLoading(false);
sink.setStage('');
sink.setSseActive(false);
sink.refresh();
sink.requestScroll();
return;
}
}
const last: ChatMessage | null = sink.lastMessage();
if (last !== null && last.role === 'assistant' && !last.isFinal) {
last.content = content;
last.isFinal = true;
last.isStreaming = false;
} else if (last !== null && last.role === 'assistant' && last.isFinal) {
// POST 已经创建了最终消息,仅合并内容(如果有增量)
if (content.length > last.content.length) {
last.content = content;
}
} else {
const msg: ChatMessage = {
id: sink.allocId(),
role: 'assistant',
content: content,
isFinal: true,
};
sink.pushNew(msg);
}
sink.setLoading(false);
sink.setStage('');
sink.setSseActive(false);
sink.refresh();
sink.requestScroll();
} else if (type === 'reasoning') {
const rc: string = payload['content'] as string ?? '';
if (rc.length > 0) {
sink.setStage('AI 思考中...');
// 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
// 此时还没有"未完成的助手消息",必须新建一条,否则思考内容直接丢失。
const last: ChatMessage = sink.ensureAssistant();
last.reasoningContent = rc;
sink.refresh();
sink.requestScroll();
}
} else if (type === 'sync_required') {
// 断线重连时服务端要求补拉历史(ring 里没有可重放的聚合事件)
sink.reloadHistory();
} else if (type === 'system') {
// 内核级状态事件(目前唯一发布者是 plugin_crashed)。
//
// ★ 为何必须补上(2026-10-13 协议审计):该事件一直在发
// (internal/plugin/dynamic_proc.go:175,注释还写着「webui/诊断插件
// 可订阅」),但鸿蒙端从未处理 —— 插件崩溃对手机用户不可见。
if (payload['event'] === 'plugin_crashed') {
const nm: string = payload['plugin'] as string ?? '?';
// 用 stage 条告知(鸿蒙端 sink 没有 toast 能力;stage 是已有的轻量提示位)。
// 不用 setLoading(false):崩溃不影响本轮对话本身。
sink.setStage('插件 ' + nm + ' 异常退出,内核正在重启它');
sink.refresh();
}
} else if (type === 'error') {
// ★ 这里原本是 'agent_error' —— 那是一个**不存在的类型**(2026-10-13 审计)。
// events 包与 SDK 都没有这个常量,全仓无任何发布者,生产日志 0 次。
// 而真正的失败帧叫 'error'(见 webui/handler_chat.go 与 cli/plugin.go
// 都发 {type:"error", ...}),于是「本轮处理失败」提示根本没机会显示。
// 改用真实类型名。
sink.setLoading(false);
sink.setStage('本轮处理失败,请重试');
sink.refresh();
} else if (type === 'content_delta') {
const delta: string = payload['content'] as string ?? '';
if (delta.length > 0) {
sink.setSseActive(true);
const last: ChatMessage = sink.ensureAssistant();
last.content += delta;
sink.refresh();
sink.requestScroll();
}
} else if (type === 'reasoning_delta') {
const delta: string = payload['content'] as string ?? '';
if (delta.length > 0) {
sink.setSseActive(true);
sink.setStage('AI 思考中...');
const last: ChatMessage = sink.ensureAssistant();
if (last.reasoningContent === undefined) {
last.reasoningContent = '';
}
last.reasoningContent += delta;
sink.refresh();
}
} else if (type === 'tool_call') {
const toolName: string = payload['tool'] as string ?? '';
const toolStatus: string = payload['status'] as string ?? '';
const toolPlugin: string = payload['plugin'] as string ?? '';
if (toolName.length > 0) {
sink.setStage('工具调用: ' + toolName);
const target: ToolCallInfo = sink.ensureToolCall(toolName);
if (toolPlugin.length > 0) {
target.plugin = toolPlugin;
}
const argsText: string = stringifyField(payload['args']);
if (argsText.length > 0) {
target.args = argsText;
}
if (toolStatus.length > 0) {
// 后端只在工具执行【结束】时发 tool_call(status=ok/denied/interrupted),
// 所以拿到 status 就意味着这次调用已收尾,result 一并落卡。
target.status = toolStatus;
target.result = stringifyField(payload['result']);
} else {
target.status = 'running';
}
sink.refresh();
sink.requestScroll();
}
} else if (type === 'stage') {
const phase: string = payload['phase'] as string ?? '';
const channel: string = payload['channel'] as string ?? '';
const stageTool: string = payload['tool'] as string ?? '';
if (channel !== '_consolidation_') {
// 运行态面板的阶段管道靠这条轨迹活着:先喂轨迹,再管聊天侧的角标。
// 两者是独立消费者,轨迹不依赖任何聊天状态。
stageTrail.onStage(phase, stageTool);
if (phase === 'pre_action') {
sink.setStage('AI 思考中...');
} else if (phase === 'before_toolcall') {
sink.setStage('工具调用: ' + stageTool);
// 关键:tool_call 事件只在执行【结束】后才发,所以"调用中"这一态
// 必须由 before_toolcall 建卡,否则用户永远看不到工具正在跑。
if (stageTool.length > 0) {
const tc: ToolCallInfo = sink.ensureToolCall(stageTool);
if (tc.status === undefined) {
tc.status = 'running';
}
}
} else if (phase === 'before_output') {
sink.setStage('生成回复中...');
}
sink.refresh();
}
}
} catch (e) {
// ignore parse errors
}
}

View File

@ -0,0 +1,602 @@
/**
* 聊天流状态源(单例)。
*
* 从 pages/ChatPage.ets 抽出:消息数组、分页游标、"哪几条是新消息"、
* SSE 连接与历史拉取都属于同一个状态机;页面只剩渲染与输入。
*
* 为什么数组不进 AppStorage:StatusStore 已经踩过一次 —— 数组同步语义不可靠。
* 这里沿用同一套做法:数组留在 store 内部,标量走 AppStorage 广播,
* 另加一个自增版本号 K_CHAT_REV 通知订阅组件"重取一次快照"。
*
* 订阅组件的接法(见 components/ChatStream.ets):
* @StorageProp(K_CHAT_REV) @Watch('onRev') private rev: number = 0;
* onRev(): void { this.messages = chatStore.messages(); }
* ForEach 拿到的仍然是"每次刷新一个新数组引用",与拆分前
* (this.messages = this.messages.slice())的渲染语义完全一致。
*
* SSE 事件的翻译在 common/ChatSse.ets:本类实现它的 ChatStreamSink 接口,
* 依赖方向只有"ChatStore → ChatSse"一条,不构成循环。
*/
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
import { SseClient, SseEvent } from './SseClient';
import { connStore } from './ConnStore';
import { apiClient } from './ApiClient';
import { CHAT_PAGE_SIZE } from './Constants';
import { ParsedHistory, parseHistoryPayload } from './ChatHistory';
import { applyChatSse, ChatStreamSink } from './ChatSse';
// ===== AppStorage 键:页面/聊天流/输入区共用 =====
export const K_CHAT_REV: string = 'chatRev';
export const K_CHAT_SCROLL_REV: string = 'chatScrollRev';
export const K_CHAT_LOADING: string = 'chatBusy';
export const K_CHAT_STAGE: string = 'chatStageText';
export const K_CHAT_CONNECTED: string = 'chatSseUp';
const SSE_RECONNECT_MS: number = 5000;
/** 增量轮询间隔:与 WebUI 的 chatTicker 一致(3s)。 */
const CHAT_POLL_MS: number = 3000;
/**
* 把服务端来的消息并进本地列表,**按 seq 对账**(与 WebUI 的
* applyServerMessages 同口径)。
*
* 为什么不再按“正文内容”去重:那是本次调研确认的缺陷根因。同一个人把
* 同一句话发两次,或本地乐观消息与服务端回显内容相同时,内容比对会把
* 其中一条误判成重复而丢弃(“App 发出的消息不显示”就是这个表现)。
* seq 是服务端分配的唯一序号,才是可靠的定位符。
*
* 规则:
* - 服务端消息带 seq:本地已有同 seq → 原地更新(工具卡/最终文本是
* 原地改的,不产生新 seq,只靠 after 拿不到,必须靠尾部探测更新);
* 本地没有 → 追加。
* - 服务端消息无 seq(旧后端):退化为「本地末尾同角色同内容则认领」。
* - 本地无 seq 的乐观 user 消息:服务端回显同一句时被认领(补上 seq),
* 而不是重复出现——认领先匹配最后一条无 seq 的同类消息。
*
* tailOnly:只允许在末尾追加/更新,用于“尾部探测”(拉最新一条做原地更新),
* 避免把历史中间的消息插进来造成顺序错乱。
*/
function reconcileServerMsgs(local: ChatMessage[], incoming: ChatMessage[],
tailOnly: boolean, alloc: () => number): ChatMessage[] {
const out: ChatMessage[] = local.slice();
for (let i = 0; i < incoming.length; i++) {
const sm: ChatMessage = incoming[i];
const sq: number | undefined = sm.seq;
let found: number = -1;
if (sq !== undefined) {
// 从尾部往前找:新消息总在尾部,省掉全表扫描
for (let j = out.length - 1; j >= 0 && j >= out.length - 12; j--) {
if (out[j].seq === sq) {
found = j;
break;
}
}
}
if (found >= 0) {
// 原地更新:保留本地 id(组件按 id 复用,不重建气泡),
// 只覆盖服务端权威字段。
const prev: ChatMessage = out[found];
if (prev.content !== sm.content) {
prev.content = sm.content;
}
if (sm.reasoningContent !== undefined && prev.reasoningContent !== sm.reasoningContent) {
prev.reasoningContent = sm.reasoningContent;
}
if (sm.toolCalls !== undefined) {
prev.toolCalls = sm.toolCalls;
}
if (sm.attachment !== undefined) {
prev.attachment = sm.attachment;
}
if (sm.source !== undefined) {
prev.source = sm.source;
}
prev.isFinal = true;
prev.isStreaming = false;
continue;
}
if (tailOnly) {
// 尾部探测:只有比本地最后一条 seq 更大才有意义,否则忽略(它已在中间)
let maxLocalSeq: number = 0;
for (let j = 0; j < out.length; j++) {
const ls: number | undefined = out[j].seq;
if (ls !== undefined && ls > maxLocalSeq) {
maxLocalSeq = ls;
}
}
if (sq !== undefined && sq > maxLocalSeq) {
sm.id = alloc();
out.push(sm);
}
continue;
}
// 认领本地乐观消息:本地末尾尚未拿到 seq 的同类消息,视为它的回显。
if (sq !== undefined) {
let claimed: number = -1;
for (let j = out.length - 1; j >= 0; j--) {
const lm: ChatMessage = out[j];
if (lm.seq !== undefined) {
break;
}
if (lm.role === sm.role) {
claimed = j;
break;
}
}
if (claimed >= 0) {
const prev: ChatMessage = out[claimed];
prev.seq = sq;
prev.isFinal = true;
prev.isStreaming = false;
if (sm.source !== undefined) {
prev.source = sm.source;
}
if (sm.attachment !== undefined) {
prev.attachment = sm.attachment;
}
continue;
}
}
sm.id = alloc();
out.push(sm);
}
return out;
}
class ChatStore implements ChatStreamSink {
private msgs: ChatMessage[] = [];
private nextId: number = 1;
private refreshTimer: number = -1;
private reconnectTimer: number = -1;
/** 分页历史:当前已加载消息在服务端全量中的起始下标 */
private offset: number = 0;
/** 是否还有更早历史可向上加载 */
private hasEarlier: boolean = false;
private loadingOlder: boolean = false;
/** 正在为新消息播入场动画的 id */
private newIds: number[] = [];
// SSE 正在为当前轮次推送内容时置 true,阻止 POST 响应重复创建消息
private sseActiveForTurn: boolean = false;
/** 增量游标:本地已知的最大服务端 seq(对应 WebUI 的 state.chatLastSeq) */
private lastSeq: number = 0;
/** 增量轮询中进行中,避免重入 */
private polling: boolean = false;
private pollTimer: number = -1;
private sse: SseClient = new SseClient();
init(): void {
AppStorage.setOrCreate<boolean>(K_CHAT_LOADING, false);
AppStorage.setOrCreate<string>(K_CHAT_STAGE, '');
AppStorage.setOrCreate<boolean>(K_CHAT_CONNECTED, false);
AppStorage.setOrCreate<number>(K_CHAT_REV, 0);
AppStorage.setOrCreate<number>(K_CHAT_SCROLL_REV, 0);
}
// ===================== 读取 =====================
messages(): ChatMessage[] {
return this.msgs;
}
findMessage(id: number): ChatMessage | undefined {
for (let i = 0; i < this.msgs.length; i++) {
if (this.msgs[i].id === id) {
return this.msgs[i];
}
}
return undefined;
}
lastMessage(): ChatMessage | null {
if (this.msgs.length === 0) {
return null;
}
return this.msgs[this.msgs.length - 1];
}
isFresh(id: number): boolean {
return this.newIds.indexOf(id) >= 0;
}
hasMore(): boolean {
return this.hasEarlier;
}
isFetchingOlder(): boolean {
return this.loadingOlder;
}
isLoading(): boolean {
return AppStorage.get<boolean>(K_CHAT_LOADING) ?? false;
}
setLoading(v: boolean): void {
AppStorage.set<boolean>(K_CHAT_LOADING, v);
}
setStage(s: string): void {
AppStorage.set<string>(K_CHAT_STAGE, s);
}
setConnected(v: boolean): void {
AppStorage.set<boolean>(K_CHAT_CONNECTED, v);
}
sseActive(): boolean {
return this.sseActiveForTurn;
}
setSseActive(v: boolean): void {
this.sseActiveForTurn = v;
}
/** 聊天流里最后一个带附件的消息:宽屏进入 Split 时用它填充右栏 */
latestAttachment(): ChatAttachment | undefined {
for (let i = this.msgs.length - 1; i >= 0; i--) {
const a: ChatAttachment | undefined = this.msgs[i].attachment;
if (a !== undefined) {
return a;
}
}
return undefined;
}
// ===================== 列表变更 =====================
allocId(): number {
return this.nextId++;
}
/** 追加一条消息并播入场动画 */
pushNew(msg: ChatMessage): void {
this.msgs.push(msg);
this.markNew(msg.id);
}
/** 标记新消息,触发入场动画 */
markNew(msgId: number): void {
const arr: number[] = this.newIds.slice();
arr.push(msgId);
this.newIds = arr;
// 这里不切片也不广播:与拆分前一致,入场动画的开场交给紧随其后的
// refresh()(50ms 防抖后换新数组引用)那一次一起触发。
setTimeout(() => {
const idx: number = this.newIds.indexOf(msgId);
if (idx >= 0) {
const updated: number[] = this.newIds.slice();
updated.splice(idx, 1);
this.newIds = updated;
this.forceRefresh();
}
}, 250);
}
/**
* 取当前助手消息里名为 name 的未完成工具卡,没有就建一张。
* 顺带保证一定存在一条"未定稿的助手消息"来挂这些卡。
*/
ensureToolCall(name: string): ToolCallInfo {
const last: ChatMessage = this.ensureAssistant();
if (last.toolCalls === undefined) {
last.toolCalls = [];
}
for (let i = 0; i < last.toolCalls.length; i++) {
const t: ToolCallInfo = last.toolCalls[i];
// 只复用"仍在执行"的同名卡:同一轮里同名工具被多次调用时,
// 已完成的那张不能被后来的调用覆盖。
const st: string | undefined = t.status;
if (t.name === name && (st === undefined || st.length === 0 || st === 'running')) {
return t;
}
}
const created: ToolCallInfo = { name: name, args: '' };
last.toolCalls.push(created);
return created;
}
/**
* 保证存在一条"未定稿的助手消息",返回它。
* 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
* 此时还没有"未完成的助手消息",必须新建一条,否则内容直接丢失。
*/
ensureAssistant(): ChatMessage {
const last: ChatMessage | null = this.lastMessage();
if (last !== null && last.role === 'assistant' && last.isFinal !== true) {
return last;
}
const msg: ChatMessage = {
id: this.allocId(),
role: 'assistant',
content: '',
isStreaming: true,
isFinal: false,
};
this.pushNew(msg);
return msg;
}
/** 流式/高频变更的通知信号:让 ChatStream 滚到底 */
requestScroll(): void {
const cur: number = AppStorage.get<number>(K_CHAT_SCROLL_REV) ?? 0;
AppStorage.set<number>(K_CHAT_SCROLL_REV, cur + 1);
}
/** 防抖刷新:合并高频 SSE delta,最多 ~20fps */
refresh(): void {
if (this.refreshTimer >= 0) {
return;
}
this.refreshTimer = setTimeout(() => {
this.refreshTimer = -1;
this.msgs = this.msgs.slice();
this.bump();
}, 50);
}
/** 强制立即刷新(用于状态切换等需要即时响应的场景) */
forceRefresh(): void {
if (this.refreshTimer >= 0) {
clearTimeout(this.refreshTimer);
this.refreshTimer = -1;
}
this.msgs = this.msgs.slice();
this.bump();
}
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
bump(): void {
const cur: number = AppStorage.get<number>(K_CHAT_REV) ?? 0;
AppStorage.set<number>(K_CHAT_REV, cur + 1);
}
cancelRefresh(): void {
if (this.refreshTimer >= 0) {
clearTimeout(this.refreshTimer);
this.refreshTimer = -1;
}
}
// ===================== 折叠开关 =====================
reasoningOpen(msgId: number): boolean {
const m: ChatMessage | undefined = this.findMessage(msgId);
return m !== undefined && m.reasoningOpen === true;
}
setReasoningOpen(msgId: number, open: boolean): void {
const m: ChatMessage | undefined = this.findMessage(msgId);
if (m !== undefined) {
m.reasoningOpen = open;
}
}
toolOpen(msgId: number, index: number): boolean {
const m: ChatMessage | undefined = this.findMessage(msgId);
if (m === undefined || m.toolCalls === undefined || index >= m.toolCalls.length) {
return false;
}
return m.toolCalls[index].open === true;
}
setToolOpen(msgId: number, index: number, open: boolean): void {
const m: ChatMessage | undefined = this.findMessage(msgId);
if (m !== undefined && m.toolCalls !== undefined && index < m.toolCalls.length) {
m.toolCalls[index].open = open;
}
}
// ===================== 历史 =====================
private parseHistory(obj: Record<string, Object>): ParsedHistory {
return parseHistoryPayload(obj, () => this.allocId());
}
async loadHistory(): Promise<void> {
try {
// 分段懒加载:首屏只拉最新 CHAT_PAGE_SIZE 条,向上滚动触顶再拉更早的。
const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const parsed: ParsedHistory = this.parseHistory(obj);
// 首屏允许列表本来就是空的(全部加载失败/新会话):这里不做早退,
// 否则游标 lastSeq 永远建不起来,增量轮询也就起不来。
if (this.msgs.length === 0) {
this.msgs = parsed.msgs;
} else {
// 与本地未回显的消息按 seq 对账,而不是整表替换。
//
// 为什么:sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争;
// 若历史快照里还没有这条 user 消息,整表替换会让它凭空消失
// (“客户端侧发出的消息不显示”)。
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
() => this.allocId());
}
this.offset = parsed.offset;
this.hasEarlier = parsed.hasMore;
// 增量游标:首屏全量后据 last_seq 初始化,后续只拿增量。
if (parsed.lastSeq > this.lastSeq) {
this.lastSeq = parsed.lastSeq;
}
this.forceRefresh();
this.requestScroll();
this.startPolling();
} catch (e) {
// ignore history load failure
}
}
/**
* 增量轮询:只拉 seq 更大的消息,再补一次尾部探测。
*
* 这是 jianf 说的「接口调用方式改变」——后端 /chat/history 早已提供
* after=<seq> 游标(commit 9711177),WebUI 前端据此 3s 轮询增量并 patch
* 视图。鸿蒙端一直只做首屏全量加载,于是**其他端/其他渠道发来的消息
* 永远进不来**(页面不会加载新的聊天信息)。
*
* 尾部探测不可省:工具调用与最终文本是**原地改写**已有 seq 的记录,
* 不会产生新 seq,单靠 after 拿不到这些更新。
*/
async pollIncremental(): Promise<void> {
if (this.polling) {
return;
}
this.polling = true;
try {
if (this.lastSeq <= 0) {
// 游标还没建立(首屏没跑或失败):退回全量,交给 loadHistory 建游标。
this.polling = false;
await this.loadHistory();
return;
}
const resp = await apiClient.getWithTimeout(
'/chat/history?after=' + this.lastSeq, 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const parsed: ParsedHistory = this.parseHistory(obj);
let changed: boolean = false;
if (parsed.msgs.length > 0) {
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
() => this.allocId());
changed = true;
}
if (parsed.lastSeq > this.lastSeq) {
this.lastSeq = parsed.lastSeq;
}
// 尾部探测:拿最新一条做原地更新(工具卡/最终文本)。
try {
const tailResp = await apiClient.getWithTimeout('/chat/history?limit=1', 8000);
const tailObj: Record<string, Object> = JSON.parse(tailResp.body) as Record<string, Object>;
const tail: ParsedHistory = this.parseHistory(tailObj);
if (tail.msgs.length > 0) {
const before: number = this.msgs.length;
this.msgs = reconcileServerMsgs(this.msgs, tail.msgs, true,
() => this.allocId());
if (this.msgs.length !== before) {
changed = true;
}
}
} catch (e) {
// 尾部探测失败不影响增量结果
}
if (changed) {
this.forceRefresh();
}
} catch (e) {
// 轮询失败静默:下一拍会重试(SSE 仍在负责流式渲染)
} finally {
this.polling = false;
}
}
/** 起 3s 增量轮询(与 WebUI 的 chatTicker 同节奏)。重复调用无副作用。 */
startPolling(): void {
if (this.pollTimer >= 0) {
return;
}
this.pollTimer = setInterval(() => {
this.pollIncremental();
}, CHAT_POLL_MS);
}
stopPolling(): void {
if (this.pollTimer >= 0) {
clearInterval(this.pollTimer);
this.pollTimer = -1;
}
}
/**
* 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。
* 触顶(yOffset 接近 0)且有更早历史时由 onDidScroll 触发。
*/
async loadOlder(): Promise<void> {
if (this.loadingOlder || !this.hasEarlier) {
return;
}
this.loadingOlder = true;
try {
const before: number = this.offset;
if (before <= 0) {
this.hasEarlier = false;
return;
}
const resp = await apiClient.getWithTimeout(
'/chat/history?limit=' + CHAT_PAGE_SIZE + '&before=' + before, 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const parsed: ParsedHistory = this.parseHistory(obj);
if (parsed.msgs.length === 0) {
this.hasEarlier = false;
return;
}
// 前置插入新页(更早的在前),追加到当前列表头部;id 用新分配的避免与新消息撞号
this.msgs = parsed.msgs.concat(this.msgs);
this.offset = parsed.offset;
this.hasEarlier = parsed.hasMore;
this.bump();
} catch (e) {
// 失败保留 hasEarlier,允许下次滚动重试
} finally {
this.loadingOlder = false;
}
}
/** sync_required:断线重连后服务端要求补拉历史 */
reloadHistory(): void {
this.loadHistory();
}
// ===================== SSE 连接 =====================
connect(): void {
const cur = connStore.getCurrentConnection();
if (cur === null) {
return;
}
// 增量轮询与 SSE 同时拉起:SSE 负责 token 级流式观感,
// 轮询负责「界面最终状态」——两者是两条腿,缺一不可
// (轮询没接是“其他端/其他渠道的新消息永远不出现”的直接原因)。
// 放在这里而不是只放在 loadHistory 末尾:首屏加载失败时也要能自愈。
this.startPolling();
this.sse.close();
this.sse.connect(cur, '/chat/events',
(ev: SseEvent) => {
applyChatSse(ev, this);
},
() => {
this.setConnected(false);
this.scheduleReconnect();
},
() => {
this.setConnected(true);
}).catch(() => {
this.setConnected(false);
this.scheduleReconnect();
});
}
scheduleReconnect(): void {
if (this.reconnectTimer >= 0) {
return;
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = -1;
this.connect();
}, SSE_RECONNECT_MS);
}
cancelReconnect(): void {
if (this.reconnectTimer >= 0) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = -1;
}
}
/** 页面消失:断线、停表,避免后台空转 */
disconnect(): void {
this.cancelReconnect();
this.stopPolling();
this.sse.close();
this.cancelRefresh();
}
}
export const chatStore: ChatStore = new ChatStore();

View File

@ -0,0 +1,236 @@
import { preferences } from '@kit.ArkData';
import { Context } from '@kit.AbilityKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import { ConnectionConfig, AppSettings, emptySettings, defaultConnection, normalizeBaseUrl } 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 = [];
}
// 存量数据可能是旧版本存进去的裸域名或带尾斜杠的地址,读出来时一并规范化,
// 否则老用户升级后仍然会拼出错误的请求地址。
for (let i = 0; i < this.connections.length; i++) {
const c: ConnectionConfig = this.connections[i];
c.url = normalizeBaseUrl(c.url);
}
}
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();
}
}
}
// 不预置任何连接:地址与密钥属于用户私有配置,不能随源码分发。
// 连接列表为空时各页面走"尚未配置后端连接"的统一提示,用户到设置页添加。
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 = normalizeBaseUrl(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 = normalizeBaseUrl(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,359 @@
/**
* 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;
// ===== AppStorage 跨页面信号键 =====
//
// 未连接后端时的“入口可达性”靠这三个键串起来:聊天空态按钮 → 切主 Tab →
// 设置页打开连接二级页。不用组件回调是因为按钮与目标分属不同的 Swiper 子页,
// 中间还隔着 Index,逐层传回调会把两个无关页面耦在一起。
/** 当前是否已配置并激活后端连接(空态/入口的响应式判断) */
export const K_HAS_CONN: string = 'hasConn';
/** 外部请求切换主 Tab(-1 = 无请求),由 Index 监听 */
export const K_REQUESTED_TAB: string = 'requestedTab';
/** 请求设置页打开某个二级页(空串 = 无请求),由 SettingsPage 监听 */
export const K_SETTINGS_SUB: string = 'settingsSubRequest';
// ===== 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;
// ===== 动效时长(ms)=====
/**
* 统一动效节奏,避免每处各写一个魔数导致快慢不一。
*
* 取值依据:状态切换类交互(按下、展开、选中)控制在 150-250ms,
* 超过 300ms 会显得"拖";进出场大位移(导航栏收起、悬浮层)用 300-400ms
* 才不突兀。曲线统一 EaseOut:起步快、收尾缓,手感上更"跟手"。
*/
/** 按压反馈、图标旋转等即时反馈 */
export const ANIM_FAST: number = 150;
/** 展开/折叠、选中态迁移等常规状态切换 */
export const ANIM_NORMAL: number = 220;
/** 卡片入场、列表项出现 */
export const ANIM_ENTER: number = 280;
/** 导航栏收起、悬浮层进出等大位移 */
export const ANIM_SLOW: number = 400;
/** 按压态缩放:轻微下沉即可,过度缩放显廉价 */
export const PRESS_SCALE: number = 0.97;
/** 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,317 @@
import { webSocket } from '@kit.NetworkKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { CapResult } from './BridgeCaps';
import { BridgeCmdHandler, bridgeHelloFrame, bridgeBindFrame, bridgeResultFrame,
bridgeDataStartFrame, bridgeDataEndFrame, bridgeEventFrame, bridgeStatusFrame,
bridgeChunkSlices } from './BridgeProtocol';
export class DeviceBridgeClient {
private ws: webSocket.WebSocket = webSocket.createWebSocket();
private url: string = '';
private token: string = '';
private deviceId: string = '';
private name: string = 'HologramAgent OHOS';
private kind: string = 'phone';
private caps: string[] = [];
private hostname: string = 'ohos';
private connected: boolean = false;
private bound: boolean = false;
private manualClose: boolean = false;
private reconnectTimer: number = -1;
private connectionGeneration: number = 0;
private cmdHandler: BridgeCmdHandler | null = null;
private onStateChange: ((open: boolean) => void) | null = null;
isConnected(): boolean {
return this.connected && this.bound;
}
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> {
try {
this.ws.off('open');
this.ws.off('message');
this.ws.off('close');
this.ws.off('error');
this.ws.close().catch(() => {
// ignore stale socket close failure
});
} catch (e) {
// ignore stale socket cleanup failure
}
this.connectionGeneration = this.connectionGeneration + 1;
const generation: number = this.connectionGeneration;
const socket: webSocket.WebSocket = webSocket.createWebSocket();
this.ws = socket;
this.connected = false;
this.bound = false;
this.bindWsEvents(socket, authorized, generation);
const opts: webSocket.WebSocketRequestOptions = {
header: this.authHeader(),
};
try {
await socket.connect(this.url, opts);
} catch (e) {
if (generation === this.connectionGeneration && !this.manualClose) {
this.connected = false;
this.bound = false;
this.notifyState(false);
this.scheduleReconnect();
}
}
}
/** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */
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(socket: webSocket.WebSocket, authorized: boolean, generation: number): void {
socket.on('open', (err: Error, value: Object) => {
if (generation !== this.connectionGeneration || this.manualClose) {
socket.close().catch(() => {
// ignore stale socket close failure
});
return;
}
this.connected = true;
this.bound = false;
this.sendHello(authorized);
this.sendBind();
});
socket.on('message', (err: Error, value: string | ArrayBuffer) => {
if (generation === this.connectionGeneration && typeof value === 'string') {
this.handleTextFrame(value);
}
});
socket.on('close', (err: Error, value: webSocket.CloseResult) => {
this.handleSocketEnd(generation);
});
socket.on('error', (err: Error) => {
this.handleSocketEnd(generation);
});
}
private handleSocketEnd(generation: number): void {
if (generation !== this.connectionGeneration) {
return;
}
this.connected = false;
this.bound = false;
this.notifyState(false);
this.scheduleReconnect();
}
private notifyState(open: boolean): void {
if (this.onStateChange !== null) {
this.onStateChange(open);
}
}
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;
}
}
/** 更新本地授权状态并在已绑定连接上同步到服务端。 */
updateAuthorized(authorized: boolean): void {
this.lastAuthorized = authorized;
if (this.connected && this.bound) {
this.sendHello(authorized);
}
}
disconnect(): void {
this.manualClose = true;
this.connectionGeneration = this.connectionGeneration + 1;
this.cancelReconnect();
this.connected = false;
this.bound = 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
}
this.notifyState(false);
}
private sendHello(authorized: boolean): void {
this.lastAuthorized = authorized;
this.send(bridgeHelloFrame(this.deviceId, this.name, this.kind, this.caps,
this.hostname, authorized));
}
private sendBind(): void {
this.send(bridgeBindFrame(this.deviceId, this.token));
}
// ===== 命令处理 =====
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 === 'bind_ack') {
const accepted: boolean = obj['ok'] === true;
if (accepted && this.connected && !this.manualClose) {
this.bound = true;
this.cancelReconnect();
this.notifyState(true);
} else {
this.bound = false;
this.notifyState(false);
try {
this.ws.close().catch(() => {
// ignore bind rejection close failure
});
} catch (e) {
this.scheduleReconnect();
}
}
return;
}
if (op !== 'cmd') {
// ★★ 2026-10-13 协议审计:这里原本是静默 return,而服务端**会**发
// op=push / cmd_speech_start / cmd_speech_end / hello_ack。
// 本机(鸿蒙端)只做「被控设备」,不做输出通道,所以收到这些 op
// 属预期——但**静默丢弃**会让「服务端以为发出去了、本端却毫无痕迹」,
// 排查时只能对时序猜。至少留一行日志,让“收到了但不支持”与
// “根本没收到”可区分。
//
// 为何不报错回去:这些 op 不要求回执(服务端 pushToDevice 不等
// device→server 的应答),回一个错误帧反而会被当成未知帧处理。
if (op.length > 0) {
hilog.debug(0x0000, 'HologramAgent', 'device bridge: 忽略不支持的 op %{public}s', op);
}
return;
}
if (!this.bound) {
return;
}
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);
}
private dispatchCommand(reqId: string, command: string): void {
if (this.cmdHandler === null) {
this.sendResult(reqId, 'error', '', '本机能力尚未就绪,请保持应用在前台后重试');
return;
}
const handler: BridgeCmdHandler = this.cmdHandler;
handler(reqId, command).then((res: CapResult) => {
// res.chunked 时结果已由能力自己用二进制分块发完(如录像):
// 此时再发 cmd_result 会让网关把一条已完成请求与后续分块错配。
if (res.chunked === true) {
return;
}
this.sendResult(reqId, res.status, res.output, res.error);
}).catch((e: Object) => {
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
});
}
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
this.send(bridgeResultFrame(reqId, status, output, errMsg));
}
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
this.send(bridgeDataStartFrame(reqId, kind, mime, bytes.byteLength));
const chunks: Uint8Array[] = bridgeChunkSlices(bytes);
for (let i = 0; i < chunks.length; i++) {
const ab: ArrayBuffer = chunks[i].buffer as ArrayBuffer;
try {
this.ws.send(ab).catch(() => {
// ignore per-chunk failure; end frame reports error below
});
} catch (e) {
break;
}
}
this.send(bridgeDataEndFrame(reqId));
}
sendEvent(eventType: string, detail: string): void {
this.send(bridgeEventFrame(this.deviceId, eventType, detail));
}
sendStatus(status: string): void {
this.send(bridgeStatusFrame(this.deviceId, status));
}
send(text: string): void {
if (!this.connected) {
return;
}
this.ws.send(text).catch(() => {
// ignore
});
}
}
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();

View File

@ -0,0 +1,180 @@
import { common } from '@kit.AbilityKit';
import { http } from '@kit.NetworkKit';
import { deviceBridge } from './DeviceBridge';
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
import { connStore } from './ConnStore';
import { ConnectionConfig } from '../model/Model';
export { LOCAL_DEVICE_CAPS } from './BridgeCaps';
let bridgeStarting: boolean = false;
let foregroundActive: boolean = false;
let rootUIReady: boolean = false;
let bridgeGeneration: number = 0;
let stateTrackingReady: boolean = false;
function ensureBridgeStateTracking(): void {
if (stateTrackingReady) {
return;
}
stateTrackingReady = true;
AppStorage.setOrCreate<boolean>('deviceBridgeConnected', false);
deviceBridge.setStateListener((open: boolean) => {
AppStorage.set<boolean>('deviceBridgeConnected', open);
});
}
/**
* 把门户 HTTP 地址转换为同源设备桥 WebSocket 地址(**回退路径**)。
*
* 网关改造为子域反代后位于 devices.<基域名>,而基域名与子域标签都是**服务端
* 配置**,客户端拼不出来。正常路径是先调 discoverGateway() 问服务端;
* 本函数只在「服务端没有发现端点」(老版本 HologramAgent)时兜底,
* 保留旧部署的可用性。
*/
export function deviceGatewayUrl(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://')) {
rest = trimmed.substring('http://'.length);
} else if (trimmed.startsWith('wss://')) {
scheme = 'wss://';
rest = trimmed.substring('wss://'.length);
} else if (trimmed.startsWith('ws://')) {
rest = trimmed.substring('ws://'.length);
}
return scheme + rest + '/api/v1/device/ws';
}
/**
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab,
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
*/
/**
* 向门户询问设备网关的**权威地址**。
*
* 为什么必须问而不是自己拼:网关现在挂在 devices.<基域名> 子域上,基域名
* (webui.base_domain,默认 localhost)与子域标签(插件声明里可改)都在
* 服务端,客户端无从得知。服务端作答是唯一不会漂移的做法。
*
* 失败/老版本(404)不报错,返回空串让调用方回退到 deviceGatewayUrl()——
* 发现是增强而非必需。
*/
async function discoverGateway(portalUrl: string, apiKey: string): Promise<string> {
let base: string = portalUrl.trim();
if (base.length === 0) {
return '';
}
// 用户配置里可能填的是完整网关地址:截到门户根再拼发现路径
const cut: number = base.indexOf('/api/v1/');
if (cut >= 0) {
base = base.substring(0, cut);
}
while (base.length > 0 && base.charAt(base.length - 1) === '/') {
base = base.substring(0, base.length - 1);
}
try {
const r = await http.createHttp().request(base + '/api/v1/device/gateway', {
method: http.RequestMethod.GET,
header: { 'X-API-Key': apiKey } as Record<string, string>,
connectTimeout: 5000,
readTimeout: 5000,
});
if (r.responseCode !== 200) {
return '';
}
const body: string = typeof r.result === 'string' ? r.result : '';
const parsed: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
// 服务端明确报告不可用(没有声明设备网关反代)时不返回地址,
// 让调用方回退,而不是拿着一个连不上的 URL 反复重连。
if (parsed['available'] !== true) {
return '';
}
// 优先**门户同源形态**(url_portal:同一 host、同一端口)。
//
// 原因:子域形态 devices.<基域名> 依赖 DNS 解析,而 *.localhost 只有
// 浏览器内置该特例(RFC 6761)—— 应用内 HTTP/WS 客户端走系统解析器,
// 通常解析不到。门户同源形态无任何 DNS 依赖,永远可解析。
const portal: string = parsed['url_portal'] as string;
if (portal !== undefined && portal !== null && portal.length > 0) {
return portal;
}
const url: string = parsed['url'] as string;
return url === undefined || url === null ? '' : url;
} catch (e) {
// 网络失败 / 老版本无此端点:静默回退
return '';
}
}
/** 解析设备桥最终使用的网关地址:优先服务端发现,回退同源推导。 */
async function resolveGatewayUrl(portalUrl: string, apiKey: string): Promise<string> {
const discovered: string = await discoverGateway(portalUrl, apiKey);
if (discovered.length > 0) {
return discovered;
}
return deviceGatewayUrl(portalUrl);
}
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
foregroundActive = true;
setBridgeAppContext(context);
installCmdRouter();
ensureBridgeStateTracking();
if (!rootUIReady || deviceBridge.isConnected() || bridgeStarting) {
return;
}
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur === null || cur.url.length === 0 || cur.apiKey.length === 0) {
return;
}
bridgeStarting = true;
bridgeGeneration = bridgeGeneration + 1;
const generation: number = bridgeGeneration;
const deviceId: string = connStore.ensureDeviceId();
try {
const gatewayUrl: string = await resolveGatewayUrl(cur.url, cur.apiKey);
await deviceBridge.connect(
gatewayUrl, cur.apiKey, deviceId,
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
if (!foregroundActive || generation !== bridgeGeneration) {
deviceBridge.disconnect();
}
} catch (e) {
// DeviceBridge 自己会安排重连;前台启动不弹技术错误打扰用户。
}
if (generation === bridgeGeneration) {
bridgeStarting = false;
}
}
/** 根页面挂载完成后才连接,避免首条 screensue 到达时展示层尚未注册。 */
export function markForegroundBridgeUIReady(context: common.UIAbilityContext): void {
rootUIReady = true;
startForegroundBridge(context);
}
/** 后台不接受需要前台 UI/剪贴板授权的命令。 */
export function stopForegroundBridge(): void {
foregroundActive = false;
bridgeGeneration = bridgeGeneration + 1;
bridgeStarting = false;
shutdownSpeakerUse();
deviceBridge.disconnect();
}
/** 连接配置切换或修改后立即让设备桥使用新地址和 Token。 */
export async function restartForegroundBridge(context: common.UIAbilityContext): Promise<void> {
bridgeGeneration = bridgeGeneration + 1;
bridgeStarting = false;
deviceBridge.disconnect();
await startForegroundBridge(context);
}

View File

@ -0,0 +1,73 @@
/**
* 设备页的纯逻辑:本机 device_id 兜底与在线设备列表解析。
*
* 从 pages/DevicePage.ets 抽出(非 UI,可被其它页面/桥复用)。
*/
import { DeviceInfo } from '../model/Model';
import { connStore } from './ConnStore';
// ===== 二级页面路由 id(页面与一级入口列表共用)=====
export const SUB_NONE: string = '';
export const SUB_LOCAL: string = 'local';
export const SUB_CAPS: string = 'caps';
export const SUB_GATEWAY: string = 'gateway';
export const SUB_LIST: string = 'list';
/**
* 本机 device_id:桥里已有就用桥的,其次读持久化,都没有则生成一个并落盘。
* 生成后必须持久化,否则每次冷启动换 id,网关侧会累积成一堆幽灵设备。
*
* 判定顺序与原 DevicePage.aboutToAppear 一致:桥的 id 优先于持久化的 id。
*/
export function resolveDeviceId(bridgeId: string): string {
let id: string = bridgeId;
if (id.length === 0) {
id = connStore.getDeviceId();
}
if (id.length === 0) {
id = 'ohos-' + Date.now().toString(36);
try {
connStore.saveDeviceId(id);
} catch (e) {
// ignore persist failure
}
}
return id;
}
/**
* 解析 /device/online 响应体。
*
* apiClient 已自动前置 /api/v1,调用方只写其后的部分
* (否则会拼成 /api/v1/api/v1/device/online 并 404)。
*/
export function parseOnlineDevices(parsed: Record<string, Object>): DeviceInfo[] {
const devs: Object = parsed['devices'];
const list: DeviceInfo[] = [];
if (devs === undefined || devs === null) {
return list;
}
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);
}
return list;
}

View File

@ -0,0 +1,282 @@
/**
* Markdown 解析器(无 UI 依赖)。
*
* 从 components/StaticMarkdown.ets 抽出:解析与渲染分家后,
* 解析规则可以单独被复用/测试,渲染组件也回到可读长度。
*
* 覆盖:标题、段落、代码围栏、无序/有序列表、引用块、分隔线、表格,
* 以及行内的粗体/斜体/行内代码/链接。
*/
// ── 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;
}
/** 表格行 → 单元格数组(去掉首尾空串产生的空单元格)。 */
function parseTableRow(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;
}
// ── 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 headers: string[] = parseTableRow(tLines[0]);
const rows: string[][] = [];
for (let k = 1; k < tLines.length; k++) {
if (!isTableSep(tLines[k].trim())) {
rows.push(parseTableRow(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;
}

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,174 @@
/**
* 插件数据获取(无 UI 依赖)。
*
* 从 pages/PluginsPage.ets 抽出:三处接口的取数、合并口径与字段解析
* 都放在这里,页面只负责把结果落到 @State。
*
* 数据源对齐 WebGUI renderPlugins:
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools(按 plugin 归属)
* - GET /plugins → 已安装外部插件元数据(version/description 等)
* - GET /plugins/disabled → {disabled:[{name,...}]}
* 三方按名称合并去重排序。
*/
import { apiClient } from './ApiClient';
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
/** 拉取并合并插件列表;失败时抛错,由调用方转成人话提示。 */
export async function fetchPluginRows(): Promise<PluginRow[]> {
// ---- kernel: loaded plugins + tool ownership ----
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
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 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);
}
return rows;
}
function 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;
}
/**
* GET /plugins/{name} —— 后端返回插件清单字段。
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
* 内置插件不在 /plugins 里,取不到详情时由调用方退回列表已有信息。
*/
export async function fetchPluginDetail(name: string): Promise<PluginDetail> {
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 = strArray(o['tags']);
d.files = strArray(o['files']);
return d;
}

View File

@ -0,0 +1,78 @@
/**
* 插件状态展示口径(纯函数,无 UI 依赖)。
*
* 从 pages/PluginsPage.ets 抽出:列表行与详情页共用同一套状态判定/文案/配色,
* 抽成模块后两处不会再各写一份。
*
* 状态口径与 WebGUI 一致:已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。
*/
import { PluginRow } from '../model/Model';
/** 'loaded' | 'pending' | 'disabled' | 'notloaded' */
export function pluginStatusOf(plugin: PluginRow): string {
if (plugin.loaded && !plugin.disabled) {
return 'loaded'; // 已加载
}
if (plugin.loaded && plugin.disabled) {
return 'pending'; // 运行中(禁用待生效)
}
if (plugin.disabled) {
return 'disabled'; // 已禁用
}
return 'notloaded'; // 未加载
}
export function pluginStatusText(plugin: PluginRow): string {
const s: string = pluginStatusOf(plugin);
if (s === 'loaded') {
return '已加载';
}
if (s === 'pending') {
return '待生效';
}
if (s === 'disabled') {
return '已禁用';
}
return '未加载';
}
/** 状态点颜色;未加载态用主题里的弱化色(mutedColor 由调用方从 palette 取)。 */
export function pluginStatusColor(plugin: PluginRow, mutedColor: string): string {
const s: string = pluginStatusOf(plugin);
if (s === 'loaded') {
return '#17A964';
}
if (s === 'pending') {
return '#D99A2B';
}
if (s === 'disabled') {
return '#E84026';
}
return mutedColor;
}
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
export function pluginRowSubtitle(plugin: PluginRow): string {
const parts: string[] = [];
parts.push(pluginStatusText(plugin));
parts.push(plugin.external ? '外部' : '内置');
if (plugin.tools !== undefined && plugin.tools.length > 0) {
parts.push(plugin.tools.length.toString() + ' 工具');
}
return parts.join(' · ');
}
/** 详情页状态行:状态 + 内置/外部 + 已废弃 + 工具数(顺序与原实现一致) */
export function pluginDetailStatusLine(row: PluginRow, deprecated: boolean): string {
const parts: string[] = [];
parts.push(pluginStatusText(row));
parts.push(row.external ? '外部' : '内置');
if (deprecated) {
parts.push('已废弃');
}
if (row.tools !== undefined && row.tools.length > 0) {
parts.push(row.tools.length.toString() + ' 个工具');
}
return parts.join(' · ');
}

View File

@ -0,0 +1,182 @@
/**
* screensue 载荷解析与 HTML 渲染(无 UI 依赖)。
*
* 从 BridgeCaps.ets 抽出:加进 HTML 检测/编码后那个文件超过 520 行,
* 超出工程「单文件 ≤400 行」的约定;而「screensue 内容怎么解析、怎么渲染」
* 与「设备能力怎么实现」本就是两件事。
*/
import { util } from '@kit.ArkTS';
// ===== screensue 内容解析 =====
// 服务端协议: screensue [秒] <内容>;0=常驻。
export interface ScreensuePayload {
duration: number; // 秒;0 表示常驻直到用户关闭
content: string;
}
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
const leadingSpaces: RegExp = new RegExp('^\\s+');
const firstSpace: RegExp = new RegExp('\\s');
let rest: string = rawArgs.replace(leadingSpaces, '');
const splitAt: number = rest.search(firstSpace);
if (splitAt > 0) {
const first: string = rest.substring(0, splitAt);
const digits: RegExp = new RegExp('^\\d+$');
if (digits.test(first)) {
p.duration = Math.min(parseInt(first, 10), 86400);
rest = rest.substring(splitAt).replace(leadingSpaces, '');
}
}
p.content = rest;
return p;
}
/**
* 判断 agent 下发的 screensue 内容是不是 HTML。
*
* 服务端两侧协议都允许 HTML(localuse 的 local_screensue 在 Linux 用 browsh/w3m
* 渲染 HTML;remotedevice 的工具说明写的就是「显示内容/HTML」)。
*
* ★ 判据必须容忍前导杂质:实测 agent 常把整段文档连引号一起传进来
* (`'<html>…</html>'`),而"首个非空字符必须是 '<'"的旧判据直接判否、
* 退回纯文本渲染,用户看到的就是满屏标签源码(截图取证)。
*
* 所以这里扫到第一个「像标签开头」的 '<',不要求它在开头;但只有后面紧根
* 字母或 '/' 时才认,避免把 "a < b" 这类文本里的比较符当标签。
*/
export function looksLikeHtml(content: string): boolean {
return findHtmlStart(content) >= 0;
}
/** 找到第一个「像标签开头」的 '<';没有则 -1。 */
function findHtmlStart(content: string): number {
for (let i = 0; i < content.length; i++) {
if (content.charAt(i) !== '<') {
continue;
}
const next: string = i + 1 < content.length ? content.charAt(i + 1) : '';
if (next === '/') {
const after: string = i + 2 < content.length ? content.charAt(i + 2) : '';
if (isAsciiLetter(after)) {
return i;
}
continue;
}
if (isAsciiLetter(next)) {
return i;
}
}
return -1;
}
function isAsciiLetter(ch: string): boolean {
if (ch.length === 0) {
return false;
}
const c: number = ch.charCodeAt(0);
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
}
/**
* 取出真正的 HTML 片段:剥掉 agent 误带的包裹引号,再从头截到第一个标签。
*
* 剥引号是必须的:不剥的话那个孤立的 `'` 会被 Web 当正文渲染出来
* (截图上第一行就是它),而且它还会把后续判据带偏。返回 '' 表示不是 HTML。
*/
export function screensueHtmlDocument(content: string): string {
let body: string = content.trim();
// 反复剥成对的包裹引号(agent 把整段 HTML 当命令参数传时的常见形态)。
while (body.length >= 2) {
const first: string = body.charAt(0);
const last: string = body.charAt(body.length - 1);
if ((first === '\'' && last === '\'') || (first === '"' && last === '"')) {
body = body.substring(1, body.length - 1).trim();
continue;
}
break;
}
const idx: number = findHtmlStart(body);
if (idx < 0) {
return '';
}
if (idx > 0) {
body = body.substring(idx);
}
return body;
}
/**
* 把 screensue 内容编成可直接交给 Web 组件 `loadData` 的 base64。
*
* 为什么必须上 Web(不再用 RichText):RichText 只认极小标签子集,
* 对 <style>、CSS 动画、内联 SVG 一律不渲染 —— 实测 agent 推的是完整
* HTML 文档(含 @keyframes 与 <svg>),RichText 下只能看到源码。用户明确要求引入 webview。
*
* 为什么用 base64 而不是明文 loadData:encoding 非 base64 时按 URL 规则转义,
* 一个几 KB 的完整文档会撞上长度/转义问题;base64 是整篇加载的推荐方式,
* 中文与引号、'#' 也不会被二次转义(自己手写 UTF-8 编码,见 base64Utf8)。
*
* 片段(非完整文档)补一层 shell:加 <meta viewport> 让窄屏排版正确,
* 并注入主题前景色,避免深色主题下黑字不可见。返回 '' 表示不是 HTML(走纯文本渲染)。
*/
export function screensueWebData(content: string, dark: boolean): string {
const fragment: string = screensueHtmlDocument(content);
if (fragment.length === 0) {
return '';
}
if (hasHtmlShell(fragment)) {
// 已是完整文档:不再包壳,也不注入颜色(由页面自带样式决定)。
return base64Utf8(fragment);
}
const fg: string = dark ? '#E8ECF4' : '#1B2430';
const wrapped: string = '<!DOCTYPE html><html><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
+ '<style>html,body{margin:0;padding:0}'
+ 'body{padding:10px;color:' + fg + ';font-family:sans-serif;font-size:16px;'
+ 'line-height:1.6;word-break:break-word;-webkit-text-size-adjust:100%}'
+ 'img,svg,video{max-width:100%;height:auto}</style></head><body>'
+ fragment + '</body></html>';
return base64Utf8(wrapped);
}
/** 内容是否已是完整 HTML 文档(有 <html> 或 <!DOCTYPE>),不必再包壳。 */
function hasHtmlShell(s: string): boolean {
const head: string = s.substring(0, 400).toLowerCase();
return head.indexOf('<html') >= 0 || head.indexOf('<!doctype') >= 0;
}
/**
* UTF-8 字符串 → base64。
*
* 不能把 UTF-16 码元直接交给 Base64Helper:那样中文会变成乱码。
* 这里手写 UTF-8 字节序列(按码点,含代理对合成)后再编码。
*/
export function base64Utf8(s: string): string {
const bytes: number[] = [];
for (let i = 0; i < s.length; i++) {
let code: number = s.charCodeAt(i);
// 代理对(emoji 等)合成成一个码点。
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < s.length) {
const next: number = s.charCodeAt(i + 1);
if (next >= 0xDC00 && next <= 0xDFFF) {
code = ((code - 0xD800) << 10) + (next - 0xDC00) + 0x10000;
i++;
}
}
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
} else if (code < 0x10000) {
bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
} else {
bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F),
0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
}
}
const helper: util.Base64Helper = new util.Base64Helper();
return helper.encodeToStringSync(new Uint8Array(bytes));
}

View File

@ -0,0 +1,313 @@
/**
* 设置数据模型与纯解析逻辑(无 UI 依赖)。
*
* 从 pages/SettingsPage.ets 抽出:/settings 的响应解析、key 分区归类、
* 分页取项都是纯函数,页面只负责把结果落到 @State。
*/
/** One settings key card rendered in the editor list. */
export interface SettingEntry {
key: string;
displayName: string;
description: string;
type: string; // bool | int | duration | select | password | text | string
options: string[];
value: string; // raw string value as stored by backend
dirty: boolean;
}
export interface SettingsSection {
id: string;
title: string;
count: number;
}
export interface SettingMetaRaw {
key: string;
type: string;
displayName: string;
description: string;
category: string;
options: string[];
}
export const SETTINGS_PAGE_SIZE: number = 40;
/**
* 二级页面标识。
* 一级入口列表(components/SettingsRootEntries.ets)与页面路由表分开成文件后,
* 这些 id 必须只有一个来源 —— 否则改一处就会"点了没反应"。
*/
export const SUB_NONE: string = '';
export const SUB_STATUS: string = 'status';
export const SUB_CONNECTIONS: string = 'connections';
export const SUB_APPEARANCE: string = 'appearance';
export const SUB_BACKEND: string = 'backend';
export const SUB_SECTION: string = 'section';
/** /settings 响应解析结果 */
export interface ParsedSettings {
meta: Record<string, SettingMetaRaw>;
values: Record<string, string>;
}
/** 分区统计结果 */
export interface SectionStats {
sections: SettingsSection[];
/** plugin.* 配置项总数(编辑入口在插件详情页,这里只用于提示去向) */
pluginKeyCount: number;
/** 涉及的插件个数 */
pluginConfigCount: number;
}
/**
* 解析 GET /settings 响应:meta 定义 + 当前值。
* 值统一转成字符串(后端可能给 bool/number/嵌套对象)。
*/
export function parseSettingsPayload(body: string): ParsedSettings {
const obj: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
const metaStore: Record<string, SettingMetaRaw> = {};
const valuesStore: Record<string, string> = {};
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 optsArr: Object | undefined = item['options'];
const opts: string[] = [];
if (optsArr !== undefined && optsArr !== null) {
const oa: Object[] = optsArr as Object[];
for (let i = 0; i < oa.length; i++) {
opts.push(oa[i] as string);
}
}
const meta: 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,
};
metaStore[mk] = meta;
}
}
const rawVals: Object | undefined = obj['settings'];
if (rawVals !== undefined && rawVals !== null) {
const vObj: Record<string, Object> = rawVals as Record<string, Object>;
for (const vk of Object.keys(vObj)) {
if (vk.length === 0) {
continue;
}
const val: Object = vObj[vk];
let strVal: string;
if (typeof val === 'string') {
strVal = val as string;
} else if (typeof val === 'boolean' || typeof val === 'number') {
strVal = String(val);
} else {
strVal = JSON.stringify(val);
}
valuesStore[vk] = strVal;
}
}
const parsed: ParsedSettings = { meta: metaStore, values: valuesStore };
return parsed;
}
export function metaCategory(metaStore: Record<string, SettingMetaRaw>, key: string): string {
const m: SettingMetaRaw | undefined = metaStore[key];
return m !== undefined && m.category.length > 0 ? m.category : '';
}
export function categoryTitle(cat: string): string {
const map: Record<string, string> = {
'agent': '智能体',
'daemon': '守护进程',
'llm': '大模型',
'sources': '数据源',
'input': '输入',
'paths': '路径',
'resources': '资源',
'defaults': '默认值',
'snapshot': '快照',
'rollback': '回滚',
};
const t: string | undefined = map[cat];
return t !== undefined ? t : cat;
}
/**
* 把 key 归到分区:
* - core.* → 'core/<category>',展示在「核心」二级页下
* - plugin.* → 'plugin/<name>',仅用于计数;实际编辑在插件详情页里,
* 不在这里列出(否则同一批 key 会有两个入口)
* - 其余 → 'other'
*/
export function buildSections(
valuesStore: Record<string, string>, metaStore: Record<string, SettingMetaRaw>): SectionStats {
const ids: string[] = [];
const counts: Record<string, number> = {};
const titles: Record<string, string> = {};
let pluginKeys: number = 0;
const plugNames: string[] = [];
for (const key of Object.keys(valuesStore)) {
if (key.startsWith('plugin.')) {
// 插件配置不在这里列:它属于插件本身,入口在「插件 → 详情 → 插件配置」。
// 这里只统计,用于提示有多少项在那边。
pluginKeys = pluginKeys + 1;
const rest: string = key.substring('plugin.'.length);
const dot: number = rest.indexOf('.');
const plugName: string = dot > 0 ? rest.substring(0, dot) : rest;
if (plugName.length > 0 && plugNames.indexOf(plugName) < 0) {
plugNames.push(plugName);
}
continue;
}
let secId: string;
if (key.startsWith('core.')) {
const cat: string = metaCategory(metaStore, key);
secId = cat.length > 0 ? 'core/' + cat : 'core/misc';
if (titles[secId] === undefined) {
titles[secId] = cat.length > 0 ? categoryTitle(cat) : '未分类';
}
} else {
secId = 'other';
if (titles[secId] === undefined) {
titles[secId] = '其他';
}
}
if (counts[secId] === undefined) {
counts[secId] = 0;
ids.push(secId);
}
counts[secId] = counts[secId] + 1;
}
ids.sort((a: string, b: string): number => a.localeCompare(b));
const secs: SettingsSection[] = [];
for (const id of ids) {
secs.push({ id: id, title: titles[id] ?? id, count: counts[id] ?? 0 });
}
const stats: SectionStats = {
sections: secs,
pluginKeyCount: pluginKeys,
pluginConfigCount: plugNames.length,
};
return stats;
}
export function pickInitialSection(sections: SettingsSection[]): string {
for (let i = 0; i < sections.length; i++) {
if (sections[i].id === 'core/agent') {
return 'core/agent';
}
}
return sections.length > 0 ? sections[0].id : 'core';
}
export function keyInSection(
metaStore: Record<string, SettingMetaRaw>, key: string, secId: string): boolean {
if (secId === 'other') {
return !key.startsWith('core.') && !key.startsWith('plugin.');
}
if (secId.startsWith('core/')) {
if (!key.startsWith('core.')) {
return false;
}
const cat: string = secId.substring('core/'.length);
return cat === 'misc'
? metaCategory(metaStore, key).length === 0
: metaCategory(metaStore, key) === cat;
}
if (secId.startsWith('plugin/')) {
const p: string = secId.substring('plugin/'.length);
return key.startsWith('plugin.' + p + '.');
}
return false;
}
export function activeSectionTitle(sections: SettingsSection[], secId: string): string {
for (let i = 0; i < sections.length; i++) {
if (sections[i].id === secId) {
return sections[i].title;
}
}
return '配置项';
}
/** 某个分区的配置项(按 key 字典序,最多 PAGE_SIZE*4 项) */
export function buildEntries(
valuesStore: Record<string, string>,
metaStore: Record<string, SettingMetaRaw>,
secId: string): SettingEntry[] {
const entries: SettingEntry[] = [];
const keys: string[] = Object.keys(valuesStore).filter((k: string): boolean => {
return keyInSection(metaStore, k, secId);
});
keys.sort((a: string, b: string): number => a.localeCompare(b));
const limit: number = Math.min(keys.length, SETTINGS_PAGE_SIZE * 4);
for (let i = 0; i < limit; i++) {
const key: string = keys[i];
const meta: SettingMetaRaw | undefined = metaStore[key];
const entry: SettingEntry = {
key: key,
displayName: meta !== undefined && meta.displayName.length > 0 ? meta.displayName : key,
description: meta !== undefined ? meta.description : '',
type: meta !== undefined ? meta.type : 'string',
options: meta !== undefined ? meta.options : [],
value: valuesStore[key] ?? '',
dirty: false,
};
entries.push(entry);
}
return entries;
}
/** 覆盖某个 entry 的 value/dirty,返回新数组(保持 @State 数组替换语义) */
export function withEntryMarked(
entries: SettingEntry[], key: string, value: string, dirty: boolean): SettingEntry[] {
const next: SettingEntry[] = [];
for (let i = 0; i < entries.length; i++) {
const e: SettingEntry = entries[i];
if (e.key === key) {
const copy: SettingEntry = {
key: e.key,
displayName: e.displayName,
description: e.description,
type: e.type,
options: e.options,
value: value,
dirty: dirty,
};
next.push(copy);
} else {
next.push(e);
}
}
return next;
}
/**
* 从 picker 返回的 URI 里取图片后缀(带点)。
* Image 组件依赖后缀选择解码器;沙箱里存成无后缀文件会静默解码失败,
* 用户看到的就是"背景图设置了却不生效"。取不到后缀时兜底 .jpg。
*/
export function imageExt(uri: string): string {
let s: string = uri;
const q: number = s.indexOf('?');
if (q >= 0) {
s = s.substring(0, q);
}
const dot: number = s.lastIndexOf('.');
const slash: number = s.lastIndexOf('/');
if (dot > slash && dot < s.length - 1) {
const ext: string = s.substring(dot).toLowerCase();
if (ext.length <= 5) {
return ext;
}
}
return '.jpg';
}

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,180 @@
/**
* 阶段轨迹(单例):本轮对话在七个内核阶段里真实发生过什么。
*
* 由 ChatSse 在收到 `stage` 事件时喂入,运行态面板读取。
* 为什么不放进 StatusStore:轨迹来自 SSE 流、与 /status、/kernel 的轮询无关,
* 两者的生命周期和失败模式都不一样,混在一个数据源里会互相拖累
* (SSE 断连不该让状态卡变空,状态轮询失败也不该清掉轨迹)。
*
* 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
* 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
* 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
*/
/** 一个阶段组 */
export interface StageGroup {
/** 0 输入 / 1 行动 / 2 工具 / 3 输出 / 4 结束 */
group: number;
label: string;
en: string;
}
/** 轨迹里的一条事件 */
export interface StageEvent {
group: number;
/** 'stage' | 'tool' | 'output' */
kind: string;
label: string;
short: string;
count: number;
}
/** 阶段组定义,索引即 group */
export const STAGE_GROUPS: StageGroup[] = [
{ group: 0, label: '输入', en: 'in' },
{ group: 1, label: '行动', en: 'act' },
{ group: 2, label: '工具', en: 'tool' },
{ group: 3, label: '输出', en: 'out' },
{ group: 4, label: '结束', en: 'done' },
];
/** 当前阶段(SSE 驱动)。空串 = 空闲。 */
export const K_STAGE_PHASE: string = 'stagePhase';
/** 轨迹版本号:数组不进 AppStorage,靠它触发订阅组件重取快照 */
export const K_STAGE_REV: string = 'stageRev';
/** 阶段停留多久后回「空闲」——否则会留下一个永远停在 after_output 的假状态 */
const IDLE_AFTER_MS: number = 2500;
const MAX_ITEMS: number = 24;
/** rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名 */export function shortTool(name: string): string {
let n: string = name;
const i: number = n.lastIndexOf('__');
if (i >= 0) {
n = n.substring(i + 2);
}
return n.length > 14 ? n.substring(0, 13) + '…' : n;
}
class StageTrail {
private items: StageEvent[] = [];
private timerId: number = -1;
init(): void {
AppStorage.setOrCreate<string>(K_STAGE_PHASE, '');
AppStorage.setOrCreate<number>(K_STAGE_REV, 0);
}
/** 只给订阅组件读;调用方不要持有它 */
snapshot(): StageEvent[] {
return this.items;
}
currentPhase(): string {
return AppStorage.get<string>(K_STAGE_PHASE) ?? '';
}
/** 按阶段分组取条目 */
eventsOf(group: number): StageEvent[] {
const out: StageEvent[] = [];
for (let i = 0; i < this.items.length; i++) {
const e: StageEvent = this.items[i];
if (e.group === group) {
out.push(e);
}
}
return out;
}
/**
* 收到一条 stage 事件。phase 取值与内核一致:
* on_input / pre_action / post_action / before_toolcall / after_toolcall /
* before_output / after_output。
*/
onStage(phase: string, tool: string): void {
if (phase.length === 0) {
return;
}
if (phase === 'on_input') {
// 新的一轮:清空上一轮的轨迹
this.items = [];
this.push(0, 'stage', '输入', '输入');
} else if (phase === 'pre_action') {
this.push(1, 'stage', '组装上下文并思考', '思考');
} else if (phase === 'before_toolcall' && tool.length > 0) {
// output_* 是输出通道工具,与普通工具用不同配色区分
const kind: string = tool.indexOf('output_') === 0 ? 'output' : 'tool';
this.push(2, kind, tool, shortTool(tool));
} else if (phase === 'before_output') {
this.push(3, 'stage', '生成回复', '生成');
} else if (phase === 'after_output') {
this.push(4, 'stage', '本轮完成', '完成');
} else {
// post_action / after_toolcall 不单独记:它们与相邻格重复,
// 逐条记会把轨迹刷成噪音。
AppStorage.set<string>(K_STAGE_PHASE, phase);
this.armIdleTimer();
return;
}
AppStorage.set<string>(K_STAGE_PHASE, phase);
this.armIdleTimer();
this.bump();
}
private push(group: number, kind: string, label: string, short: string): void {
// 同一阶段重复出现的同一条(如同一工具连调 3 次)只累加计数,不刷屏
const n: number = this.items.length;
if (n > 0) {
const last: StageEvent = this.items[n - 1];
if (last.group === group && last.kind === kind && last.short === short) {
last.count = last.count + 1;
return;
}
}
this.items.push({ group: group, kind: kind, label: label, short: short, count: 1 });
if (this.items.length > MAX_ITEMS) {
this.items.shift();
}
}
private armIdleTimer(): void {
if (this.timerId !== -1) {
clearTimeout(this.timerId);
}
this.timerId = setTimeout(() => {
this.timerId = -1;
AppStorage.set<string>(K_STAGE_PHASE, '');
this.bump();
}, IDLE_AFTER_MS);
}
private bump(): void {
const cur: number = AppStorage.get<number>(K_STAGE_REV) ?? 0;
AppStorage.set<number>(K_STAGE_REV, cur + 1);
}
}
export const stageTrail: StageTrail = new StageTrail();
/**
* 内核的七个阶段归并到五个展示格。顺序与 sdk.Stage 一致,
* 所以「当前阶段」直接看返回值是不是当前格。空闲返回 -1。
*/
export function phaseGroup(phase: string): number {
if (phase === 'on_input') {
return 0;
}
if (phase === 'pre_action' || phase === 'post_action') {
return 1;
}
if (phase === 'before_toolcall' || phase === 'after_toolcall') {
return 2;
}
if (phase === 'before_output') {
return 3;
}
if (phase === 'after_output') {
return 4;
}
return -1;
}

View File

@ -0,0 +1,502 @@
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[];
}
// ===== 运行态快照(/runtime)=====
//
// 与 StatGroup 的分工:明细卡回答「内核有哪些东西、多少」,
// 运行态回答「现在在干什么」——四级中断队列积压多少、有几个驻留子。
// 两者数据源不同(/kernel vs /runtime),所以分开取、分开存。
/** 一条队列(四级中断之一,或排队队列) */
export interface RuntimeQueue {
/** 4/3/2/1;0 表示排队队列(无级别) */
lv: number;
name: string;
desc: string;
depth: number;
registered: number;
preempted: number;
}
/** 一次刷新的运行态快照 */
export interface RuntimeSnapshot {
/** 排队队列深度(无级别,纯 FIFO) */
ready: number;
pending: number;
stack: number;
maxStack: number;
subagents: number;
/** 格槽数(按全场最大深度缩放,至少 5) */
slots: number;
/** 五条队列:L4/L3/L2/L1 + 排队 */
queues: RuntimeQueue[];
}
interface LevelDef {
lv: number;
name: string;
desc: string;
}
/** 四级中断的定义(顺序即 L4→L1) */
const LEVEL_DEFS: LevelDef[] = [
{ lv: 4, name: 'L4', desc: '内核独占' },
{ lv: 3, name: 'L3', desc: '交互' },
{ lv: 2, name: 'L2', desc: '消息' },
{ lv: 1, name: 'L1', desc: '后台' },
];
/** JSON 数组里按下标取数:越界/类型不符都当 0 */
function numAt(arr: Object[] | undefined, i: number): number {
if (arr === undefined || arr === null || i < 0 || i >= arr.length) {
return 0;
}
const v: number = arr[i] as number;
return isNaN(v) ? 0 : v;
}
/** 格槽数:按全场最大深度缩放,至少 5 格(0 时也要有可见形状)、最多 16 格 */
function clampSlots(maxQ: number): number {
if (maxQ < 5) {
return 5;
}
return maxQ > 16 ? 16 : maxQ;
}
// ===== 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';
/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */
export const K_BUILD: string = 'statBuildSub';
class StatusStore {
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
private groups: StatGroup[] = [];
/** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */
private runtimeSnapshot: RuntimeSnapshot | undefined = undefined;
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);
AppStorage.setOrCreate<string>(K_BUILD, '');
}
getGroups(): StatGroup[] {
return this.groups;
}
getRuntime(): RuntimeSnapshot | undefined {
return this.runtimeSnapshot;
}
isUp(): boolean {
return AppStorage.get<boolean>(K_UP) ?? false;
}
async refresh(): Promise<void> {
if (!apiClient.hasConnection()) {
this.fail(noConnectionMessage());
return;
}
// setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。
// init() 已把所有键种好,刷新阶段必须用 set,否则摘要卡会永远停在
// 版本 "-"、插件 0 的初始状态。
AppStorage.set<boolean>(K_LOADING, true);
AppStorage.set<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.set<boolean>(K_UP, true);
AppStorage.set<string>(K_VERSION, version);
AppStorage.set<number>(K_AGENTS, agents);
AppStorage.set<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);
await this.collectRuntime();
this.groups = groups;
this.bump();
} catch (e) {
this.fail(userMessage('status.refresh', e));
}
AppStorage.set<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.set<number>(K_PLUGINS, pluginCount);
AppStorage.set<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) });
}
// 构建身份:版本 / commit / SDK 兼容都取自 /kernel 的 build(-ldflags 注入)。
// 为何不能只用 /status 的 version:那里只有一个版本号,分不清是哪个内核、
// 哪次构建;许可标识更是完全没有。
const build: Record<string, Object> | undefined = k['build'] as Record<string, Object>;
let lic: string = '';
let licURL: string = '';
let srcURL: string = '';
if (build !== undefined && build !== null) {
const bVer: string = build['version'] as string ?? '';
const bCommit: string = build['commit'] as string ?? '';
const bName: string = build['kernel_name'] as string ?? 'HologramAgent';
const bSdk: string = build['sdk_compatible'] as string ?? '';
const bTime: string = build['build_time'] as string ?? '';
lic = build['license'] as string ?? '';
licURL = build['license_url'] as string ?? '';
srcURL = build['source_url'] as string ?? '';
if (bVer.length > 0) {
AppStorage.set<string>(K_VERSION, 'v' + bVer);
kernelFields.push({ label: '内核版本', value: 'v' + bVer });
}
let sub: string = bName;
if (bCommit.length > 0 && bCommit !== 'unknown') {
sub = sub + ' · ' + bCommit.substring(0, 7);
}
AppStorage.set<string>(K_BUILD, sub);
if (bCommit.length > 0) {
kernelFields.push({ label: 'Commit', value: bCommit });
}
if (bSdk.length > 0) {
kernelFields.push({ label: 'SDK 兼容', value: bSdk });
}
if (bTime.length > 0 && bTime !== 'unknown') {
kernelFields.push({ label: '构建时间', value: formatTime(bTime) });
}
}
groups.push({ title: '内核', fields: kernelFields });
// 开源许可:只给一个源码链接、不写协议名,使用者看不出这受什么许可约束,
// 也看不出网络服务场景下的 §13 义务。
const legalFields: StatField[] = [];
if (lic.length > 0) {
legalFields.push({ label: '许可协议', value: lic });
}
if (licURL.length > 0) {
legalFields.push({ label: '协议全文', value: licURL });
}
if (srcURL.length > 0) {
legalFields.push({ label: '源码仓库', value: srcURL });
}
if (legalFields.length > 0) {
if (lic.toUpperCase().indexOf('AGPL') >= 0) {
legalFields.push({
label: '网络条款',
value: '把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径(§13)',
});
}
groups.push({ title: '开源许可', fields: legalFields });
}
// LLM:provider / 可用源 / 是否可用
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 的概览分组
}
}
/**
* 取运行态快照(/runtime)。失败不影响已经取到的明细分组:
* 旧后端可能没有这个端点,那时面板显示「运行态数据不可用」即可。
*/
private async collectRuntime(): Promise<void> {
try {
const resp = await apiClient.getWithTimeout('/runtime', 8000);
const r: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const sc: Record<string, Object> | undefined = r['scheduler'] as Record<string, Object>;
const residents: Object[] | undefined = r['residents'] as Object[];
let ready: number = 0;
let pending: number = 0;
let stack: number = 0;
let maxStack: number = 4;
let qArr: Object[] | undefined = undefined;
let regArr: Object[] | undefined = undefined;
let preArr: Object[] | undefined = undefined;
if (sc !== undefined && sc !== null) {
ready = sc['ready_queue_depth'] as number ?? 0;
pending = sc['pending_interrupts'] as number ?? 0;
stack = sc['suspend_stack'] as number ?? 0;
maxStack = sc['max_suspend_depth'] as number ?? 4;
qArr = sc['interrupt_queues'] as Object[];
regArr = sc['interrupts_by_level'] as Object[];
preArr = sc['preempts_by_level'] as Object[];
}
// 四级语义引用内核的定义(internal/agent/core/scheduler.go),
// 前端只负责把它们画出来,不自己起名字。
const queues: RuntimeQueue[] = [];
let maxQ: number = 1;
for (let i = 0; i < LEVEL_DEFS.length; i++) {
const d: LevelDef = LEVEL_DEFS[i];
const depth: number = numAt(qArr, d.lv);
if (depth > maxQ) {
maxQ = depth;
}
queues.push({
lv: d.lv,
name: d.name,
desc: d.desc,
depth: depth,
registered: numAt(regArr, d.lv),
preempted: numAt(preArr, d.lv),
});
}
if (ready > maxQ) {
maxQ = ready;
}
// 第五条:排队队列。它不是优先级,而是另一**类别**(排队 vs 中断),
// 所以 lv 用 0 标记「无级别」。
queues.push({
lv: 0,
name: '排队',
desc: 'FIFO',
depth: ready,
registered: 0,
preempted: 0,
});
const subagents: number = residents !== undefined && residents !== null
? residents.length : 0;
this.runtimeSnapshot = {
ready: ready,
pending: pending,
stack: stack,
maxStack: maxStack,
subagents: subagents,
slots: clampSlots(maxQ),
queues: queues,
};
} catch (e) {
// /runtime 不可用(旧后端):保留上一次快照,不清空
}
}
private fail(msg: string): void {
AppStorage.set<string>(K_ERR, msg);
AppStorage.set<boolean>(K_UP, false);
AppStorage.set<boolean>(K_LOADING, false);
this.groups = [];
this.runtimeSnapshot = undefined;
this.bump();
}
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
private bump(): void {
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
AppStorage.set<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 HologramAgent` 排查,不进 UI。
*/
const DOMAIN: number = 0xA0A0;
const TAG: string = 'HologramAgent';
/** 网络类错误的统一提示:不暴露主机名、端口、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;
}
/**
* 把任意异常翻译成一句用户能看懂、且不含技术细节的话。
* 同时把原始信息写入 hilog(scene 用于定位是哪个调用点)。
*/
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.code(ArkTS 不允许 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,265 @@
/**
* 「外观」二级页面:主题三选一 + 自定义背景图。
*
* 从 pages/SettingsPage.ets 抽出。
* themeMode / bgImage / bgOpacity 用 @Link 与一级页面共享(一级页的
* 外观行要显示当前主题名,两边必须是同一份数据)。
*/
import { connStore } from '../common/ConnStore';
import { AppSettings, emptySettings } from '../model/Model';
import { applyThemeMode } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD,
ANIM_NORMAL } from '../common/Constants';
import { imageExt } from '../common/SettingsModel';
import { userMessage } from '../common/UserError';
import { SubPageLayer, PlainCard } from './SubPage';
import { MotionBase } from './MotionBase';
import { picker, fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
@Component
export struct AppearancePane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Link themeMode: string;
@Link bgImage: string;
@Link bgOpacity: number;
onBack?: () => void;
onToast?: (msg: string, isError: boolean) => void;
@State pickingBg: boolean = false;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private toast(msg: string, isError: boolean): void {
const cb: ((m: string, e: boolean) => void) | undefined = this.onToast;
if (cb !== undefined) {
cb(msg, isError);
}
}
private applyTheme(mode: string): void {
this.themeMode = mode;
this.persistSettings();
// 立即翻转全局主题标志,整个 UI 随之切换
applyThemeMode(mode);
}
private async pickBackgroundImage(): Promise<void> {
if (this.pickingBg) {
return;
}
this.pickingBg = true;
try {
const options = new picker.PhotoSelectOptions();
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
const photoPicker = new picker.PhotoViewPicker();
const result = await photoPicker.select(options);
if (result.photoUris.length === 0) {
return;
}
const srcUri: string = result.photoUris[0];
const ctx = getContext(this) as common.UIAbilityContext;
// 文件名带时间戳:Image 组件按 src 字符串做内存缓存,
// 每次都写同一个 bg_image 会让第二次换图看起来"没生效"。
// 后缀必须保留:Image 组件按扩展名挑选解码器,无后缀的沙箱文件会解码失败,
// 表现就是"设置了背景图但没生效"(onError 里能看到 decode 失败)。
const destPath: string = ctx.filesDir + '/bg_' + Date.now().toString(36) + imageExt(srcUri);
const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
const destFile = fileIo.openSync(destPath,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
fileIo.copyFileSync(srcFile.fd, destFile.fd);
fileIo.closeSync(srcFile);
fileIo.closeSync(destFile);
this.removeOldBgFile();
// Image 只认带协议头的沙箱 URI,裸路径会被当成资源名而静默失败
this.bgImage = 'file://' + destPath;
this.persistSettings();
this.toast('背景图已设置', false);
} catch (e) {
this.toast(userMessage('settings.pickBg', e), true);
}
this.pickingBg = false;
}
/** 删除上一张背景图文件,避免沙箱里越攒越多 */
private removeOldBgFile(): void {
const old: string = this.bgImage;
if (old.length === 0) {
return;
}
const path: string = old.startsWith('file://') ? old.substring(7) : old;
try {
fileIo.unlinkSync(path);
} catch (e) {
// 文件可能已不存在,忽略
}
}
private clearBackgroundImage(): void {
this.removeOldBgFile();
this.bgImage = '';
this.persistSettings();
this.toast('已清除背景图', false);
}
private onBgOpacityChange(value: number): void {
this.bgOpacity = value / 100;
this.persistSettings();
}
private persistSettings(): void {
const s: AppSettings = emptySettings();
const old: AppSettings = connStore.getSettings();
s.lang = old.lang;
s.theme = this.themeMode.length > 0 ? this.themeMode : (old.theme.length > 0 ? old.theme : 'system');
s.currentConnId = old.currentConnId;
s.bgImage = this.bgImage;
s.bgOpacity = this.bgOpacity;
connStore.saveSettings(s);
AppStorage.set<string>('bgImage', this.bgImage);
AppStorage.set<number>('bgOpacity', this.bgOpacity);
}
@Builder
ThemeOption(label: string, mode: string) {
// 按压缩放由 MotionBase 统一;flexWeight: 1 让三枚选项在 Row 里继续等分。
MotionBase({ pressEnabled: true, flexWeight: 1 }) {
Column() {
Text(label)
.fontSize(12)
.fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary)
// 子节点的颜色迁移要自己声明:父容器的 .animation() 不下传
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
}
.justifyContent(FlexAlign.Center)
.width('100%')
.height(34)
.borderRadius(RADIUS_MD)
.backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover)
// 三选一的选中态迁移:底色与描边一起过渡。写在 .border 之后、
// 覆盖它上面的所有状态驱动属性。
.border({
width: 1,
color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder,
})
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.onClick(() => {
this.applyTheme(mode);
})
}
}
build() {
SubPageLayer({
title: '外观',
tab: 3,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
Column() {
PlainCard({ caption: '主题' }) {
Row({ space: 8 }) {
this.ThemeOption('跟随系统', 'system')
this.ThemeOption('浅色', 'light')
this.ThemeOption('深色', 'dark')
}
.width('100%')
Text(this.themeMode === 'system'
? '当前跟随系统,系统切换深浅色时自动跟随'
: (this.themeMode === 'dark' ? '当前强制深色主题' : '当前强制浅色主题'))
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 10 })
}
PlainCard({ caption: '背景图' }) {
Row() {
Column() {
Text('自定义背景图')
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text(this.bgImage.length > 0 ? '已设置背景图' : '未设置背景图')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.bgImage.length > 0) {
Button('更换')
.height(28)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.margin({ right: 6 })
.onClick(() => {
this.pickBackgroundImage();
})
Button('清除')
.height(28)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor('#E84026')
.onClick(() => {
this.clearBackgroundImage();
})
} else {
Button(this.pickingBg ? '选择中...' : '选择图片')
.height(28)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.onClick(() => {
this.pickBackgroundImage();
})
}
}
.width('100%')
if (this.bgImage.length > 0) {
Row() {
Text('透明度')
.fontSize(11)
.fontColor(this.palette().textMuted)
Slider({
value: Math.round(this.bgOpacity * 100),
min: 5,
max: 60,
step: 1,
})
.layoutWeight(1)
.selectedColor(this.palette().accent)
.trackColor(this.palette().bgHover)
.margin({ left: 8, right: 8 })
.onChange((v: number, mode: SliderChangeMode) => {
if (mode === SliderChangeMode.Moving || mode === SliderChangeMode.Click) {
this.onBgOpacityChange(v);
}
})
Text(Math.round(this.bgOpacity * 100).toString() + '%')
.fontSize(11)
.fontColor(this.palette().textSecondary)
.width(32)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 12 })
}
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}

View File

@ -0,0 +1,343 @@
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 { extLabel, formatBytes, sanitize } from '../common/AttachmentMeta';
import { loadPixelMap } from '../common/AttachmentImage';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { COLOR_ERROR } from '../common/Constants';
import { MotionBase } from './MotionBase';
import { PlainCard } from './SubPage';
/**
* 附件 UI:气泡内的附件卡 + 附件详情二级页内容。
*
* 解析/格式化(parseAttachment、formatBytes、extLabel…)在 common/AttachmentMeta.ets,
* 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI。
*
* 附件卡:图片显示缩略图,文件显示一枚文件条。
* 点击进入附件详情二级页面(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() {
// 整卡按压反馈统一收进 MotionBase(父组件):scale 回弹 + 透明度压暗。
// fillWidth: false —— 附件卡在聊天气泡内按内容自适应宽度,不能撑满整行。
MotionBase({ pressEnabled: true, pressOpacity: 0.88, fillWidth: false }) {
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)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.scale({ x: 0.98, y: 0.98 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
} 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)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} 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)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
} 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.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
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 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: -12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
// 预览:图片铺满宽度可缩放查看;文件给一个大图标占位
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)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} else if (this.pixel !== undefined) {
Image(this.pixel)
.width('100%')
.constraintSize({ maxHeight: 420 })
.objectFit(ImageFit.Contain)
.borderRadius(RADIUS_MD)
.draggable(false)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
} 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 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 6 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
}
}
.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)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}

View File

@ -0,0 +1,172 @@
/**
* 「核心配置」相关的两个二级页面:分类列表 + 某个分类的配置项。
*
* 从 pages/SettingsPage.ets 抽出。
* 取数/分区/落库都留在页面(它同时要显示"几个分类 · 几项"和错误态),
* 这里只负责渲染与把用户动作转成回调。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { SettingsSection, SettingEntry, activeSectionTitle } from '../common/SettingsModel';
import { SubPageLayer, NavGroup, NavRow, PlainCard } from './SubPage';
import { SettingsEntryCard } from './SettingsEntryCard';
@Component
export struct BackendSectionsPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop sections: SettingsSection[] = [];
@Prop pluginKeyCount: number = 0;
@Prop pluginConfigCount: number = 0;
@Prop busy: boolean = false;
@Prop errorText: string = '';
@Prop activeSection: string = '';
/** 宽屏右栏正显示分区明细时高亮左侧对应行 */
@Prop highlightRows: boolean = false;
onBack?: () => void;
onRefresh?: () => void;
onOpenSection?: (id: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '核心配置',
tab: 3,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
showRefresh: true,
onRefresh: () => {
const cb: (() => void) | undefined = this.onRefresh;
if (cb !== undefined) {
cb();
}
},
}) {
Column() {
if (this.errorText.length > 0) {
PlainCard({ caption: '状态' }) {
Text(this.errorText)
.fontSize(12)
.fontColor('#E84026')
}
}
NavGroup({ caption: '核心' }) {
ForEach(this.sections, (sec: SettingsSection, idx: number) => {
NavRow({
icon: $r('app.media.ic_tune'),
title: sec.title,
subtitle: sec.count.toString() + ' 项配置',
showDivider: idx < this.sections.length - 1,
selected: this.highlightRows && this.activeSection === sec.id,
onTap: () => {
const cb: ((id: string) => void) | undefined = this.onOpenSection;
if (cb !== undefined) {
cb(sec.id);
}
},
})
}, (sec: SettingsSection) => sec.id + sec.count.toString())
}
// 插件配置不在这里编辑:入口在插件页的详情里,这里只指路,避免两处重复入口
if (this.pluginKeyCount > 0) {
Text('插件的 ' + this.pluginKeyCount.toString() + ' 项配置(' +
this.pluginConfigCount.toString() + ' 个插件)在「插件 → 选择插件 → 插件配置」中修改。')
.fontSize(12)
.fontColor(this.palette().textMuted)
.width('100%')
.padding({ left: 4, right: 4 })
}
if (this.sections.length === 0 && !this.busy) {
Text('未获取到配置分类。检查后端连接后点击刷新。')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ left: 4 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}
@Component
export struct SectionEntriesPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop sections: SettingsSection[] = [];
@Prop activeSection: string = '';
@Prop entries: SettingEntry[] = [];
@Prop busy: boolean = false;
onBack?: () => void;
onRefresh?: () => void;
onSaveValue?: (key: string, value: string) => void;
onSaveCurrent?: (key: string) => void;
onEdit?: (key: string, value: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: activeSectionTitle(this.sections, this.activeSection),
tab: 3,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
showRefresh: true,
onRefresh: () => {
const cb: (() => void) | undefined = this.onRefresh;
if (cb !== undefined) {
cb();
}
},
}) {
Column() {
ForEach(this.entries, (entry: SettingEntry) => {
SettingsEntryCard({
entry: entry,
onSaveValue: (key: string, value: string) => {
const cb: ((k: string, v: string) => void) | undefined = this.onSaveValue;
if (cb !== undefined) {
cb(key, value);
}
},
onSaveCurrent: (key: string) => {
const cb: ((k: string) => void) | undefined = this.onSaveCurrent;
if (cb !== undefined) {
cb(key);
}
},
onEdit: (key: string, value: string) => {
const cb: ((k: string, v: string) => void) | undefined = this.onEdit;
if (cb !== undefined) {
cb(key, value);
}
},
})
}, (entry: SettingEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c'))
if (!this.busy && this.entries.length === 0) {
Text('该分区暂无配置项')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding(16)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}

View File

@ -0,0 +1,139 @@
/**
* 输入区上方的两个悬浮条:加号菜单(图片/文件)与待发送附件预览。
*
* 从 components/ChatComposer.ets 拆出 —— 两者都是"输入区上方的独立浮层",
* 数据与动作全部由输入区传入。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
ANIM_NORMAL } from '../common/Constants';
import { MotionBase } from './MotionBase';
import { formatBytes } from '../common/AttachmentMeta';
/** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */
@Component
export struct ChatAttachMenu {
@StorageProp('themeIsDark') private isDark: boolean = true;
onPickImage?: () => void;
onPickFile?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
@Builder
AttachOption(icon: Resource, label: string, tap: () => void) {
// 按压反馈交给 MotionBase:每个实例自带独立按压态,
// 修掉了原先两枚胶囊共用一个 @State、按一枚两枚同时缩放的问题。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Row({ space: 6 }) {
Image(icon)
.width(15)
.height(15)
.fillColor(this.palette().textSecondary)
.draggable(false)
Text(label)
.fontSize(12)
.fontColor(this.palette().textPrimary)
}
.padding({ left: 12, right: 14, top: 8, bottom: 8 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_PILL)
.border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
.onClick(tap)
}
}
build() {
// 外层撑满并左对齐:菜单要出现在加号正上方,而不是跟着悬浮区右对齐
Row() {
Row({ space: 8 }) {
this.AttachOption($r('app.media.ic_image'), '图片', () => {
const cb: (() => void) | undefined = this.onPickImage;
if (cb !== undefined) {
cb();
}
})
this.AttachOption($r('app.media.ic_file'), '文件', () => {
const cb: (() => void) | undefined = this.onPickFile;
if (cb !== undefined) {
cb();
}
})
}
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ bottom: 8 })
.hitTestBehavior(HitTestMode.Transparent)
// 加号菜单由 if 控制,进出场只能靠 transition;配合 toggle 处的
// animateTo,展开时两枚胶囊从加号上方浮起而不是硬闪出来。
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
/** 待发送附件预览条:缩略信息 + 一个移除按钮 */
@Component
export struct ChatPendingChip {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop name: string = '';
@Prop byteSize: number = 0;
@Prop isImage: boolean = false;
@Prop uploading: boolean = false;
onRemove?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
// 按压反馈交给 MotionBase(全宽预览条)
MotionBase({ pressEnabled: true }) {
Row({ space: 8 }) {
Image(this.isImage ? $r('app.media.ic_image') : $r('app.media.ic_file'))
.width(15)
.height(15)
.fillColor(this.palette().accent)
.draggable(false)
Column({ space: 1 }) {
Text(this.name)
.fontSize(12)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.uploading ? '上传中...' : formatBytes(this.byteSize))
.fontSize(10)
.fontColor(this.palette().textMuted)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.uploading) {
LoadingProgress()
.width(14)
.height(14)
.color(this.palette().accent)
} else {
Image($r('app.media.ic_close'))
.width(13)
.height(13)
.fillColor(this.palette().textMuted)
.draggable(false)
.onClick(() => {
const cb: (() => void) | undefined = this.onRemove;
if (cb !== undefined) {
cb();
}
})
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.margin({ bottom: 8 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_MD)
.border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
}
}
}

View File

@ -0,0 +1,247 @@
/**
* 单条聊天气泡(含头像、渠道名、思考卡、工具卡、附件卡、正文)。
*
* 从 pages/ChatPage.ets 抽出(原来是 Avatar / ChanAvatar / BubbleSlot /
* MessageBubble / BubbleBody 五个 @Builder)。
*
* 传参约定:一切都在构造时快照进来。ForEach 的键(structSig)只在
* 消息"结构"变化时改变(新增思考/工具/附件/来源、定稿),结构一变
* 气泡就整条重建,因此结构类字段不需要二次更新;唯一会在键不变时
* 持续变化的是正文 content,所以它单独用基本类型 @Prop 传(与
* MarkdownView 的 @Prop content 走同一条响应式链路,流式渲染不变)。
*/
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
ANIM_NORMAL } from '../common/Constants';
import { bubbleMaxWidth, chanColor, chanLabel, chanLetter } from '../common/ChatFormat';
import { AttachmentCard } from './Attachment';
import { MarkdownView } from './MarkdownView';
import { ChatReasoningCard, ChatToolCard } from './ChatToolCard';
@Component
export struct ChatBubble {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop msgId: number = 0;
@Prop role: string = '';
/** 正文:唯一会在 ForEach 键不变时持续变化的字段 */
@Prop content: string = '';
@Prop isStreaming: boolean = false;
@Prop isFinal: boolean = false;
@Prop reasoningContent: string = '';
@Prop reasoningOpen: boolean = false;
@Prop toolCalls: ToolCallInfo[] = [];
@Prop attachment: ChatAttachment | undefined = undefined;
@Prop source: string = '';
/** 是否"别处来的"消息(渠道/设备) */
@Prop channel: boolean = false;
/** 是否自己发的(右对齐、"我"头像) */
@Prop mine: boolean = false;
/** 入场动画阶段 */
@Prop fresh: boolean = false;
onOpenAttachment?: (att: ChatAttachment) => void;
onToggleReasoning?: (msgId: number) => void;
onToggleTool?: (msgId: number, index: number) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** 思考卡/工具卡是否是"面板":气泡宽度要放宽,见 common/ChatFormat */
private maxWidth(): string {
const msg: ChatMessage = {
id: this.msgId,
role: this.role,
content: this.content,
toolCalls: this.toolCalls,
};
if (this.reasoningContent.length > 0) {
msg.reasoningContent = this.reasoningContent;
}
return bubbleMaxWidth(msg);
}
@Builder
Avatar() {
Text(this.role === 'user' ? '我' : 'AI')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(this.role === 'user' ? this.palette().msgUserText : this.palette().accent)
.textAlign(TextAlign.Center)
.width(28)
.height(28)
.borderRadius(RADIUS_PILL)
.backgroundColor(this.role === 'user' ? this.palette().msgUserBg : this.palette().accentBg)
.margin({ top: 2 })
}
@Builder
ChanAvatar() {
Text(chanLetter(this.source))
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width(28)
.height(28)
.borderRadius(RADIUS_PILL)
.backgroundColor(chanColor(this.source))
.margin({ top: 2 })
}
/**
* 气泡占位槽 —— 分栏右侧被切掉的根因就在这里。
*
* 原来 BubbleBody 直接放进 Row,它的 constraintSize maxWidth 是百分比
* (78% / 92%)。百分比是相对【父节点外框】解析的,而这个 Row 自带
* 左右 8 的 padding、外层列表 Column 又有左右 14 的 padding,
* 于是 92% 算出来的宽度里包含了这些 padding,再加上 28 的头像和 8 的
* 间距,一行的总宽就超过了可用内容宽。窄屏因为整体够宽看不出来,
* 分栏后左栏只有 420vp,溢出的十几 vp 直接被栏宽裁掉 —— 表现为
* 消息右侧被切了一条(这与 MarkdownView 里 width('100%') 溢出 12vp
* 被 clip 的问题是同一个成因)。
*
* 修法同 MarkdownView:用 layoutWeight(1) 拿"剩余空间"而不是百分比。
* 槽自身无 padding,外框宽 == 内容宽 == 头像与间距之外的真实可用宽度,
* 气泡的百分比再相对它解析,无论栏宽多少都不可能溢出。
*/
@Builder
BubbleSlot() {
Column() {
this.BubbleBody()
}
.layoutWeight(1)
.alignItems(this.mine ? HorizontalAlign.End : HorizontalAlign.Start)
}
@Builder
BubbleBody() {
Column() {
// 渠道来源名(对齐 GUI 的 msg-chan-name):只有别处来的消息才显示
if (this.channel) {
Text(chanLabel(this.source))
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textMuted)
.margin({ bottom: 4 })
}
// Reasoning card (assistant only)
if (this.role === 'assistant' && this.reasoningContent.length > 0) {
ChatReasoningCard({
content: this.reasoningContent,
open: this.reasoningOpen,
sweeping: this.isFinal !== true && this.isStreaming === true,
fresh: this.fresh,
onToggle: () => {
const cb: ((id: number) => void) | undefined = this.onToggleReasoning;
if (cb !== undefined) {
cb(this.msgId);
}
},
})
}
// 附件卡(图片缩略图 / 文件条),点击进入附件详情二级页
if (this.attachment !== undefined) {
AttachmentCard({
att: this.attachment,
mine: this.role === 'user',
onTap: () => {
const a: ChatAttachment | undefined = this.attachment;
const cb: ((att: ChatAttachment) => void) | undefined = this.onOpenAttachment;
if (a !== undefined && cb !== undefined) {
cb(a);
}
},
})
}
// Content bubble — 对齐 WebGUI bubbleGrow + textFadeIn
if (this.content.length > 0) {
if (this.role === 'assistant') {
MarkdownView({
content: this.content,
isStreaming: this.isStreaming === true,
isDark: this.isDark,
})
} else {
Text(this.content)
.fontSize(15)
.lineHeight(24)
.fontColor(this.palette().msgBubbleText)
.textAlign(TextAlign.Start)
.wordBreak(WordBreak.BREAK_ALL)
.constraintSize({ maxWidth: '100%' })
.margin({ top: this.attachment !== undefined ? 8 : 0 })
}
}
// Tool cards
if (this.toolCalls.length > 0) {
Column() {
ForEach(this.toolCalls, (tc: ToolCallInfo, tci: number) => {
ChatToolCard({
tc: tc,
index: tci,
onToggle: (index: number) => {
const cb: ((id: number, i: number) => void) | undefined = this.onToggleTool;
if (cb !== undefined) {
cb(this.msgId, index);
}
},
})
}, (tc: ToolCallInfo, tci: number) => tci.toString() + tc.name)
}
// 不写 width('100%'):百分比会按气泡外框解析而溢出 12vp 被 clip。
// 让它自适应,最大宽约束由气泡内容框向下传递,ChatToolCard 内部用 layoutWeight 取满。
.alignItems(HorizontalAlign.Start)
.margin({ top: this.content.length > 0 ? 6 : 0 })
}
}
.constraintSize({ maxWidth: this.maxWidth() })
.clip(true)
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(this.role === 'user' ? this.palette().msgUserBubbleBg : this.palette().msgAssistantBubbleBg)
.borderRadius({
topLeft: RADIUS_MD,
topRight: RADIUS_MD,
bottomLeft: this.role === 'assistant' ? 4 : RADIUS_MD,
bottomRight: this.role === 'user' ? 4 : RADIUS_MD,
})
.border({ width: 1, color: this.role === 'user' ? this.palette().msgUserBubbleBorder : this.palette().msgAssistantBubbleBorder })
.shadow({ radius: 8, color: this.palette().shadow, offsetY: 2 })
.alignItems(HorizontalAlign.Start)
// bubbleGrow: 气泡入场缩放效果
.scale({
x: this.fresh ? 0.95 : 1,
y: this.fresh ? 0.95 : 1,
})
.animation({ duration: 200, curve: Curve.EaseOut })
}
build() {
Row({ space: 8 }) {
if (this.channel) {
this.ChanAvatar()
this.BubbleSlot()
} else if (this.role === 'user') {
this.BubbleSlot()
this.Avatar()
} else {
this.Avatar()
this.BubbleSlot()
}
}
.width('100%')
.alignItems(VerticalAlign.Top)
// 槽已经用 layoutWeight 吃掉了剩余宽度,这里的对齐实际不再参与分配,
// 保留是为了兜底:若某处布局退化成非加权分配,方向也仍然正确。
.justifyContent(this.mine ? FlexAlign.End : FlexAlign.Start)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
// 入场动画:对齐 WebGUI viewIn (opacity 0 -> 1, translateY 6 -> 0)
.opacity(this.fresh ? 0 : 1)
.translate({ y: this.fresh ? 8 : 0 })
.animation({ duration: 180, curve: Curve.EaseOut })
}
}

View File

@ -0,0 +1,361 @@
/**
* 悬浮输入区:加号菜单 + 待发送附件预览 + 输入行(含发送/中断)。
*
* 从 pages/ChatPage.ets 抽出(原来是 ChatBody 的层2 + AttachMenu /
* AttachOption / PendingAttachmentChip 三个 @Builder,外加选图/选文件、
* 沙箱落盘、带附件发送这一整套方法)。
*
* 输入区自己持有文本与附件状态;只有会影响【列表底部留白】的三项
* (inputMultiLine / attachMenuOpen / pendingName)用 @Link 与页面共享。
*/
import { ChatMessage } from '../model/Model';
import { userMessage } from '../common/UserError';
import { connStore } from '../common/ConnStore';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL } from '../common/Constants';
import { mimeOf } from '../common/ChatFormat';
import { chatStore, K_CHAT_LOADING } from '../common/ChatStore';
import { sendChatText, sendChatFile, interruptChat } from '../common/ChatSession';
import { fileNameOf } from '../common/AttachmentMeta';
import { ChatAttachMenu, ChatPendingChip } from './ChatAttachBar';
import { NavFloatOverlay, FloatIconButton } from './PageTopBar';
import { picker, fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { MeasureOptions } from '@ohos.measure';
/** 输入框字号与内边距:文字测量必须和 TextArea 的实际排版参数一致 */
const INPUT_FONT_SIZE: number = 14;
const INPUT_INNER_PAD: number = 16;
/** 单行态左右让位:左边加号 42+8,右边发送键 44+8 */
const INPUT_LEFT_GAP: number = 50;
const INPUT_RIGHT_GAP: number = 52;
@Component
export struct ChatComposer {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
/** 输入框是否已进入多行态(页面用它算列表底部留白) */
@Link inputMultiLine: boolean;
/** 加号菜单是否展开(同上) */
@Link attachMenuOpen: boolean;
/** 待发送附件的展示名(同上) */
@Link pendingName: string;
@State inputText: string = '';
@State pendingSize: number = 0;
@State pendingIsImage: boolean = false;
@State uploading: boolean = false;
private pendingPath: string = '';
private pendingMime: string = '';
/** 底部固定行的实测宽度:用于文字测量,判断是否需要换行 */
private inputRowWidth: number = 0;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** 输入框底:高不透明度 + blur,保证背景内容不会透过输入文字 */
private inputSolidBg(): string {
return this.isDark ? 'rgba(28, 28, 30, 0.94)' : 'rgba(245, 245, 247, 0.92)';
}
/**
* 由【文本本身】判断输入框是否需要换行,而不是回读控件高度。
*
* 用 MeasureUtils 在单行态可用宽度下测量文字:宽度超了就是多行。
* 测量宽度恒定取单行态(窄)宽度,与控件当前实际宽度无关,
* 所以"多行时变宽"不会反过来改变判定结果 —— 没有反馈环,也就不抖。
*/
private recomputeMultiLine(text: string): void {
const avail: number = this.inputRowWidth - INPUT_LEFT_GAP - INPUT_RIGHT_GAP
- INPUT_INNER_PAD * 2;
if (avail <= 0) {
return;
}
let multi: boolean = text.indexOf('\n') >= 0;
if (!multi && text.length > 0) {
const opt: MeasureOptions = {
textContent: text,
fontSize: INPUT_FONT_SIZE,
};
const size: SizeOptions = this.getUIContext().getMeasureUtils().measureTextSize(opt);
// measureTextSize 返回 px,可用宽度是 vp,换算后再比
const widthVp: number = this.getUIContext().px2vp(size.width as number);
multi = widthVp > avail;
}
if (multi !== this.inputMultiLine) {
this.getUIContext().animateTo({ duration: 260, curve: Curve.Friction }, () => {
this.inputMultiLine = multi;
});
}
}
// ===================== 附件:选择与上传 =====================
/** 从图库挑一张图 */
private async pickImage(): Promise<void> {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.attachMenuOpen = false;
});
try {
const options = new picker.PhotoSelectOptions();
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
const photoPicker = new picker.PhotoViewPicker();
const result = await photoPicker.select(options);
if (result.photoUris.length === 0) {
return;
}
this.stagePickedFile(result.photoUris[0], true);
} catch (e) {
chatStore.setStage(userMessage('chat.pickImage', e));
chatStore.forceRefresh();
}
}
/** 从文件管理器挑一个文件 */
private async pickFile(): Promise<void> {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.attachMenuOpen = false;
});
try {
const options = new picker.DocumentSelectOptions();
options.maxSelectNumber = 1;
const docPicker = new picker.DocumentViewPicker();
const uris: string[] = await docPicker.select(options);
if (uris.length === 0) {
return;
}
this.stagePickedFile(uris[0], false);
} catch (e) {
chatStore.setStage(userMessage('chat.pickFile', e));
chatStore.forceRefresh();
}
}
/**
* 把 picker 给的 URI 复制到应用沙箱。
* http 的 multiFormDataList.filePath 只能读应用自己的沙箱路径,
* 直接把 picker 的 media:// URI 交过去会读不到内容。
*/
private stagePickedFile(srcUri: string, isImage: boolean): void {
try {
const ctx = getContext(this) as common.UIAbilityContext;
const name: string = fileNameOf(srcUri);
const destPath: string = ctx.filesDir + '/up_' + Date.now().toString(36) + '_' + name;
const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
const destFile = fileIo.openSync(destPath,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
fileIo.copyFileSync(srcFile.fd, destFile.fd);
fileIo.closeSync(srcFile);
fileIo.closeSync(destFile);
const stat = fileIo.statSync(destPath);
this.clearPendingFile();
this.pendingPath = destPath;
this.pendingName = name;
this.pendingSize = stat.size;
this.pendingIsImage = isImage;
this.pendingMime = mimeOf(name, isImage);
} catch (e) {
chatStore.setStage(userMessage('chat.stageFile', e));
}
chatStore.forceRefresh();
}
/** 丢弃待发送附件,并删掉沙箱里的临时副本 */
private clearPendingFile(): void {
if (this.pendingPath.length > 0) {
try {
fileIo.unlinkSync(this.pendingPath);
} catch (e) {
// 已不存在,忽略
}
}
this.pendingPath = '';
this.pendingName = '';
this.pendingSize = 0;
this.pendingIsImage = false;
this.pendingMime = '';
}
/** 发送:有附件走 multipart(POST /chat/file),否则走 POST /chat */
private async send(): Promise<void> {
if (this.loading || this.uploading) {
return;
}
// 没连后端时直接返回、且不清输入/不动附件:与拆分前 sendChat 的守卫顺序一致
if (connStore.getCurrentConnection() === null) {
return;
}
const text: string = this.inputText.trim();
if (this.pendingPath.length > 0) {
const path: string = this.pendingPath;
const name: string = this.pendingName;
const size: number = this.pendingSize;
const isImage: boolean = this.pendingIsImage;
const mime: string = this.pendingMime;
this.inputText = '';
this.inputMultiLine = false;
this.uploading = true;
await sendChatFile(text, path, name, size, isImage, mime);
this.uploading = false;
this.clearPendingFile();
chatStore.forceRefresh();
chatStore.requestScroll();
return;
}
if (text.length === 0) {
return;
}
this.inputText = '';
this.inputMultiLine = false;
await sendChatText(text);
}
// ===================== UI =====================
build() {
NavFloatOverlay({ tab: 0 }) {
// 加号展开的两个选项(图片 / 文件),点一次收起
if (this.attachMenuOpen) {
ChatAttachMenu({
onPickImage: () => {
this.pickImage();
},
onPickFile: () => {
this.pickFile();
},
})
}
// 待发送附件预览(选好图片/文件、还没点发送时显示)
if (this.pendingName.length > 0) {
ChatPendingChip({
name: this.pendingName,
byteSize: this.pendingSize,
isImage: this.pendingIsImage,
uploading: this.uploading,
onRemove: () => {
this.clearPendingFile();
chatStore.forceRefresh();
},
})
}
// Stack 而不是 Column:加号与发送按钮钉死在底部这一行不动,
// 输入框是浮在它们上面的独立层,超过一行就往上长并展开到整行宽度。
Stack({ alignContent: Alignment.Bottom }) {
// 底层:固定不动的一行 —— 左加号(图片/文件)、右发送/中断按钮
Row() {
FloatIconButton({
icon: $r('app.media.ic_plus'),
onTap: () => {
// 菜单展开会同时改变列表底部留白,用 animateTo 把
// 列表内边距和菜单进出场拉到同一个时钟上。
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.attachMenuOpen = !this.attachMenuOpen;
});
},
})
Blank()
if (this.loading) {
Button() {
Image($r('app.media.ic_stop'))
.width(16)
.height(16)
.fillColor(Color.White)
}
.width(44)
.height(44)
.type(ButtonType.Circle)
.backgroundColor('#77809A')
// 发送/中断切换是 if 分支整体替换,用 transition 淡入淡出
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
.onClick(() => {
interruptChat();
})
} else {
Button() {
Image($r('app.media.ic_send'))
.width(18)
.height(18)
.fillColor(Color.White)
}
.width(44)
.height(44)
.type(ButtonType.Circle)
.backgroundColor(this.palette().accent)
.enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0)
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
.onClick(() => {
this.send();
})
}
}
.width('100%')
.height(44)
.alignItems(VerticalAlign.Center)
.onAreaChange((_o: Area, n: Area) => {
// 这一行高度恒为 44、宽度恒为 100%,测量它不会形成反馈环。
const w: number = n.width as number;
if (Math.abs(w - this.inputRowWidth) > 0.5) {
this.inputRowWidth = w;
this.recomputeMultiLine(this.inputText);
}
})
// 上层:输入框。
// 单行时左右让出加号(42+8)与发送键(44+8)的位置,与它们同处一行;
// 多行时整体上移 52 抬到那一行之上,并铺满整行宽度。
//
// 之前"只上移不变宽"是因为宽度被钉死了:让宽度跟着实测高度变会形成
// 布局反馈环(变宽→文字回落成一行→变窄→又折行),卡在半弹出态抖动。
// 现在改用 MeasureUtils 直接量文字:始终按【窄宽度】测量是否需要换行,
// 判定输入只依赖文本内容,与控件实际宽度无关,所以变宽也不会自激。
Row() {
TextArea({
placeholder: '输入消息...',
text: this.inputText,
})
.layoutWeight(1)
// 不写死高度:单行 44,随文字换行自动增高,最多约 5 行后内部滚动
.constraintSize({ minHeight: 44, maxHeight: 168 })
.fontSize(INPUT_FONT_SIZE)
.fontColor(this.palette().textPrimary)
.placeholderFont({ size: 13 })
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.inputSolidBg())
.backdropBlur(24)
.borderRadius(22)
.border({ width: 1, color: this.palette().glassBorder })
.padding({
left: INPUT_INNER_PAD,
right: INPUT_INNER_PAD,
top: 11,
bottom: 11,
})
.enterKeyType(EnterKeyType.Send)
.onChange((value: string) => {
this.inputText = value;
this.recomputeMultiLine(value);
})
.onSubmit(() => {
this.send();
})
}
.width('100%')
// 多行时必须显式写 0:给 .padding() 传 undefined 在增量更新时会被当作
// "不修改该属性",旧的左右 50/52 留在原地 —— 这就是"只上移不变宽"。
.padding(this.inputMultiLine
? { left: 0, right: 0 }
: { left: INPUT_LEFT_GAP, right: INPUT_RIGHT_GAP })
.margin({ bottom: this.inputMultiLine ? 52 : 0 })
// 关键:这层 Row 铺满整宽,它的左右 padding 正好压在加号与发送键上方。
// 不设 None 的话 padding 区域仍属于 Row,会把点击吞掉 —— 发送键点不动。
// None = 自身不响应、子节点(TextArea)照常响应,触摸落到下层那一行。
.hitTestBehavior(HitTestMode.None)
.animation({ duration: 260, curve: Curve.Friction })
}
.width('100%')
}
}
}

View File

@ -0,0 +1,285 @@
/**
* 聊天消息流:列表 + 顶栏遮罩 + 底部淡出遮罩 + 滚动/懒加载。
*
* 从 pages/ChatPage.ets 抽出(原来是 ChatBody 里除悬浮输入区之外的三层)。
* 消息数组来自 common/ChatStore.ets:用版本号 K_CHAT_REV 订阅,
* 版本变化时重取一次快照(数组引用每次都是新的,ForEach 的渲染语义
* 与拆分前 this.messages = this.messages.slice() 完全一致)。
*/
import { ChatMessage } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
import { SUB_CONNECTIONS } from '../common/SettingsModel';
import { navBar } from '../common/NavBarController';
import { ChatAttachment } from '../model/Model';
import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore';
import { isChannelMsg, isSelfMsg, structSig } from '../common/ChatFormat';
import { connStore } from '../common/ConnStore';
import { ChatBubble } from './ChatBubble';
import { PageTopBar } from './PageTopBar';
@Component
export struct ChatStream {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 是否已配置后端连接(决定空态是引导连接还是引导开聊) */
@StorageProp(K_HAS_CONN) private hasConn: boolean = false;
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
@StorageProp(K_CHAT_STAGE) private stage: string = '';
/** 数组快照的订阅信号 */
@StorageProp(K_CHAT_REV) @Watch('onChatRev') private rev: number = 0;
/** "滚到底"请求信号 */
@StorageProp(K_CHAT_SCROLL_REV) @Watch('onScrollReq') private scrollRev: number = 0;
@State messages: ChatMessage[] = [];
/** 列表底部留白:随输入区展开/加号菜单/待发送附件变化 */
@Prop bottomPad: number = 210;
onOpenAttachment?: (att: ChatAttachment) => void;
private scroller: Scroller = new Scroller();
private autoScrolling: boolean = false;
/** 滚动世代号:scrollRev 每次变化自增,旧一轮的延迟滚动据此作废 */
private scrollGen: number = 0;
private navHidden: boolean = false;
aboutToAppear(): void {
this.messages = chatStore.messages();
// 首帧如果已经有消息(历史加载先于本组件挂载完成),必须自己滚到底。
//
// 为何必须补这一下:@Watch 只在值**变化**时触发,不触发初始值。
// ChatPage.aboutToAppear 里 loadHistory() 是异步的,若它在 ChatStream
// 构造之前就完成了,requestScroll 递增的 chatScrollRev 就成了“挂载前
// 已经发生的变化”——本组件的 onScrollReq 永远不会被调到,表现就是
// “消息加载好了却停在顶部/中间,不滚到最新”。
if (this.messages.length > 0) {
this.scrollToBottom();
}
}
private onChatRev(): void {
this.messages = chatStore.messages();
}
private onScrollReq(): void {
this.scrollToBottom();
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/**
* 底部淡出遮罩的两个端色:背景底色的全不透明 / 全透明版本。
* bgPrimary 是 6 位十六进制,这里手拼 8 位 ARGB —— 与 PageTopBar
* 顶部淡出用的是同一手法,保证上下两端的融入观感一致。
*/
private opaqueBottomBg(): string {
return '#FF' + this.palette().bgPrimary.substring(1);
}
private transparentBottomBg(): string {
return '#00' + this.palette().bgPrimary.substring(1);
}
private scrollToBottom(): void {
this.autoScrolling = true;
// 多次重试:内容高度是消息数组更新后**若干帧内**才逐步确定的,
// 长历史 / Markdown / 思考卡 / 工具卡布局都慢。旧实现只重试到 260ms,
// 长历史下那一次仍落在“当时”的底部(用户看到的是加载完停在中间)。
// 用递增间隔重试到 ~1s,让后几帧的布局增长也跟得上。
//
// scrollRev 变化时旧一轮的定时器不能继续干预新滚动,用世代号作废。
const gen: number = ++this.scrollGen;
const delays: number[] = [50, 120, 220, 360, 550, 800];
for (let i = 0; i < delays.length; i++) {
setTimeout(() => {
if (gen !== this.scrollGen) {
return;
}
this.scroller.scrollEdge(Edge.Bottom);
}, delays[i]);
}
setTimeout(() => {
if (gen !== this.scrollGen) {
return;
}
this.autoScrolling = false;
this.navHidden = false;
navBar.setVisible(true);
}, 900);
}
/**
* 滚动回调:只设置普通标志位,仅在状态翻转时通知 navBar,
* 不在回调里做任何耗时操作。navBar.setVisible 内部已去重,
* 而布局(padding)不再依赖 navVisible,故翻转只触发 GPU 变换,
* 不会引起布局回流——这是滑动流畅的关键。
*/
private handleScrollDirection(yOffset: number, state: ScrollState): void {
if (this.autoScrolling) {
return;
}
// 触顶(近顶部 60vp)且服务端还有更早历史 → 向上懒加载下一页
if (yOffset < 60 && chatStore.hasMore()) {
chatStore.loadOlder();
}
if (state === ScrollState.Idle) {
if (this.navHidden) {
this.navHidden = false;
navBar.setVisible(true);
}
} else {
// Scroll / Fling:向下/惯性滚动时隐藏导航与输入栏
if (!this.navHidden) {
this.navHidden = true;
navBar.setVisible(false);
}
}
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
// 层1:消息列表(铺满全屏,内容从顶栏遮罩下方穿过时逐渐淡出)
Column() {
Scroll(this.scroller) {
Column() {
ForEach(this.messages, (msg: ChatMessage, idx: number) => {
ChatBubble({
msgId: msg.id,
role: msg.role,
content: msg.content,
isStreaming: msg.isStreaming === true,
isFinal: msg.isFinal === true,
reasoningContent: msg.reasoningContent ?? '',
reasoningOpen: msg.reasoningOpen === true,
toolCalls: msg.toolCalls ?? [],
attachment: msg.attachment,
source: msg.source ?? '',
channel: isChannelMsg(msg.source ?? '', connStore.ensureDeviceId()),
mine: isSelfMsg(msg.role, isChannelMsg(msg.source ?? '', connStore.ensureDeviceId())),
fresh: chatStore.isFresh(msg.id),
onOpenAttachment: (att: ChatAttachment) => {
const cb: ((a: ChatAttachment) => void) | undefined = this.onOpenAttachment;
if (cb !== undefined) {
cb(att);
}
},
onToggleReasoning: (id: number) => {
// 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation,
// 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
chatStore.setReasoningOpen(id, !chatStore.reasoningOpen(id));
chatStore.forceRefresh();
});
},
onToggleTool: (id: number, index: number) => {
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
chatStore.setToolOpen(id, index, !chatStore.toolOpen(id, index));
chatStore.forceRefresh();
});
},
})
}, (msg: ChatMessage, idx: number) => structSig(msg))
if (this.loading) {
Row({ space: 8 }) {
LoadingProgress()
.width(16)
.height(16)
.color(this.palette().accent)
Text(this.stage.length > 0 ? this.stage : '处理中...')
.fontSize(12)
.fontColor(this.palette().textMuted)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.padding({ left: 52, top: 6, bottom: 6 })
// if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新);
// 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
.width('100%')
.padding({
left: 14,
right: 14,
top: 76,
bottom: this.bottomPad,
})
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
this.handleScrollDirection(yOffset, state);
})
}
.width('100%')
.height('100%')
// 层1.05:空态 —— 未连接后端时给出明确的“去设置连接”入口。
//
// 为什么必须有:全新安装时聊天页只有一条空列表 + 输入框,用户看不到
// 任何连后端的入口(入口在设置页的二级页里,很容易找不到)。
if (this.messages.length === 0 && !this.loading) {
Column({ space: 10 }) {
Image($r('app.media.ic_link'))
.width(34)
.height(34)
.fillColor(this.palette().textMuted)
.draggable(false)
Text(this.hasConn ? '开始新的对话' : '尚未连接后端服务')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
Text(this.hasConn
? '在下方输入框发送第一条消息'
: '请先在“后端连接”里填写服务地址与 API Key')
.fontSize(12)
.fontColor(this.palette().textMuted)
.textAlign(TextAlign.Center)
if (!this.hasConn) {
Button('去设置连接')
.height(34)
.fontSize(13)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.margin({ top: 4 })
.onClick(() => {
// 跨页信号:切到设置 Tab,并让设置页直接打开连接二级页
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, SUB_CONNECTIONS);
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, 3);
})
}
}
.width('100%')
.height('100%')
.padding({ left: 44, right: 44 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
// 自身不吃触摸(空白处仍可滑列表),但子节点(按钮)正常响应
.hitTestBehavior(HitTestMode.Transparent)
}
// 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透)
PageTopBar({ title: '聊天' })
// 层1.6:底部淡出遮罩 —— 滚动内容接近悬浮输入区/底部导航时逐渐隐入背景,
// 而不是在玻璃后面清晰可见(PageTopBar 顶部淡出手法的镜像,方向相反)。
Column()
.width('100%')
.height(170)
.linearGradient({
direction: GradientDirection.Bottom,
colors: [
[this.transparentBottomBg(), 0.0],
[this.opaqueBottomBg(), 0.6],
[this.opaqueBottomBg(), 1.0],
],
})
.hitTestBehavior(HitTestMode.None)
}
.width('100%')
.height('100%')
}
}

View File

@ -0,0 +1,253 @@
/**
* 气泡内的两张"面板"卡:思考过程卡 + 工具调用卡。
*
* 从 pages/ChatPage.ets 抽出(原来分别是 ReasoningCard / ToolCard 两个 @Builder)。
* 折叠状态与开关动作都交回调用方(状态在 chatStore 里,且展开/收起要在
* animateTo 帧内完成 —— 那需要组件上下文)。
*/
import { ToolCallInfo } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM,
ANIM_NORMAL } from '../common/Constants';
import { tcRunning, tcError, tcLeftColor, tcIcoColor, tcStateLabel, tcStateColor } from '../common/ChatFormat';
@Component
export struct ChatReasoningCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 思考正文(已折叠时也带着,展开不再请求) */
@Prop content: string = '';
@Prop open: boolean = false;
/** 流式中:显示转圈 + 扫光条 */
@Prop sweeping: boolean = false;
/** 入场动画阶段:扫光条起始偏移靠它切换 */
@Prop fresh: boolean = false;
onToggle?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
// 两层结构,原因见 ChatBubble 的布局说明:
// 外层 Row 是"外观壳"(虚线边框 / 底色 / 圆角),不设百分比宽度,
// 靠内层 layoutWeight(1) 把气泡内容框的剩余宽度吃满;
// 内层 holder Column 自身无 padding,所以它的子节点写 width('100%')
// 才有正确的解析基准,不会再溢出到气泡外被 clip 切掉。
Row() {
Column() {
Row({ space: 7 }) {
Image($r('app.media.ic_sparkle'))
.width(12)
.height(12)
.fillColor(this.palette().accent)
Text('思考过程')
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
// 流式思考时给出明确进度指示,而不是一张看不出在动的折叠卡
if (this.sweeping) {
LoadingProgress()
.width(11)
.height(11)
.color(this.palette().accent)
}
Blank()
Text(this.content.length > 0 ? this.content.length.toString() + ' 字' : '')
.fontSize(9.5)
.fontColor(this.palette().textMuted)
Image($r('app.media.ic_chevron_down'))
.width(14)
.height(14)
.fillColor(this.palette().textMuted)
.rotate({ angle: this.open ? 180 : 0 })
.animation({ duration: 200, curve: Curve.EaseOut })
}
.width('100%')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.onClick(() => {
const cb: (() => void) | undefined = this.onToggle;
if (cb !== undefined) {
cb();
}
})
if (this.open) {
Text(this.content)
.fontSize(11.5)
.lineHeight(17)
.fontColor(this.palette().textTertiary)
.width('100%')
.padding({ left: 10, right: 10, bottom: 8 })
.maxLines(24)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.wordBreak(WordBreak.BREAK_ALL)
// 面板内容靠 if 挂载:用 transition 在展开/收起时淡入淡出
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
// 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep)
if (this.sweeping) {
Stack() {
Row()
.height(2)
.borderRadius(2)
.width('200%')
.linearGradient({
angle: 90,
colors: [
['rgba(255,255,255,0.01)', 0],
[this.palette().accent, 0.35],
['rgba(255,255,255,0.01)', 0.5],
[this.palette().accent, 0.65],
['rgba(255,255,255,0.01)', 1],
],
})
.opacity(0.7)
.translate({ x: this.fresh ? '0%' : '-50%' })
.animation({ duration: 1200, curve: Curve.Linear })
}
.width('100%')
.clip(true)
.height(2)
.margin({ top: 6 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Top)
.margin({ bottom: 6 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.border({ width: 1, color: this.palette().kvBorder, style: BorderStyle.Dashed })
}
}
@Component
export struct ChatToolCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop tc: ToolCallInfo;
/** 在所属消息 toolCalls 里的下标:开关动作要交回调用方按 (msgId, index) 定位 */
@Prop index: number = 0;
onToggle?: (index: number) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
// 同 ChatReasoningCard:外层 Row 只做外观,内层 layoutWeight(1) 取真实内容宽
Row() {
Column() {
Row({ space: 6 }) {
if (tcError(this.tc)) {
Image($r('app.media.ic_error'))
.width(13).height(13)
.fillColor(tcIcoColor(this.tc, this.palette().accent))
} else if (tcRunning(this.tc)) {
LoadingProgress()
.width(12).height(12)
.color(tcIcoColor(this.tc, this.palette().accent))
} else {
Image($r('app.media.ic_check'))
.width(13).height(13)
.fillColor(tcIcoColor(this.tc, this.palette().accent))
}
Text(this.tc.name)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 名字可长可短,必须让它占剩余宽度并可收缩,
// 否则右侧状态文字会被挤出气泡(78% 宽度 + clip 直接切掉)
.layoutWeight(1)
if (this.tc.plugin !== undefined && this.tc.plugin.length > 0) {
Text(this.tc.plugin)
.fontSize(9.5)
.fontColor(this.palette().textMuted)
.maxLines(1)
.flexShrink(0)
}
// 状态徽标去掉,只留一行小字(用户要求"去掉所有状态徽标")
Text(tcStateLabel(this.tc))
.fontSize(9.5)
.fontWeight(FontWeight.Medium)
.fontColor(tcStateColor(this.tc))
.flexShrink(0)
Image($r('app.media.ic_chevron_down'))
.width(13).height(13)
.fillColor(this.palette().textMuted)
.flexShrink(0)
.rotate({ angle: this.tc.open === true ? 180 : 0 })
.animation({ duration: 150, curve: Curve.EaseOut })
}
.width('100%')
.alignItems(VerticalAlign.Center)
.onClick(() => {
const cb: ((index: number) => void) | undefined = this.onToggle;
if (cb !== undefined) {
cb(this.index);
}
})
if (this.tc.open === true) {
Column() {
if (this.tc.args.length > 0 && this.tc.args !== '{}') {
Text('参数')
.fontSize(9.5).fontWeight(FontWeight.Medium)
.fontColor(this.palette().textMuted)
.margin({ top: 6, bottom: 2 })
Text(this.tc.args)
.fontSize(11)
.fontColor(this.palette().preText)
.backgroundColor(this.palette().preBg)
.borderRadius(4)
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
.width('100%')
.textAlign(TextAlign.Start)
.wordBreak(WordBreak.BREAK_ALL)
}
if (this.tc.result !== undefined && this.tc.result.length > 0) {
Text('结果')
.fontSize(9.5).fontWeight(FontWeight.Medium)
.fontColor(this.palette().textMuted)
.margin({ top: 6, bottom: 2 })
Text(this.tc.result)
.fontSize(11)
.fontColor(this.palette().preText)
.backgroundColor(this.palette().preBg)
.borderRadius(4)
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
.width('100%')
.textAlign(TextAlign.Start)
.wordBreak(WordBreak.BREAK_ALL)
.maxLines(8)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 展开内容整体用 if 挂载:transition 让参数/结果随 chevron 一起淡入
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Top)
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
.margin({ bottom: 4 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.border({
width: { left: 3, top: 1, right: 1, bottom: 1 },
color: {
left: tcLeftColor(this.tc, this.palette().accent),
top: this.palette().kvBorder,
right: this.palette().kvBorder,
bottom: this.palette().kvBorder,
},
})
}
}

View File

@ -0,0 +1,356 @@
/**
* 「后端连接」二级页面。
*
* 从 pages/SettingsPage.ets 抽出:连接列表 UI、增删改表单与其状态、
* 以及切换连接后必须做的连带动作(刷新 ApiClient / 重启前台桥)
* 都属于这一个功能域,收在一个组件里。
*
* connections / currentId 用 @Link 与一级页面共享:一级页的入口行
* 要显示"几个连接配置"和当前连接名,两边必须是同一份数据。
*/
import { apiClient } from '../common/ApiClient';
import { connStore } from '../common/ConnStore';
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM, K_HAS_CONN } from '../common/Constants';
import { SubPageLayer, PlainCard } from './SubPage';
import { common } from '@kit.AbilityKit';
@Component
export struct ConnectionsPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Link connections: ConnectionConfig[];
@Link currentId: string;
onBack?: () => void;
onToast?: (msg: string, isError: boolean) => void;
@State showAddForm: boolean = false;
@State addFormVisible: boolean = false;
/**
* 表单当前在编辑哪条连接:空串表示新建。
*
* 之前只有"添加"入口,ConnStore.updateConnection 写好了却没有任何调用者,
* 于是地址填错的连接只能删掉重建(API Key 也得重敲)。同一套表单
* 靠这个 id 区分保存走 add 还是 update。
*/
@State editingId: string = '';
@State editUrl: string = '';
@State editApiKey: string = '';
@State editName: string = '';
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private toast(msg: string, isError: boolean): void {
const cb: ((m: string, e: boolean) => void) | undefined = this.onToast;
if (cb !== undefined) {
cb(msg, isError);
}
}
/** 连接变更后广播状态:聊天空态据此隐藏“去设置连接”入口。 */
private syncConnFlag(): void {
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
}
private currentConnName(): string {
for (let i = 0; i < this.connections.length; i++) {
if (this.connections[i].id === this.currentId) {
return this.connections[i].name;
}
}
return '未配置';
}
private selectConnection(id: string): void {
connStore.setCurrent(id).then(() => {
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('已切换连接', false);
});
}
private addConnection(): void {
const name: string = this.editName.trim();
const url: string = this.editUrl.trim();
const apiKey: string = this.editApiKey.trim();
if (name.length === 0 || url.length === 0) {
this.toast('名称和地址不能为空', true);
return;
}
if (this.editingId.length > 0) {
this.updateConnection(this.editingId, name, url, apiKey);
return;
}
connStore.addConnection(name, url, apiKey).then(() => {
this.closeConnForm();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已添加', false);
});
}
/**
* 保存对已有连接的修改。
*
* 修改当前生效的连接后必须重新 setConnection:ApiClient 持有的是
* ConnectionConfig 的引用快照,不刷新的话后续请求还会打到旧地址。
*/
private updateConnection(id: string, name: string, url: string, apiKey: string): void {
connStore.updateConnection(id, name, url, apiKey).then(() => {
this.closeConnForm();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已更新', false);
});
}
/** 打开表单:id 为空是新建,非空是编辑并回填原值(API Key 一并带出,避免用户重敲)。 */
private openConnForm(conn: ConnectionConfig | null): void {
this.showAddForm = true;
this.addFormVisible = false;
if (conn === null) {
this.editingId = '';
this.editName = '';
this.editUrl = '';
this.editApiKey = '';
} else {
this.editingId = conn.id;
this.editName = conn.name;
this.editUrl = conn.url;
this.editApiKey = conn.apiKey;
}
setTimeout(() => {
this.addFormVisible = true;
}, 30);
}
private closeConnForm(): void {
this.showAddForm = false;
this.addFormVisible = false;
this.editingId = '';
this.editName = '';
this.editUrl = '';
this.editApiKey = '';
}
private deleteConnection(id: string): void {
connStore.deleteConnection(id).then(() => {
this.connections = connStore.getConnections();
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
} else {
apiClient.clearConnection();
}
this.syncConnFlag();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已删除', false);
});
}
build() {
SubPageLayer({
title: '后端连接',
tab: 3,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
Column() {
PlainCard({ caption: '当前连接' }) {
Row() {
Column({ space: 3 }) {
Text(this.currentConnName())
.fontSize(15)
.fontColor(this.palette().textPrimary)
Text(this.currentId.length > 0 ? '已激活,用于所有请求' : '尚未选择连接')
.fontSize(11)
.fontColor(this.palette().textMuted)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Button('+ 添加')
.height(30)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.onClick(() => {
this.openConnForm(null);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.showAddForm) {
Column() {
Text(this.editingId.length > 0 ? '编辑连接' : '新建连接')
.fontSize(12)
.fontColor(this.palette().textSecondary)
.margin({ bottom: 10 })
TextInput({ placeholder: '名称 (如 HologramAgent)', text: this.editName })
.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 })
.margin({ bottom: 10 })
.onChange((v: string) => {
this.editName = v;
})
TextInput({ placeholder: '地址 (域名或 http://192.168.1.100:8080)', text: this.editUrl })
.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 })
.margin({ bottom: 10 })
.onChange((v: string) => {
this.editUrl = v;
})
TextInput({ placeholder: 'API Key (可选)', text: this.editApiKey })
.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(InputType.Password).margin({ bottom: 12 })
.onChange((v: string) => {
this.editApiKey = v;
})
Row() {
Button('取消')
.height(30)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.onClick(() => {
this.closeConnForm();
})
Blank()
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.onClick(() => {
this.addConnection();
})
}
.width('100%')
}
.width('100%')
.padding(12)
.borderRadius(RADIUS_MD)
.backgroundColor(this.palette().bgHover)
.border({ width: 1, color: this.palette().kvBorder })
.margin({ top: 12 })
.alignItems(HorizontalAlign.Start)
.opacity(this.addFormVisible ? 1 : 0)
.translate({ y: this.addFormVisible ? 0 : 12 })
.animation({ duration: 220, curve: Curve.EaseOut })
}
}
PlainCard({ caption: '全部连接' }) {
if (this.connections.length === 0) {
Text('暂无连接。点击上方“添加”配置后端地址。')
.fontSize(12)
.fontColor(this.palette().textMuted)
}
ForEach(this.connections, (conn: ConnectionConfig) => {
Row() {
Circle({ width: 8, height: 8 })
.fill(conn.id === this.currentId ? this.palette().accent : '#77809A')
.margin({ right: 10 })
Column() {
Text(conn.name)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(conn.url)
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (conn.id !== this.currentId) {
Button('切换')
.height(26)
.fontSize(11)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.margin({ right: 6 })
.onClick(() => {
this.selectConnection(conn.id);
})
} else {
Text('使用中')
.fontSize(11)
.fontColor(this.palette().accent)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().accentBg)
.margin({ right: 6 })
}
Button('编辑')
.height(26)
.fontSize(11)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.margin({ right: 6 })
.onClick(() => {
this.openConnForm(conn);
})
Button('删除')
.height(26)
.fontSize(11)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor('#E84026')
.onClick(() => {
this.deleteConnection(conn.id);
})
}
.width('100%')
.padding(10)
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.border({
width: { left: 3 },
color: conn.id === this.currentId ? this.palette().accent : Color.Transparent,
})
.margin({ bottom: 6 })
// 键里带上 name/url:ForEach 对相同键只更新绑定、不重跑 @Builder 体,
// 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput,
// 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。
}, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}

View File

@ -0,0 +1,316 @@
/**
* 设备页的四个二级页面内容:本机设备 / 设备能力 / 设备通道 / 接入的设备。
*
* 从 pages/DevicePage.ets 抽出(原来是 LocalDeviceContent / CapsContent /
* GatewayContent / OnlineDevicesContent 四个 @Builder + KvRow)。
* 每个面板自带 SubPageLayer 外壳(标题、所属 Tab、返回、刷新),
* 页面只保留路由分发 —— 与 SettingsPage 拆出的三个 Pane 同一套做法。
*/
import { DeviceInfo } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL,
ANIM_ENTER } from '../common/Constants';
import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession';
import { MotionBase } from './MotionBase';
import { PlainCard, SubPageLayer } from './SubPage';
/** 通用 KV 行(面板之间共用) */
@Component
export struct DeviceKvRow {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop label: string = '';
@Prop value: string = '';
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Row() {
Text(this.label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
Blank()
Text(this.value)
.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 })
}
}
/** 二级:本机设备(基本信息 + 远程控制授权) */
@Component
export struct DeviceLocalPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop deviceId: string = '';
@Prop authorized: boolean = false;
onBack?: () => void;
onToggleAuth?: (on: boolean) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '本机设备',
tab: 2,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
PlainCard({ caption: '基本信息' }) {
DeviceKvRow({ label: '设备 ID', value: this.deviceId.length > 0 ? this.deviceId : '未注册' })
DeviceKvRow({ label: '名称', value: 'HologramAgent OHOS' })
DeviceKvRow({ label: '类型', value: '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) => {
const cb: ((on: boolean) => void) | undefined = this.onToggleAuth;
if (cb !== undefined) {
cb(on);
}
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Circle({ width: 8, height: 8 })
.fill(this.authorized ? '#17A964' : '#E84026')
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.margin({ right: 8 })
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
.fontSize(12)
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
}
.width('100%')
.margin({ top: 12 })
.padding({ left: 4 })
}
}
}
}
/** 二级:设备能力(本机能被 agent 调用的能力清单) */
@Component
export struct DeviceCapsPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
onBack?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '设备能力',
tab: 2,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
PlainCard({ caption: '能力清单' }) {
Text('agent 通过设备桥可调用的本机能力:')
.fontSize(12)
.fontColor(this.palette().textMuted)
.margin({ bottom: 10 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(LOCAL_DEVICE_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%')
}
}
}
}
/** 二级:设备通道(网关地址 / Token / 连接状态) */
@Component
export struct DeviceGatewayPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop bridgeUrl: string = '';
@Prop bridgeToken: string = '';
@Prop bridgeConnected: boolean = false;
onBack?: () => void;
onRefresh?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '设备通道',
tab: 2,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
PlainCard({ caption: '连接信息' }) {
DeviceKvRow({ label: '网关地址', value: this.bridgeUrl.length > 0 ? this.bridgeUrl : '-' })
DeviceKvRow({ label: 'Token', value: this.bridgeToken.length > 0 ? '已从连接继承' : '未配置' })
DeviceKvRow({ label: '状态', value: this.bridgeConnected ? '已连接' : '未连接' })
}
PlainCard({ caption: '操作' }) {
Row() {
Blank()
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button('刷新设备')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.onClick(() => {
const cb: (() => void) | undefined = this.onRefresh;
if (cb !== undefined) {
cb();
}
})
}
}
.width('100%')
Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 10 })
}
}
}
}
/** 二级:接入的设备(网关侧在线设备列表) */
@Component
export struct DeviceListPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop devices: DeviceInfo[] = [];
onBack?: () => void;
onRefresh?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '接入的设备',
tab: 2,
showRefresh: true,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
onRefresh: () => {
const cb: (() => void) | undefined = this.onRefresh;
if (cb !== undefined) {
cb();
}
},
}) {
DeviceOnlineList({ devices: this.devices })
}
}
}
/** 在线设备列表本体(抽出来只是为了让 DeviceListPane 的 build 更短) */
@Component
struct DeviceOnlineList {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop devices: DeviceInfo[] = [];
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Column() {
if (this.devices.length === 0) {
Text('暂无其他设备。电脑 GUI 或 CLI 连接同一网关后会出现在这里。')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ left: 4 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
ForEach(this.devices, (dev: DeviceInfo) => {
// PlainCard 是自定义组件,transition 不能直接挂在它上面(会生成 __Common__ 包装),
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
Column() {
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%')
}
}
.width('100%')
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,92 @@
/**
* 设备页一级入口列表(本机 / 通道两组)。
*
* 从 pages/DevicePage.ets 抽出(原来是 RootEntries 一个 @Builder)。
* 宽屏高亮由组件自己读 AppStorage 的 isWideScreen 决定,
* 页面只负责给 activeSub 与四个打开动作。
*/
import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession';
import { SUB_CAPS, SUB_GATEWAY, SUB_LIST, SUB_LOCAL } from '../common/DeviceModel';
import { NavGroup, NavRow } from './SubPage';
@Component
export struct DeviceRootEntries {
/** 宽屏:左边一级界面(含底部导航栏),右边二级界面 */
@StorageProp('isWideScreen') private isWide: boolean = false;
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
@Prop activeSub: string = '';
@Prop deviceId: string = '';
@Prop bridgeConnected: boolean = false;
@Prop bridgeUrl: string = '';
@Prop loadingDevices: boolean = false;
@Prop deviceCount: number = 0;
onOpen?: (id: string) => void;
private capsCount(): number {
return LOCAL_DEVICE_CAPS.length;
}
private open(id: string): void {
const cb: ((id: string) => void) | undefined = this.onOpen;
if (cb !== undefined) {
cb(id);
}
}
build() {
// 一级入口列表整体作为一个容器根节点:@Component 的 build() 只允许一个根,
// 页面侧仍是 `.padding(...)` 的 Column,逐项布局与拆分前一致。
Column() {
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.open(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.open(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.open(SUB_GATEWAY);
},
})
NavRow({
icon: $r('app.media.ic_devices_multi'),
title: '接入的设备',
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
value: this.deviceCount.toString() + ' 台',
showDivider: false,
selected: this.isWide && this.activeSub === SUB_LIST,
onTap: () => {
this.open(SUB_LIST);
},
})
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

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 解码(与聊天图片附件同一条已验证通路),
* 成功与失败都能落 hilog,Image 只负责画一张现成的 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, 'HologramAgent', '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, 'HologramAgent', 'bg decoded ok');
} catch (e) {
// 失败必须留痕:否则表现就是"设置了但没生效",无从定位
const err = e as BusinessError;
hilog.error(0x0000, 'HologramAgent', '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,87 @@
/**
* MotionBase —— 全局通用动效的"父组件"。
*
* 目标:把各个组件重复实现的按压反馈收拢到这唯一一个父组件里,
* 全局统一手感(时长 token + EaseOut 曲线 + 形变幅度)。
* 公共组件(NavRow / StatusSummaryCard / AttachmentCard / 插件卡片 …)
* 在自己的 build 根节点接入 MotionBase,通用按压动效即随父组件生效,
* 子组件无需再各自维护 @State pressed + scale + animation + onTouch 四件套。
*
* 特有动画(多态扩展):留在各组件内部。例如聊天输入框向上弹出
* (ChatPage 的 inputMultiLine 上移 + Curve.Friction 展开)、加号菜单浮起、
* 折叠面板展开、toast 进出场——这些是"某个组件专属"的动效,
* 不应塞进父组件,由各组件按需声明。
*
* ArkUI V1 的 @Component struct 不能继承另一位 struct 的 build,
* 这里用「组合」表达继承:调用组件把内容经唯一内容插槽传给 MotionBase,
* MotionBase 在包装节点上统一挂通用动效修饰器。
*
* 通用动效契约:
* - 按压反馈:pressEnabled 时整容器 scale → pressScale,快速回弹。
* V1 自带按压态在玻璃/透明底上几乎不可见,这里统一补足;
* scale 只作用于容器自身,不侵入子节点外观。
* - onPress 只是按压瞬时的轻量钩子(如提前高亮),
* 真正的导航/提交语义仍由调用组件的 onClick(点击释放)负责,
* 二者不耦合,避免"按下即触发"的手感偏差。
*
* 用法:
* MotionBase({ pressEnabled: true }) {
* // 实际内容;外层 onClick 由调用组件自己绑在内容上
* }
*
* 注意:包装节点默认 width('100%')。像聊天气泡里的附件卡那样需要
* 「内容自适应宽度」的组件传 fillWidth: false,否则会被撑满整行。
*/
import { ANIM_FAST, PRESS_SCALE } from '../common/Constants';
@Component
export struct MotionBase {
/** 内容插槽:调用组件把实际内容传进来 */
@BuilderParam content: () => void;
/** 是否启用按压反馈(默认关,纯展示容器传 false 零开销) */
@Prop pressEnabled: boolean = false;
/** 按下的瞬间回调(可选):只做轻量即时反馈,不要在这里做导航/提交 */
onPress?: () => void;
/** 按压缩放幅度,默认全局 PRESS_SCALE */
@Prop pressScale: number = PRESS_SCALE;
/** 按压时整体透明度(1 = 不启用透明度反馈) */
@Prop pressOpacity: number = 1;
/** 包装节点是否撑满父宽;false = 由内容决定宽度(气泡内的卡片必须传 false) */
@Prop fillWidth: boolean = true;
/** 在 Row/Column 中参与剩余空间分配的权重;0 = 不参与(等分排列的按钮传 1) */
@Prop flexWeight: number = 0;
/** 按压态:只对容器自身 scale 生效 */
@State private pressed: boolean = false;
build() {
Column() {
this.content()
}
.width(this.fillWidth && this.flexWeight <= 0 ? '100%' : undefined)
.layoutWeight(this.flexWeight)
.alignItems(HorizontalAlign.Start)
// ===== 通用动效(父组件统一声明,子组件继承) =====
// 按压回弹:pressed 驱动 scale 增量,.animation 在下方覆盖该增量属性
.scale({
x: this.pressed ? this.pressScale : 1,
y: this.pressed ? this.pressScale : 1,
})
.opacity(this.pressed ? this.pressOpacity : 1)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onTouch((e: TouchEvent) => {
if (!this.pressEnabled) {
return;
}
if (e.type === TouchType.Down) {
this.pressed = true;
const cb = this.onPress;
if (cb !== undefined) {
cb();
}
} else if (e.type === TouchType.Up || e.type === TouchType.Cancel) {
this.pressed = false;
}
})
}
}

View File

@ -0,0 +1,215 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { ANIM_FAST, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from './MotionBase';
/**
* 顶栏高度(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() {
// 按压缩放交给 MotionBase(父组件统一手感);
// fillWidth: false —— 悬浮区靠右对齐,包装节点必须按内容 42vp 收窄。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button() {
Image(this.icon)
.width(18)
.height(18)
.fillColor(this.accent ? this.palette().accent : this.palette().textSecondary)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
}
.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: ANIM_SLOW, curve: Curve.EaseOut })
}
}

View File

@ -0,0 +1,291 @@
/**
* 插件详情(二级页面内容)。
*
* 从 pages/PluginsPage.ets 抽出。
* WebGUI 这里只有一个 JSON.stringify 的 <pre>,
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 配置 / 操作。
*/
import { PluginRow, PluginDetail } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM,
ANIM_FAST, COLOR_ERROR } from '../common/Constants';
import { pluginDetailStatusLine, pluginStatusColor } from '../common/PluginStatus';
import { PlainCard } from './SubPage';
import { SettingsEditor } from './SettingsEditor';
@Component
export struct PluginDetailPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop detail: PluginDetail;
/** 当前查看详情的插件名(可能还没拿到 detail.name) */
@Prop activeName: string = '';
@Prop busy: boolean = false;
@Prop errorText: string = '';
/** 一级列表里对应的那一行;取不到详情时用它兜底,也为操作按钮提供状态 */
@Prop row: PluginRow | undefined = undefined;
onToggle?: () => void;
onRemove?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
// ---- 取值助手: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 isBuiltin(): boolean {
const r: PluginRow | undefined = this.row;
return r !== undefined ? !r.external : false;
}
private isDisabled(): boolean {
const r: PluginRow | undefined = this.row;
return r !== undefined ? r.disabled : false;
}
private tools(): string[] {
const r: PluginRow | undefined = this.row;
if (r === undefined) {
return [];
}
return r.tools ?? [];
}
private statusLine(): string {
const r: PluginRow | undefined = this.row;
return r !== undefined ? pluginDetailStatusLine(r, this.detail.deprecated) : '未加载';
}
private statusColor(): string {
const r: PluginRow | undefined = this.row;
return r !== undefined ? pluginStatusColor(r, this.palette().textMuted) : 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
Badges() {
Row({ space: 6 }) {
Circle({ width: 7, height: 7 })
.fill(this.statusColor())
Text(this.statusLine())
.fontSize(12)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/** 工具清单来自一级列表已合并的 kernel.tools(按 plugin 归属) */
@Builder
ToolsCard() {
if (this.tools().length > 0) {
PlainCard({ caption: '注册的工具 (' + this.tools().length.toString() + ')' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.tools(), (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
ActionsCard() {
if (this.activeName.length > 0) {
PlainCard({ caption: '操作' }) {
Row() {
Button(this.isDisabled() ? '启用' : '禁用')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({
width: 1,
color: this.isDisabled()
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
})
.fontColor(this.isDisabled()
? this.palette().textSecondary : '#D99A2B')
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => {
const cb: (() => void) | undefined = this.onToggle;
if (cb !== undefined) {
cb();
}
})
Blank()
if (!this.isBuiltin()) {
Button('卸载')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor(COLOR_ERROR)
.onClick(() => {
const cb: (() => void) | undefined = this.onRemove;
if (cb !== undefined) {
cb();
}
})
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
.width('100%')
}
}
}
build() {
// 单一根节点:与原来作为 @Builder 直接铺在 SubPageLayer 的 Column 里同构,
// 保持 alignItems Start,避免文本被默认居中对齐。
Column() {
if (this.busy) {
Row() {
LoadingProgress()
.width(26)
.height(26)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 30, bottom: 30 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (this.errorText.length > 0) {
Text(this.errorText)
.fontSize(12)
.fontColor(COLOR_ERROR)
.padding({ left: 4, bottom: 12 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
// 概览卡:名称、版本、状态徽标、描述
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.Badges()
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.ToolsCard()
// 插件配置: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.ActionsCard()
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,166 @@
/**
* 插件一级列表(列表卡 + 三种占位态)。
*
* 从 pages/PluginsPage.ets 抽出:列表只负责"选谁",
* 描述/工具/启停全部下沉到详情页。
*/
import { PluginRow } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD,
ANIM_FAST, ANIM_ENTER } from '../common/Constants';
import { pluginRowSubtitle, pluginStatusColor } from '../common/PluginStatus';
import { noConnectionMessage } from '../common/UserError';
import { MotionBase } from './MotionBase';
/**
* 单个插件行卡。
*
* 外层 Column 只为承载 transition:.transition() 不能直接挂在自定义组件
* 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
* 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
*/
@Component
export struct PluginListCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop plugin: PluginRow;
/** 是否当前选中(宽屏高亮左侧列表项)。单独用基本类型传,选中态变更才能触发更新 */
@Prop active: boolean = false;
onTap?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Column() {
MotionBase({ pressEnabled: true }) {
Row() {
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(this.plugin.name)
.fontSize(15)
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.active ? this.palette().accent : this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
if (this.plugin.version !== undefined && this.plugin.version.length > 0) {
Text('v' + this.plugin.version)
.fontSize(10)
.fontColor(this.palette().textMuted)
}
}
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
Row({ space: 6 }) {
Circle({ width: 6, height: 6 })
.fill(pluginStatusColor(this.plugin, this.palette().textMuted))
Text(pluginRowSubtitle(this.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.active ? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.draggable(false)
}
.width('100%')
.padding(14)
.borderRadius(RADIUS_LG)
.backgroundColor(this.active ? this.palette().accentBg : this.palette().bgCard)
.border({
width: 1,
color: this.active ? this.palette().accent : this.palette().glassBorder,
})
// 选中态的底色/描边渐变:MotionBase 的 .animation() 到不了这里
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.alignItems(VerticalAlign.Center)
.onClick(() => {
const cb: (() => void) | undefined = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
}
.width('100%')
.margin({ bottom: 10 })
// ForEach key 含 loaded/disabled:启停会整行重挂载,靠 transition 变成交叉淡入
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
}
/**
* 一级列表整体:占位态 + 行卡 ForEach。
* 取数留在页面里,这里只吃数据。
*/
@Component
export struct PluginListView {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop plugins: PluginRow[] = [];
@Prop busy: boolean = false;
@Prop activeName: string = '';
@Prop hasConn: boolean = false;
onSelect?: (name: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Column() {
// ── 加载中 / 未配置 / 空列表三种占位态 ──
if (this.busy && this.plugins.length === 0) {
LoadingProgress()
.width(32)
.height(32)
.color(this.palette().accent)
.margin({ top: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!this.hasConn) {
Text(noConnectionMessage())
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!this.busy && this.plugins.length === 0 && this.hasConn) {
Text('暂无已加载插件')
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
ForEach(this.plugins, (plugin: PluginRow) => {
PluginListCard({
plugin: plugin,
active: this.activeName === plugin.name,
onTap: () => {
const cb: ((name: string) => void) | undefined = this.onSelect;
if (cb !== undefined) {
cb(plugin.name);
}
},
})
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
}
.width('100%')
}
}

View File

@ -0,0 +1,65 @@
/**
* 安装表单悬浮卡。
*
* 从 pages/PluginsPage.ets 抽出(浮在内容之上的独立图层,与列表/详情无关)。
* 轻提示条与设置页共用,见 components/ToastBar.ets。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM,
ANIM_ENTER } from '../common/Constants';
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
@Component
export struct PluginInstallForm {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop url: string = '';
onUrlChange?: (v: string) => void;
onInstall?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Row() {
TextInput({ placeholder: '.hmap 包下载 URL', text: this.url })
.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) => {
const cb: ((v: string) => void) | undefined = this.onUrlChange;
if (cb !== undefined) {
cb(v);
}
})
Button('安装')
.height(36)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor('#FFFFFF')
.margin({ left: 6 })
.onClick(() => {
const cb: (() => void) | undefined = this.onInstall;
if (cb !== undefined) {
cb();
}
})
}
.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)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
}

View File

@ -0,0 +1,385 @@
import {
ThemePalette, DARK_PALETTE, LIGHT_PALETTE,
RADIUS_SM, RADIUS_MD, ANIM_FAST,
COLOR_ACCENT, COLOR_EMERALD, COLOR_FROST_300,
} from '../common/Constants';
import { statusStore, RuntimeSnapshot, RuntimeQueue, K_REV } from '../common/StatusStore';
import {
stageTrail, StageEvent, StageGroup, STAGE_GROUPS, phaseGroup,
K_STAGE_PHASE, K_STAGE_REV,
} from '../common/StageTrail';
/** 四级中断 + 排队的配色:与 WebUI 总览同一套(级别色贯穿框头、槽位、描边) */
const LV_COLORS: string[] = ['#A3BE8C', '#4A90D9', '#0A59F7', '#FFA657', '#FF5C7A'];
/** 阶段管道里的「工具」格(循环格):一轮内可能调几十次,只露最新一条 */
const TOOL_GROUP: number = 2;
/** 索引 = lv(0 排队 / 1 L1 / 2 L2 / 3 L3 / 4 L4) */
function lvColor(lv: number): string {
if (lv < 0 || lv > 4) {
return COLOR_ACCENT;
}
return LV_COLORS[lv];
}
/**
* 运行态面板:阶段管道 + 中断队列。
*
* 与 WebUI / 桌面版同一套设计语言:**等大表框**。
* 此前鸿蒙端完全没有运行态展示(「状态」Tab 已并入设置页 + 二级明细),
* 这里补在二级明细页顶部。
*
* 两块数据来源不同:
* - 队列 / 计数:StatusStore 轮询 /runtime,靠 K_REV 通知;
* - 阶段 + 本轮轨迹:StageTrail 由 SSE 的 stage 事件喂入,靠 K_STAGE_REV 通知。
*/
@Component
export struct RuntimePanel {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 宽屏(平板/折叠)5 框一行;手机 3+2。窄屏硬塞 5 框会把标签挤成省略号。 */
@StorageProp('isWideScreen') private isWide: boolean = false;
@StorageProp(K_REV) @Watch('onStatusRev') private statusRev: number = 0;
@StorageProp(K_STAGE_REV) @Watch('onStageRev') private stageRev: number = 0;
@StorageProp(K_STAGE_PHASE) private phase: string = '';
@State private snap: RuntimeSnapshot | undefined = undefined;
/** 格槽下标:ArkUI 的 ForEach 遍历的是数组,所以把「几格」摊成下标数组 */
@State private slotIdx: number[] = [];
@State private trail: StageEvent[] = [];
aboutToAppear(): void {
this.pull();
}
private onStatusRev(): void {
this.pull();
}
private onStageRev(): void {
this.trail = stageTrail.snapshot();
}
private pull(): void {
const s: RuntimeSnapshot | undefined = statusStore.getRuntime();
this.snap = s;
if (s !== undefined) {
const idx: number[] = [];
for (let i = 0; i < s.slots; i++) {
idx.push(i);
}
this.slotIdx = idx;
} else {
this.slotIdx = [];
}
this.trail = stageTrail.snapshot();
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** 该阶段本轮发生的事件 */
private eventsOf(group: number): StageEvent[] {
const out: StageEvent[] = [];
for (let i = 0; i < this.trail.length; i++) {
const e: StageEvent = this.trail[i];
if (e.group === group) {
out.push(e);
}
}
return out;
}
/** 该阶段本轮事件的**最后一条**(最新)。没有则 undefined。 */
private latestEvent(group: number): StageEvent | undefined {
let out: StageEvent | undefined = undefined;
for (let i = 0; i < this.trail.length; i++) {
const e: StageEvent = this.trail[i];
if (e.group === group) {
out = e;
}
}
return out;
}
/** 该阶段本轮事件条数合计(同一工具连调会累加到 count 上) */
private totalCount(group: number): number {
let n: number = 0;
for (let i = 0; i < this.trail.length; i++) {
const e: StageEvent = this.trail[i];
if (e.group === group) {
n += e.count;
}
}
return n;
}
@Builder
tile(label: string, value: string, warn: boolean) {
Column({ space: 2 }) {
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(warn ? this.palette().accent : this.palette().textPrimary)
.maxLines(1)
Text(label)
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
}
/** 阶段管道的一格 */
@Builder
stageCell(g: StageGroup, active: boolean) {
Column({ space: 6 }) {
Row({ space: 5 }) {
// 用一个几何圆点而非图标:本项目没有为「阶段」准备的图形资源,
// 而猜 sys.media.* 名称会直接编译不过;也不允许用 emoji 充当图标。
// 不用 Circle().fill():那是 SDK 26 起的 API,本工程兼容版本是 6.1.1(24)。
Row()
.width(7)
.height(7)
.borderRadius(4)
.backgroundColor(active ? this.palette().accent : this.palette().textMuted)
Text(g.label)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(active ? this.palette().accent : this.palette().textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
// 本阶段本轮发生的事件;没有就给一个弱化的「无」,让框不空着。
//
// 「工具」格是循环格:一轮里可能调几十次工具/输出通道。把每一次都追加成
// 一行,这格会被撑成长条,反而看不出「现在在调什么」。所以它只保留**最新
// 一条**,右侧给本轮累计次数(与 WebUI/桌面版同一口径)。
Column({ space: 3 }) {
if (g.group === TOOL_GROUP) {
if (this.eventsOf(g.group).length > 0) {
Row({ space: 3 }) {
Text(this.latestEvent(g.group)?.short ?? '')
.fontSize(9)
.fontColor(this.latestEvent(g.group)?.kind === 'output' ? this.palette().accent
: (this.latestEvent(g.group)?.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('x' + this.totalCount(g.group).toString())
.fontSize(9)
.fontColor(this.palette().textMuted)
}
.width('100%')
} else {
Text('无')
.fontSize(9)
.fontColor(this.palette().textMuted)
.opacity(0.5)
}
} else {
ForEach(this.eventsOf(g.group), (e: StageEvent) => {
Row({ space: 3 }) {
Text(e.short)
.fontSize(9)
.fontColor(e.kind === 'output' ? this.palette().accent
: (e.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (e.count > 1) {
Text('x' + e.count.toString())
.fontSize(9)
.fontColor(this.palette().textMuted)
}
}
.width('100%')
}, (e: StageEvent, i: number) => i.toString() + ':' + e.short)
if (this.eventsOf(g.group).length === 0) {
Text('无')
.fontSize(9)
.fontColor(this.palette().textMuted)
.opacity(0.5)
}
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
.borderRadius(RADIUS_SM)
.backgroundColor(active ? this.palette().accentBg : this.palette().bgHover)
.border({ width: 1, color: active ? this.palette().accent : this.palette().glassBorder })
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
}
/** 中断队列的一格 */
@Builder
queueCell(q: RuntimeQueue) {
Column({ space: 5 }) {
Row({ space: 4 }) {
Text(q.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(lvColor(q.lv))
.maxLines(1)
Text(q.desc)
.fontSize(9)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
Text(q.depth.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.maxLines(1)
// 格槽:固定可见的「车位」。用进度条时深度为 0 宽度就是 0,
// 整格只剩文字,看上去就是「这块空着」。
Row({ space: 2 }) {
ForEach(this.slotIdx, (i: number) => {
Row()
.layoutWeight(1)
.height(11)
.borderRadius(2)
.backgroundColor(i < q.depth ? lvColor(q.lv) : this.palette().bgHover)
}, (i: number) => q.lv.toString() + '-' + i.toString())
}
.width('100%')
if (q.lv > 0) {
Text(q.registered.toString() + ' 登记 · ' + q.preempted.toString() + ' 抢占')
.fontSize(9)
.fontColor(this.palette().textMuted)
.maxLines(1)
} else {
Text('无级别')
.fontSize(9)
.fontColor(this.palette().textMuted)
.maxLines(1)
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.border({
width: 1,
color: q.depth > 0 ? lvColor(q.lv) : this.palette().glassBorder,
style: q.lv === 0 ? BorderStyle.Dashed : BorderStyle.Solid,
})
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
}
build() {
Column() {
Text('运行态')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.margin({ bottom: 8 })
if (this.snap === undefined) {
Text('运行态数据不可用')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ top: 4, bottom: 4 })
} else {
// 四个数字块
Row({ space: 8 }) {
this.tile('排队', this.snap.ready.toString(), this.snap.ready > 0)
this.tile('中断', this.snap.pending.toString(), this.snap.pending > 0)
this.tile('栈', this.snap.stack.toString() + '/' + this.snap.maxStack.toString(),
this.snap.stack > 0)
this.tile('子代理', this.snap.subagents.toString(), false)
}
.width('100%')
// 阶段管道:等大表框,事件落在所属阶段那一格
Text('阶段管道')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.margin({ top: 14, bottom: 6 })
if (this.isWide) {
Row({ space: 6 }) {
ForEach(STAGE_GROUPS, (g: StageGroup) => {
this.stageCell(g, phaseGroup(this.phase) === g.group)
}, (g: StageGroup) => 'w' + g.group.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
} else {
Row({ space: 6 }) {
ForEach(STAGE_GROUPS.slice(0, 3), (g: StageGroup) => {
this.stageCell(g, phaseGroup(this.phase) === g.group)
}, (g: StageGroup) => 'n0' + g.group.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
Row({ space: 6 }) {
ForEach(STAGE_GROUPS.slice(3), (g: StageGroup) => {
this.stageCell(g, phaseGroup(this.phase) === g.group)
}, (g: StageGroup) => 'n1' + g.group.toString())
// 占位:第二行只有 2 格,补一格位置让框宽与第一行对齐
Row().layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
.margin({ top: 6 })
}
// 中断队列:五个等大表框(L4/L3/L2/L1 + 排队)
Text('队列')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.margin({ top: 14, bottom: 6 })
if (this.isWide) {
Row({ space: 6 }) {
ForEach(this.snap.queues, (q: RuntimeQueue) => {
this.queueCell(q)
}, (q: RuntimeQueue) => 'wq' + q.lv.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
} else {
Row({ space: 6 }) {
ForEach(this.snap.queues.slice(0, 3), (q: RuntimeQueue) => {
this.queueCell(q)
}, (q: RuntimeQueue) => 'nq0' + q.lv.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
Row({ space: 6 }) {
ForEach(this.snap.queues.slice(3), (q: RuntimeQueue) => {
this.queueCell(q)
}, (q: RuntimeQueue) => 'nq1' + q.lv.toString())
Row().layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
.margin({ top: 6 })
}
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(16)
.margin({ bottom: 14 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
}
}

View File

@ -0,0 +1,197 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../common/Constants';
import { GradientBackground } from './GradientBackground';
import { PageTopBar } from './PageTopBar';
import { MotionBase } from './MotionBase';
import { looksLikeHtml, screensueWebData } from '../common/ScreensueHtml';
import { webview } from '@kit.ArkWeb';
/**
* agent 主动推送的前台内容页。
*
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
* 从而让左侧一级页面和主导航保持可见、可操作。
*
* 内容可能是纯文本,也可能是 HTML(服务端两侧协议都允许,见 ScreensueHtml)。
*
* HTML 走 **Web 组件**(用户明确要求):agent 推的常是完整文档 —— 带 <style>
* CSS 动画、内联 <svg>、radial-gradient 背景。RichText 只认极小标签子集,
* 对这些一律不渲染,实测只能看到满屏源码。
*
* 安全:内容来自 agent(第三方),所以显式关掉 JS 与本地文件访问 ——
* 注意 **javaScriptAccess 默认是 true**,不显式关掉等于让远端内容在客户端执行脚本。
* 纯文本仍走 Text(无需开销,也不该把文本塞进 Web)。
*/
@Component
export struct ScreensuePage {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop pushedText: string = '';
@Prop countdown: number = 0;
onClose: () => void = () => {
};
/** 内容是不是 HTML(决定走 Web 还是 Text)。 */
private htmlMode(): boolean {
return looksLikeHtml(this.pushedText);
}
/** HTML 的 base64 载荷(空串表示不是 HTML)。 */
private webData(): string {
return screensueWebData(this.pushedText, this.isDark);
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
GradientBackground()
Scroll() {
Column({ space: 14 }) {
Row({ space: 8 }) {
Circle({ width: 8, height: 8 })
.fill(this.palette().accent)
Text('agent 推送')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
Blank()
if (this.countdown > 0) {
Text(this.countdown.toString() + 's')
.fontSize(12)
.fontColor(this.palette().textMuted)
} else {
Text('常驻')
.fontSize(12)
.fontColor(this.palette().textMuted)
}
}
.width('100%')
Column() {
if (this.htmlMode()) {
// HTML:整篇交给 Web 渲染(base64 loadData,见 screensueWebData)。
// 高度固定 420vp:Web 不参与父级自适应测量,给 height('100%')
// 会在 Scroll 里塌成 0。内容区本身可滚。
ScreenWebView({ data: this.webData(), isDark: this.isDark })
.width('100%')
.height(420)
} else {
Text(this.pushedText)
.fontSize(16)
.lineHeight(25)
.fontColor(this.palette().textPrimary)
.width('100%')
.textAlign(TextAlign.Start)
.copyOption(CopyOptions.LocalDevice)
}
}
.width('100%')
.padding(18)
.borderRadius(18)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 18, right: 18, top: 82, bottom: 96 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.align(Alignment.Top)
PageTopBar({ title: '推送内容' })
Row() {
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button('关闭')
.height(42)
.padding({ left: 22, right: 22 })
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.backgroundColor(this.palette().accent)
.borderRadius(21)
.onClick(() => {
this.onClose();
})
}
}
.width('100%')
.padding({ left: 18, right: 18, bottom: 22 })
.justifyContent(FlexAlign.End)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 18 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
.width('100%')
.height('100%')
.backgroundColor(this.palette().bgPrimary)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 承载 screensue HTML 的 Web 视图(独立组件,便于按内容变化重建控制器)。
*
* 为什么单开一个组件而不是直接在 ScreensuePage 里放 Web:
* `WebviewController` 与 Web 组件是一对一绑定的,必须等组件挂载(onControllerAttached)
* 才能真正 loadData;把它隔离在这里,ScreensuePage 只管布局与倒计时。
*/
@Component
struct ScreenWebView {
// @Watch 挂在这里是必须的:ScreensuePage 在 `if (screensueVisible)` 里常驻,
// 第二次 screensue 只会改这个 @Prop 而不会重建组件,而 onControllerAttached
// 只在挂载时触发一次 —— 不 watch 就会一直显示上一条推送的内容。
// 实测:连推两条不同 HTML,倒计时变了、Web 里还是旧画面。
@Prop @Watch('onDataChanged') data: string = '';
@Prop isDark: boolean = true;
private controller: webview.WebviewController = new webview.WebviewController();
/** 控制器是否已与 Web 组件关联(过早 loadData 会抛 17100001)。 */
private attached: boolean = false;
/** data 变化时重新加载(组件不重建,必须显式刷新)。 */
onDataChanged(): void {
if (this.attached) {
this.load();
}
}
build() {
Web({ src: '', controller: this.controller })
// ★ 内容来自 agent(第三方):显式关闭脚本与本地文件访问。
// javaScriptAccess 的默认值是 true,不写这一行等于放任远端内容执行脚本。
.javaScriptAccess(false)
.fileAccess(false)
.domStorageAccess(false)
.onlineImageAccess(false)
.imageAccess(true) // 保留内联/数据 URI 图片(不联网)
.zoomAccess(false) // 禁手势缩放,避免与外层滚动打架
.horizontalScrollBarAccess(false)
.verticalScrollBarAccess(false)
.darkMode(WebDarkMode.Off)
.backgroundColor(Color.Transparent)
// 控制器挂载完才 loadData:过早调用会抛 17100001(控制器未与组件关联)。
.onControllerAttached(() => {
// 挂载完成才允许 loadData;此前的变更由 onDataChanged 记着,这里补一次。
this.attached = true;
this.load();
})
.width('100%')
.height('100%')
}
/** 以 base64 整篇加载(空串直接跳过,避免 Web 显示错误页)。 */
private load(): void {
if (this.data.length === 0) {
return;
}
try {
this.controller.loadData(this.data, 'text/html', 'base64');
} catch (e) {
// 加载失败不该把整页带崩:保持空白,用户仍能看到顶栏与关闭按钮。
}
}
}

View File

@ -0,0 +1,389 @@
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';
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } 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 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (this.err.length > 0) {
Text(this.err)
.fontSize(12)
.fontColor(COLOR_ERROR)
.width('100%')
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
if (!this.loading && this.err.length === 0 && this.entries.length === 0) {
Text(this.emptyHint)
.fontSize(12)
.fontColor(this.palette().textMuted)
.width('100%')
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
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 })
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
}
.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)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
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)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.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)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.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,215 @@
/**
* 单个配置项卡片。
*
* 从 pages/SettingsPage.ets 抽出:按 type 分发控件(bool/select/password/text/其他),
* 值的保存与「未保存」标记交回页面(页面上持有 valuesStore 与 entries)。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM,
ANIM_FAST, ANIM_NORMAL } from '../common/Constants';
import { SettingEntry } from '../common/SettingsModel';
@Component
export struct SettingsEntryCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop entry: SettingEntry;
/** bool 开关 / select 选项:值已知,直接落库 */
onSaveValue?: (key: string, value: string) => void;
/** 输入类控件的保存:由页面取该 key 的最新编辑值再落库 */
onSaveCurrent?: (key: string) => void;
/** 输入框内容变化:只更新本地标记,不请求 */
onEdit?: (key: string, value: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private saveValue(value: string): void {
const cb: ((key: string, value: string) => void) | undefined = this.onSaveValue;
if (cb !== undefined) {
cb(this.entry.key, value);
}
}
private saveCurrent(): void {
const cb: ((key: string) => void) | undefined = this.onSaveCurrent;
if (cb !== undefined) {
cb(this.entry.key);
}
}
private editValue(value: string): void {
const cb: ((key: string, value: string) => void) | undefined = this.onEdit;
if (cb !== undefined) {
cb(this.entry.key, value);
}
}
build() {
Column() {
Row() {
Text(this.entry.displayName)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(this.entry.dirty ? '未保存' : this.entry.type)
.fontSize(10)
.fontColor(this.entry.dirty ? '#D99A2B' : this.palette().textMuted)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.entry.dirty ? 'rgba(217, 154, 43, 0.16)' : this.palette().bgHover)
}
.width('100%')
Text(this.entry.key)
.fontSize(10)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 1 })
if (this.entry.description.length > 0) {
Text(this.entry.description)
.fontSize(11)
.fontColor(this.palette().textSecondary)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
}
// control row per type
if (this.entry.type === 'bool') {
Row() {
Text(this.entry.value === 'true' ? 'true' : 'false')
.fontSize(12)
.fontColor(this.entry.value === 'true' ? '#17A964' : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Blank()
Toggle({ type: ToggleType.Switch, isOn: this.entry.value === 'true' })
.selectedColor(this.palette().accent)
.onChange((on: boolean) => {
this.saveValue(on ? 'true' : 'false');
})
}
.width('100%')
.margin({ top: 8 })
} else if (this.entry.type === 'select' && this.entry.options.length > 0) {
Flex({
direction: FlexDirection.Row,
justifyContent: FlexAlign.Start,
alignItems: ItemAlign.Center,
wrap: FlexWrap.Wrap,
}) {
ForEach(this.entry.options, (opt: string) => {
Button(opt)
.height(26)
.fontSize(11)
.margin({ right: 6, bottom: 6 })
.backgroundColor(this.entry.value === opt ? this.palette().accent : this.palette().bgHover)
.fontColor(this.entry.value === opt ? Color.White : this.palette().textSecondary)
// 选中项迁移:底色与字色一起过渡,避免整排选项同时硬切
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => {
this.saveValue(opt);
})
}, (opt: string) => opt)
}
.width('100%')
.margin({ top: 8 })
} else if (this.entry.type === 'password') {
Row() {
TextInput({ text: this.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(InputType.Password)
.layoutWeight(1)
.onChange((v: string) => {
this.editValue(v);
})
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
.fontColor(Color.White)
.margin({ left: 8 })
.onClick(() => {
this.saveCurrent();
})
}
.width('100%')
.margin({ top: 8 })
} else if (this.entry.type === 'text') {
TextArea({ text: this.entry.value })
.width('100%')
.fontSize(13)
.fontColor(this.palette().textPrimary)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.constraintSize({ minHeight: 60, maxHeight: 200 })
.margin({ top: 8 })
.onChange((v: string) => {
this.editValue(v);
})
Row() {
Blank()
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
.fontColor(Color.White)
.onClick(() => {
this.saveCurrent();
})
}
.width('100%')
.margin({ top: 6 })
} else {
// string / int / duration
Row() {
TextInput({ text: this.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 })
.layoutWeight(1)
.onChange((v: string) => {
this.editValue(v);
})
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
.fontColor(Color.White)
.margin({ left: 8 })
.onClick(() => {
this.saveCurrent();
})
}
.width('100%')
.margin({ top: 8 })
}
}
.width('100%')
.padding(14)
.borderRadius(RADIUS_MD)
.backgroundColor(this.palette().bgCard)
.border({
width: 1,
color: this.entry.dirty ? '#D99A2B' : this.palette().glassBorder,
})
.margin({ bottom: 10 })
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,95 @@
/**
* 设置一级页的页面骨架:滚动区 + 顶栏 + 悬浮区 + 轻提示。
*
* 从 pages/SettingsPage.ets 抽出:页面本身只剩数据流与导航表,
* 这一层是纯布局 —— 所有计数/文案都由页面算好传进来。
*/
import { handleNavOnScroll } from '../common/NavBarController';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { PageTopBar, NavFloatOverlay, NavFloatRow } from './PageTopBar';
import { SettingsRootEntries } from './SettingsRootEntries';
import { ToastBar } from './ToastBar';
@Component
export struct SettingsHome {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 一级入口列表所需的计数/文案(见 SettingsRootEntries) */
@Prop connCount: number = 0;
@Prop connName: string = '';
@Prop sectionCount: number = 0;
@Prop coreKeys: number = 0;
@Prop busy: boolean = false;
@Prop errorText: string = '';
@Prop themeLabel: string = '';
@Prop activeSub: string = '';
@Prop isWide: boolean = false;
/** 保存进行中:悬浮区显示转圈 */
@Prop savingCount: number = 0;
@Prop toastMsg: string = '';
@Prop toastIsError: boolean = false;
onOpen?: (id: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Scroll() {
Column() {
SettingsRootEntries({
connCount: this.connCount,
connName: this.connName,
sectionCount: this.sectionCount,
coreKeys: this.coreKeys,
busy: this.busy,
errorText: this.errorText,
themeLabel: this.themeLabel,
activeSub: this.activeSub,
isWide: this.isWide,
onOpen: (id: string) => {
const cb: ((id: string) => void) | undefined = this.onOpen;
if (cb !== undefined) {
cb(id);
}
},
})
}
.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%')
PageTopBar({ title: '设置' })
// 一级悬浮区:仅在保存进行中显示一个转圈;
// 已保存/未保存的常驻徽标按用户要求去掉(保存本来就是即时的,不需要状态吊牌)
NavFloatOverlay({ tab: 3 }) {
NavFloatRow() {
if (this.savingCount > 0) {
LoadingProgress()
.width(14)
.height(14)
.color(this.palette().accent)
}
}
}
ToastBar({ msg: this.toastMsg, isError: this.toastIsError })
}
.width('100%')
.height('100%')
.backgroundColor(Color.Transparent)
}
}

View File

@ -0,0 +1,100 @@
/**
* 设置一级页的入口列表。
*
* 从 pages/SettingsPage.ets 抽出:纯展示 + 跳转回调,
* 所有计数/文案由页面算好传进来(页面才是这些状态的持有者)。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { SUB_STATUS, SUB_CONNECTIONS, SUB_BACKEND, SUB_SECTION, SUB_APPEARANCE } from '../common/SettingsModel';
import { StatusSummaryCard } from './StatusCards';
import { NavGroup, NavRow } from './SubPage';
@Component
export struct SettingsRootEntries {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 连接配置条数 */
@Prop connCount: number = 0;
/** 当前生效连接的展示名 */
@Prop connName: string = '';
/** 后端配置:分类数 / 核心项数 / 是否加载中 / 错误文案 */
@Prop sectionCount: number = 0;
@Prop coreKeys: number = 0;
@Prop busy: boolean = false;
@Prop errorText: string = '';
/** 当前主题的中文名(一级行右侧摘要值) */
@Prop themeLabel: string = '';
/** 宽屏分栏时用来高亮右栏对应的入口行 */
@Prop activeSub: string = '';
@Prop isWide: boolean = false;
onOpen?: (id: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private open(id: string): void {
const cb: ((id: string) => void) | undefined = this.onOpen;
if (cb !== undefined) {
cb(id);
}
}
build() {
Column() {
// 运行状态摘要(原「状态」Tab):整卡可点,进入明细二级页
StatusSummaryCard({
onTap: () => {
this.open(SUB_STATUS);
},
})
NavGroup({ caption: '连接' }) {
NavRow({
icon: $r('app.media.ic_link'),
title: '后端连接',
subtitle: this.connCount.toString() + ' 个连接配置',
value: this.connName,
selected: this.isWide && this.activeSub === SUB_CONNECTIONS,
onTap: () => {
this.open(SUB_CONNECTIONS);
},
})
NavRow({
icon: $r('app.media.ic_tune'),
title: '核心配置',
subtitle: this.sectionCount.toString() + ' 个分类 · ' + this.coreKeys.toString() + ' 项',
value: this.busy ? '加载中' : (this.errorText.length > 0 ? '不可用' : ''),
showDivider: false,
selected: this.isWide && (this.activeSub === SUB_BACKEND || this.activeSub === SUB_SECTION),
onTap: () => {
this.open(SUB_BACKEND);
},
})
}
NavGroup({ caption: '个性化' }) {
NavRow({
icon: $r('app.media.ic_theme'),
title: '外观',
subtitle: '主题与背景图',
value: this.themeLabel,
showDivider: false,
selected: this.isWide && this.activeSub === SUB_APPEARANCE,
onTap: () => {
this.open(SUB_APPEARANCE);
},
})
}
if (this.errorText.length > 0) {
Text(this.errorText)
.fontSize(12)
.fontColor('#E84026')
.padding({ left: 4, right: 4 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,332 @@
/**
* 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.
*
* 解析规则在 common/MarkdownParser.ets(已抽出,见那里);
* 本文件只负责把 MdBlock 渲染成 ArkUI 组件树。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { RADIUS_SM } from '../common/Constants';
import { MdBlock, MdSpan, parseBlocks, parseInline } from '../common/MarkdownParser';
@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,391 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM,
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
import { MotionBase } from './MotionBase';
import { RuntimePanel } from './RuntimePanel';
import {
statusStore, StatGroup, StatField, compactDuration,
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD,
} 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 = '-';
/** 内核名 · commit。与版本号分开一行:版本号本身没有内核身份。 */
@StorageProp(K_BUILD) private buildSub: 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() {
// 整卡按压反馈统一收进 MotionBase(父组件):这里是进明细页的唯一入口,
// 父组件负责 scale 回弹;导航语义仍由本卡自己的 onClick(点击释放)负责,
// 避免"按下即跳转"的误触手感。
MotionBase({ pressEnabled: true }) {
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)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
} 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 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} 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)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.draggable(false)
}
.width(48)
.height(48)
.borderRadius(RADIUS_SM)
.backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Column({ space: 3 }) {
Text(this.up ? '运行中' : '离线')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
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)
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)。
//
// 必须是**子组件**(@Prop 单向下发)而不是本组件里的 @Builder:
// ArkUI 的 @Builder 按值传参是“快照”语义,父组件重渲染时
// 不会用新值重跑 builder —— 实测:K_PLUGINS 已经是 35,
// 但 @Builder 里画的还是首次的 0,永远显示 '-'。
Row({ space: 10 }) {
KpiTile({ icon: $r('app.media.ic_plug'), label: '插件', value: this.plugins, tint: COLOR_ACCENT })
KpiTile({ icon: $r('app.media.ic_tool'), label: '工具', value: this.tools, tint: COLOR_CYAN })
}
.width('100%')
.margin({ top: 14 })
// 版本 + 「查看明细」提示,与 KPI 之间用分割线断开
Row() {
Text('版本')
.fontSize(12)
.fontColor(this.palette().textMuted)
Column() {
Text(this.version)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 内核名 + commit:光有版本号会分不清是哪个内核、哪次构建
Text(this.buildSub)
.fontSize(10)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.visibility(this.buildSub.length > 0 ? Visibility.Visible : Visibility.None)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.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;
}
}
/**
* 能力计数小卡(插件 / 工具)。
*
* 单独成组件而非 @Builder:@Builder 的按值参数不会随父组件重渲染而刷新,
* 数值会永远停在首次渲染的 0。@Prop 是单向下发,父组件因 @StorageProp
* 变化重渲染时,子组件拿到新值并重绘。
*/
@Component
struct KpiTile {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop icon: Resource = $r('app.media.ic_plug');
@Prop label: string = '';
@Prop value: number = 0;
@Prop tint: string = '';
build() {
Row({ space: 8 }) {
Image(this.icon)
.width(16)
.height(16)
.fillColor(this.tint)
.draggable(false)
Column({ space: 1 }) {
Text(this.value > 0 ? this.value.toString() : '-')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
Text(this.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 })
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
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 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
// 这里原本有一张"服务负载"卡,用两条容量条画插件/工具数量。
// 后端并不存在"插件上限/工具上限"这种容量概念,分母是两者取大值,
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
// 运行态面板(阶段管道 + 中断队列)放在明细页最前:
// 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」,
// 后者是进这个页面最先想看的。
RuntimePanel()
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 })
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}, (g: StatGroup) => g.title)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}

View File

@ -0,0 +1,364 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG,
ANIM_NORMAL } from '../common/Constants';
import { MotionBase } from './MotionBase';
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 {
if (AppStorage.has(KEY_SUBPAGE_OPEN)) {
AppStorage.set<boolean>(KEY_SUBPAGE_OPEN, open);
} else {
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 })
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.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) {
// 自定义组件不能直接挂 .transition()(会生成 __Common__ 包装节点),
// 所以各自套一层无 padding 的 Column,由它承载出入场动画,
// 让悬浮键与 400ms 平滑滑出的悬浮层不再"一帧硬切"。
Column() {
SubPageBackButton({
onTap: () => {
const cb = this.onBack;
if (cb !== undefined) {
cb();
}
},
})
}
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
if (this.showRefresh) {
Column() {
FloatIconButton({
icon: $r('app.media.ic_refresh'),
onTap: () => {
const cb = this.onRefresh;
if (cb !== undefined) {
cb();
}
},
})
}
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
}
}
}
.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() {
// 通用按压动画收进 MotionBase(父组件):scale 回弹统一。
// 选中态底色仍在这里管:它是 NavRow 特有外观,不归父组件。
MotionBase({ pressEnabled: true }) {
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)
// 父行 MotionBase 的 .animation() 到不了子节点,选中态字色自己缓动
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.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)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.draggable(false)
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.alignItems(VerticalAlign.Center)
// 选中态底色:写在 .border 之前,避免分割线宽度被纳入动画。
// MotionBase 的 .animation() 只作用在包装 Column 上,到不了这里,
// 选中高亮的底色迁移要本行自己声明。
.backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.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() {
// 玻璃底自带的按压变暗在 45% 透明度上几乎看不见,缩放反馈由 MotionBase 统一提供。
// fillWidth: false —— 悬浮区按内容宽度排列,不能撑满整行。
MotionBase({ pressEnabled: true, fillWidth: false }) {
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,48 @@
/**
* 右下角轻提示条(设置页与插件页共用)。
*
* 两处原本各写一份,只有"描边"这一处不同:设置页无描边,插件页有。
* 用 bordered 表达这个差异(width 0 的边框不占位、不可见)。
*
* 配色由调用方决定:颜色标志必须在调用方落 animateTo 之前先写好,
* 否则第一帧会用上一条 toast 的配色。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, ANIM_NORMAL } from '../common/Constants';
@Component
export struct ToastBar {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop msg: string = '';
@Prop isError: boolean = false;
/** 是否带一圈语义色描边(插件页用,设置页不用) */
@Prop bordered: boolean = false;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
if (this.msg.length > 0) {
Row() {
Text(this.msg)
.fontSize(13)
.fontColor(this.isError ? this.palette().toastErrorText : this.palette().toastText)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.isError ? this.palette().toastErrorBg : this.palette().toastBg)
.border({
width: this.bordered ? 1 : 0,
color: this.isError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 20 })
.margin({ bottom: 166 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
}

View File

@ -0,0 +1,144 @@
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';
import { startForegroundBridge, stopForegroundBridge } from '../common/DeviceBridgeSession';
/** 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('[HologramAgent] 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 {
stopForegroundBridge();
console.info('[HologramAgent] 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('[HologramAgent] main page loaded');
});
};
// 沉浸式窗口 + 主题化系统栏,消除真机顶部/底部白边
windowStage.getMainWindow((err: BusinessError, win: window.Window) => {
if (err.code !== 0) {
connStore.init(this.context).then(async () => {
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.reapplyStoredTheme();
startForegroundBridge(this.context);
startUI();
}).catch(() => {
startUI();
});
return;
}
try {
// 全屏沉浸:内容延伸到状态栏和导航栏区域
win.setWindowLayoutFullScreen(true);
} catch (e) {
console.warn('[HologramAgent] setWindowLayoutFullScreen failed: ' + (e as Error).message);
}
connStore.init(this.context).then(async () => {
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
console.info('[HologramAgent] connStore initialized');
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
this.reapplyStoredTheme();
this.applySystemBar();
startForegroundBridge(this.context);
startUI();
}).catch((e: Error) => {
console.error('[HologramAgent] 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('[HologramAgent] ability onWindowStageDestroy');
}
onForeground(): void {
startForegroundBridge(this.context);
console.info('[HologramAgent] ability onForeground');
}
onBackground(): void {
stopForegroundBridge();
console.info('[HologramAgent] ability onBackground');
}
}

View File

@ -0,0 +1,216 @@
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.seq)。
*
* 它是与后端增量查询(/chat/history?after=<seq>)对账的唯一定位符:
* 本地乐观消息没有 seq,服务端回显后靠 seq 认领并去重。
* 没有它就只能拿“正文内容”去重,一旦同一句话发两次就会误删。
*/
seq?: number;
/** 图片/文件附件(后端 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;
}
/**
* 规范化用户填写的后端地址。
*
* 用户习惯直接敲域名(homeagent.example.xyz)或粘贴带路径的地址。
* 三件事必须在存库前做掉,否则请求会以各种方式失败:
*
* 1) 补协议,且默认 https。
* 很多反代把 http 整站 302 到别的主机(例如门户站 www.xxx),
* 重定向后的主机没有 API,客户端只会拿到 404 页面而不是 401/200,
* 表现为"连接直接失败"且看不出原因。默认 https 可以绕过整类问题。
* 只有明确写了 http:// 的内网地址才走明文。
* 2) 去掉结尾斜杠,避免拼出 //api/v1。
* 3) 去掉用户误粘的 /api/v1 后缀,避免拼成 /api/v1/api/v1。
*/
export function normalizeBaseUrl(raw: string): string {
let u: string = raw.trim();
if (u.length === 0) {
return '';
}
const lower: string = u.toLowerCase();
if (!lower.startsWith('http://') && !lower.startsWith('https://')) {
u = 'https://' + u;
}
u = u.replace(/\/+$/, '');
u = u.replace(/\/api\/v1$/, '');
return u;
}
/**
* 新建连接用的空白模板。
* 这里不能写死任何地址或密钥:源码是公开的,硬编码等于把内网地址和访问凭据一起发布。
* 地址与 API Key 由用户在「设置 - 连接」里填写,未配置时各页面统一提示(见 UserError.MSG_NO_CONN)。
*/
export function defaultConnection(): ConnectionConfig {
const c: ConnectionConfig = {
id: '',
name: 'HologramAgent',
url: '',
apiKey: '',
type: 'webui',
};
return c;
}

View File

@ -0,0 +1,207 @@
import { connStore } from '../common/ConnStore';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { ChatAttachment } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
import { ChatStream } from '../components/ChatStream';
import { ChatComposer } from '../components/ChatComposer';
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
import { AttachmentDetailContent } from '../components/Attachment';
import { chatStore } from '../common/ChatStore';
/** 二级页面标识:附件(图片/文件)详情 */
const SUB_NONE: string = '';
const SUB_ATTACHMENT: string = 'attachment';
/**
* 聊天页:只做"页面壳"。
*
* 拆分后的分工(拆分前这里是 1962 行的单文件):
* - 消息流状态机(SSE / 历史 / 新消息动画)→ common/ChatStore.ets
* - SSE 事件翻译 → common/ChatSse.ets
* - 发送与中断 → common/ChatSession.ets
* - 消息列表 + 滚动 → components/ChatStream.ets
* - 气泡(含思考卡/工具卡) → components/ChatBubble.ets
* - 悬浮输入区 + 附件选择/上传 → components/ChatComposer.ets
* 本文件保留:导航栈与附件详情二级页、以及"列表底部留白"这个
* 输入区与列表之间的耦合点(所以三个相关标志位用 @Link 与输入区共享)。
*/
@Component
export struct ChatPage {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 宽屏:左边聊天流(含底部导航栏),右边附件详情 */
@StorageProp('isWideScreen') private isWide: boolean = false;
/** 当前在右栏/二级页展示的附件;未打开时为 undefined */
@State activeAtt: ChatAttachment | undefined = undefined;
@State activeSub: string = SUB_NONE;
/** 以下三项影响列表底部留白,与输入区共享(输入区负责改) */
@State inputMultiLine: boolean = false;
@State attachMenuOpen: boolean = false;
@State pendingName: string = '';
private navStack: NavPathStack = new NavPathStack();
aboutToAppear(): void {
// 把本页导航栈登记给 Index:返回手势/三键返回按当前 Tab 精确派发到这里
registerNavStack(0, this.navStack, () => {
this.activeSub = SUB_NONE;
this.activeAtt = undefined;
});
chatStore.init();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
chatStore.loadHistory();
chatStore.connect();
}
}
aboutToDisappear(): void {
unregisterNavStack(0);
chatStore.disconnect();
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/**
* 消息列表底部留白:悬浮输入区(导航栏上方 94 + 输入行 44 ≈ 138)
* 之上再留 ~72 的安全间距,保证最后一条气泡的思考卡/工具卡/附件
* 不被遮挡;加号菜单与待发送附件预览展开时再加高。
*/
private listBottomPad(): number {
let pad: number = 210;
if (this.inputMultiLine) {
pad += 60;
}
if (this.attachMenuOpen) {
pad += 70;
}
if (this.pendingName.length > 0) {
pad += 46;
}
return pad;
}
// ===================== 二级页面:附件详情 =====================
/**
* 打开附件详情。
* WebGUI 点图片是新开浏览器标签、点文件是直接下载;
* 移动端没有"标签页",改成一个二级页面:大图预览 + 元信息 + 保存。
*/
private openAttachment(att: ChatAttachment): void {
this.activeAtt = att;
this.activeSub = SUB_ATTACHMENT;
if (this.isWide && this.navStack.size() > 0) {
this.navStack.replacePathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), false);
} else {
this.navStack.pushPathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), true);
}
}
private closeAttachment(): void {
this.navStack.pop();
this.activeSub = SUB_NONE;
this.activeAtt = undefined;
}
build() {
// 宽屏:左栏聊天流(含底部导航与输入区),右栏附件详情。
// 窄屏:附件详情整屏覆盖,系统返回手势直接作用于 navStack。
Navigation(this.navStack) {
this.ChatBody()
}
.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%')
.onNavBarStateChange((isVisible: boolean) => {
markSubPageOpen(this.isWide || !isVisible);
})
.onNavigationModeChange((mode: NavigationMode) => {
if (mode === NavigationMode.Split) {
markSubPageOpen(true);
// 右栏不能空白:有附件就展示最近一个,没有则由 SubDestination 显示空态
if (this.navStack.size() === 0) {
const a: ChatAttachment | undefined = chatStore.latestAttachment();
this.activeAtt = a;
this.activeSub = SUB_ATTACHMENT;
this.navStack.pushPathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), false);
}
} else {
this.navStack.clear(false);
this.activeSub = SUB_NONE;
markSubPageOpen(false);
}
})
}
/** 附件详情二级页面 */
@Builder
SubDestination(name: string, param: object) {
NavDestination() {
if (name === SUB_ATTACHMENT) {
SubPageLayer({
title: '附件详情',
tab: 0,
onBack: () => {
this.closeAttachment();
},
}) {
this.AttachmentPane()
}
}
}
.hideTitleBar(true)
.backgroundColor(Color.Transparent)
}
@Builder
AttachmentPane() {
if (this.activeAtt !== undefined) {
AttachmentDetailContent({ att: this.activeAtt })
} else {
// 宽屏右栏空态:聊天里还没有任何图片/文件
Column({ space: 8 }) {
Image($r('app.media.ic_file'))
.width(38)
.height(38)
.fillColor(this.palette().textMuted)
.draggable(false)
Text('选择聊天中的图片或文件查看详情')
.fontSize(13)
.fontColor(this.palette().textMuted)
}
.width('100%')
.padding({ top: 60 })
.alignItems(HorizontalAlign.Center)
}
}
@Builder
ChatBody() {
Stack({ alignContent: Alignment.Bottom }) {
// 层1~1.6:消息流 + 顶栏遮罩 + 底部淡出遮罩
ChatStream({
bottomPad: this.listBottomPad(),
onOpenAttachment: (att: ChatAttachment) => {
this.openAttachment(att);
},
})
// 层2:悬浮输入区(导航栏上方,与导航栏左右平齐,各组件独立不共框)
ChatComposer({
inputMultiLine: $inputMultiLine,
attachMenuOpen: $attachMenuOpen,
pendingName: $pendingName,
})
}
.width('100%')
.height('100%')
}
}

View File

@ -0,0 +1,307 @@
import { deviceBridge } from '../common/DeviceBridge';
import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient';
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, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { deviceGatewayUrl } from '../common/DeviceBridgeSession';
import { DeviceRootEntries } from '../components/DeviceRootEntries';
import { DeviceLocalPane, DeviceCapsPane, DeviceGatewayPane, DeviceListPane } from '../components/DevicePanes';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { markSubPageOpen, subPageParam } from '../components/SubPage';
import { parseOnlineDevices, resolveDeviceId, SUB_CAPS, SUB_GATEWAY, SUB_LIST,
SUB_LOCAL, SUB_NONE } from '../common/DeviceModel';
/**
* 设备页:只做"页面壳"。
*
* 拆分后的分工(拆分前这里是 560 行的单文件):
* - 一级入口列表(本机/通道两组) → components/DeviceRootEntries.ets
* - 四个二级页面内容(本机/能力/通道/列表)→ components/DevicePanes.ets
* - device_id 兜底与在线设备解析 → common/DeviceModel.ets(路由 id 也在那)
* 本文件保留:导航栈与二级页分发、授权开关、toast、设备列表拉取与生命周期。
*/
@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;
@StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false;
@State bridgeUrl: string = '';
@State bridgeToken: string = '';
@State deviceId: string = '';
@State authorized: boolean = false;
@State devices: DeviceInfo[] = [];
@State loadingDevices: boolean = false;
@State toastMsg: string = '';
@State toastIsError: boolean = false;
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack();
aboutToAppear(): void {
// id 判定顺序:桥里的 id 优先于持久化的 id,都没有才生成并落盘
this.deviceId = resolveDeviceId(deviceBridge.getDeviceId());
this.authorized = connStore.getDeviceAuth();
// Gateway URL derives from current connection
const cur = connStore.getCurrentConnection();
if (cur !== null) {
this.bridgeUrl = deviceGatewayUrl(cur.url);
this.bridgeToken = cur.apiKey;
}
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;
}
/**
* 打开二级页面。
*
* 窄屏(Stack 模式):push 一层,整屏覆盖,系统侧滑返回可退。
* 宽屏(Split 模式):左栏一级列表常驻,右栏只应有一页,
* 所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。
*/
private openSub(id: string): void {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
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.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
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);
this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false);
}
private showToast(msg: string, isError: boolean): void {
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
this.toastIsError = isError;
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = msg;
});
setTimeout(() => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
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 list: DeviceInfo[] = parseOnlineDevices(parsed);
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.devices = list;
});
} else {
this.devices = [];
}
} catch (e) {
// 网络失败保持静默,不打扰用户
}
this.loadingDevices = false;
}
build() {
// Navigation 提供真实导航栈:系统侧滑返回 / 三键返回都会自动 pop
Navigation(this.navStack) {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Scroll() {
Column() {
DeviceRootEntries({
activeSub: this.activeSub,
deviceId: this.deviceId,
bridgeConnected: this.bridgeConnected,
bridgeUrl: this.bridgeUrl,
loadingDevices: this.loadingDevices,
deviceCount: this.devices.length,
onOpen: (id: string) => {
this.openSub(id);
},
})
}
.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%')
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)用 Split:navBar(一级列表 + 底部悬浮导航栏)常驻左栏,
// 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.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.activeSub = SUB_NONE;
});
markSubPageOpen(false);
}
})
}
/**
* 二级页面路由表。
* 每个面板自带 SubPageLayer 外壳(标题/所属 Tab/返回/刷新),这里只做分发。
*/
@Builder
SubDestination(name: string, param: object) {
NavDestination() {
if (name === SUB_LOCAL) {
DeviceLocalPane({
deviceId: this.deviceId,
authorized: this.authorized,
onBack: () => {
this.closeSub();
},
onToggleAuth: (on: boolean) => {
this.toggleAuthorized(on);
},
})
} else if (name === SUB_CAPS) {
DeviceCapsPane({
onBack: () => {
this.closeSub();
},
})
} else if (name === SUB_GATEWAY) {
DeviceGatewayPane({
bridgeUrl: this.bridgeUrl,
bridgeToken: this.bridgeToken,
bridgeConnected: this.bridgeConnected,
onBack: () => {
this.closeSub();
},
onRefresh: () => {
this.refreshDevices();
},
})
} else if (name === SUB_LIST) {
DeviceListPane({
devices: this.devices,
onBack: () => {
this.closeSub();
},
onRefresh: () => {
this.refreshDevices();
},
})
}
}
.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 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
}
const RADIUS_MD: number = 10;

View File

@ -0,0 +1,421 @@
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, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from '../components/MotionBase';
import { GradientBackground } from '../components/GradientBackground';
import { ScreensuePage } from '../components/ScreensuePage';
import { registerScreensueHandler } from '../common/BridgeRouter';
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
import { snapshotComponentId } from '../common/BridgeCaps';
import { ScreensuePayload } from '../common/ScreensueHtml';
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() {
// 底部导航按压反馈:交给 MotionBase 统一(flexWeight 由外层调用点的
// .layoutWeight(1) 负责,这里内部只需撑满自己那一格)。
MotionBase({ pressEnabled: true }) {
Column({ space: 3 }) {
Image(this.icon)
.width(22)
.height(22)
.fillColor(this.active ? this.accent : this.muted)
// 选中态的颜色迁移必须写在子节点上:父容器的 .animation()
// 只驱动它自己的属性,不会下传给 Image / Text。
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.draggable(false)
Text(this.label)
.fontSize(10)
// fontWeight 不可插值,切换时仍是跳变,只对颜色做过渡
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.active ? this.accent : this.muted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.maxLines(1)
}
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.width('100%')
.height(48)
}
}
}
@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;
/** 外部请求切换主 Tab(未连接时聊天空态的“去设置连接”用) */
@StorageProp(K_REQUESTED_TAB) @Watch('onRequestedTab') private requestedTab: number = -1;
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);
}
// 后端连接状态广播:聊天空态根据它决定是否显示“去设置连接”。
// Index.aboutToAppear 在 EntryAbility 等 connStore.init 之后才跑,
// 所以此处读到的连接状态就是真实的启动态。
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, -1);
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, '');
// 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取
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();
// 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。
registerScreensueHandler((payload: ScreensuePayload) => {
this.showScreensue(payload);
});
markForegroundBridgeUIReady(getContext(this) as common.UIAbilityContext);
}
/** 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);
}
/**
* 响应外部切 Tab 请求(聊天空态的“去设置连接”)。
*
* 为什么不能直接改 AppStorage 的 currentTab:Index 的 currentTab 是
* @State,Swiper.index() 只认它;外部写 AppStorage 不会驱动 Swiper。
* 所以用独立请求键 + @Watch 把请求转成自己的状态变更。
*/
private onRequestedTab(): void {
const t: number = this.requestedTab;
AppStorage.set<number>(K_REQUESTED_TAB, -1);
if (t < 0 || t >= this.tabs.length) {
return;
}
this.currentTab = t;
navBar.setVisible(true);
}
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.set<boolean>('isWideScreen', wide);
}
}
/**
* 统一的返回处理:把返回事件精确派发给【当前 Tab】的那个 Navigation 栈。
*
* 四个页面各自持有 Navigation(宽屏需要"左一级右二级"),它们在 Swiper 里
* 同时存活,系统返回落到哪个栈并不确定 —— 这就是"有的页面无法使用返回手势"
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
*/
onBackPress(): boolean {
if (this.screensueVisible) {
this.closeScreensue();
return true;
}
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: ANIM_SLOW, curve: Curve.EaseOut })
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
.hitTestBehavior(HitTestMode.Transparent)
// screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏,
// 左侧一级页面与主导航保持可见、可操作。
if (this.screensueVisible) {
if (this.isWide) {
Row() {
Column()
.width(WIDE_NAV_BAR_WIDTH)
.height('100%')
.hitTestBehavior(HitTestMode.None)
ScreensuePage({
pushedText: this.screensueText,
countdown: this.screensueCountdown,
onClose: () => {
this.closeScreensue();
},
})
.layoutWeight(1)
.height('100%')
.padding({ top: this.topInset })
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
} else {
ScreensuePage({
pushedText: this.screensueText,
countdown: this.screensueCountdown,
onClose: () => {
this.closeScreensue();
},
})
.width('100%')
.height('100%')
.padding({ top: this.topInset })
}
}
}
.width('100%')
.height('100%')
.id(snapshotComponentId())
// 实时跟随窗口宽度:平板旋转、折叠展开、分屏、自由窗口拖拽都会回调,
// 比启动时查一次 display 更可靠(不会漏掉运行中的尺寸变化)。
.onAreaChange((oldValue: Area, newValue: Area) => {
this.updateWideScreen(newValue.width as number);
})
}
}

View File

@ -0,0 +1,374 @@
import { apiClient } from '../common/ApiClient';
import { userMessage } 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 { ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
import { PluginDetailPane } from '../components/PluginDetailPane';
import { PluginListView } from '../components/PluginListView';
import { PluginInstallForm } from '../components/PluginsOverlays';
import { ToastBar } from '../components/ToastBar';
import { fetchPluginRows, fetchPluginDetail } from '../common/PluginApi';
/** 二级页面标识:插件详情 */
const SUB_NONE: string = '';
const SUB_DETAIL: string = 'detail';
interface InstallBody {
url: string;
}
@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;
}
/** 取数口径见 common/PluginApi.ets;这里只负责把结果落到 @State。 */
private async loadPlugins(): Promise<void> {
if (!apiClient.hasConnection()) {
return;
}
this.loading = true;
try {
const rows: PluginRow[] = await fetchPluginRows();
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.plugins = rows;
});
} catch (e) {
this.showToast(userMessage('plugins.load', e), true);
}
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.loading = false;
});
}
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 /plugins,body 带 url。
await apiClient.post('/plugins', bodyObj);
this.showToast('安装请求已发送', false);
this.installUrl = '';
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.showInstallForm = false;
});
this.loadPlugins();
} catch (e) {
this.showToast(userMessage('plugins.install', e), true);
}
}
// ===================== 二级页面:插件详情 =====================
/**
* 打开插件详情。
*
* 窄屏(Stack):push 一层整屏覆盖,系统侧滑返回可退。
* 宽屏(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 = '';
}
/**
* 详情数据落地。
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
*/
private async loadDetail(name: string): Promise<void> {
this.detailLoading = true;
this.detailError = '';
try {
const d: PluginDetail = await fetchPluginDetail(name);
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
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 ?? '';
}
const errText: string = (row !== null && row.external) ? userMessage('plugins.detail', e) : '';
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detail = d;
this.detailError = errText;
});
}
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detailLoading = false;
});
}
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 {
// 颜色标志必须在 animateTo 闭包之外先落地,否则第一帧配色是上一次的
this.toastIsError = isError;
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = msg;
});
setTimeout(() => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = '';
});
}, 2500);
}
build() {
// 宽屏(>=600vp)走 Split:navBar(左栏 = 插件列表 + 底部导航栏)常驻,
// NavDestination(右栏 = 插件详情)与之并列;窄屏则是整屏覆盖的二级页面。
Navigation(this.navStack) {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Scroll() {
Column() {
PluginListView({
plugins: this.plugins,
busy: this.loading,
activeName: this.activeName,
hasConn: apiClient.hasConnection(),
onSelect: (name: string) => {
this.openDetail(name);
},
})
}
.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) {
PluginInstallForm({
url: this.installUrl,
onUrlChange: (v: string) => {
this.installUrl = v;
},
onInstall: () => {
this.installPlugin();
},
})
}
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)
.rotate({ angle: this.showInstallForm ? 45 : 0 })
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
}
.width(50)
.height(50)
.type(ButtonType.Circle)
.backgroundColor(this.palette().accent)
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
.onClick(() => {
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.showInstallForm = !this.showInstallForm;
});
})
}
}
ToastBar({ msg: this.toastMsg, isError: this.toastIsError, bordered: true })
}
.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);
},
}) {
PluginDetailPane({
detail: this.detail,
activeName: this.activeName,
busy: this.detailLoading,
errorText: this.detailError,
row: this.activeRow() ?? undefined,
onToggle: () => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.togglePlugin(r);
}
},
onRemove: () => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.removePlugin(r);
}
},
})
}
}
}
.hideTitleBar(true)
.backgroundColor(Color.Transparent)
}
}

View File

@ -0,0 +1,434 @@
import { apiClient } from '../common/ApiClient';
import { userMessage, noConnectionMessage } from '../common/UserError';
import { connStore } from '../common/ConnStore';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { ConnectionConfig, AppSettings } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, K_HAS_CONN, K_SETTINGS_SUB } from '../common/Constants';
import { RADIUS_MD, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
import { StatusDetailContent } from '../components/StatusCards';
import { SettingsHome } from '../components/SettingsHome';
import { ConnectionsPane } from '../components/ConnectionsPane';
import { AppearancePane } from '../components/AppearancePane';
import { BackendSectionsPane, SectionEntriesPane } from '../components/BackendSettingsPane';
import {
SettingEntry, SettingsSection, SettingMetaRaw, ParsedSettings, SectionStats,
parseSettingsPayload, buildSections, pickInitialSection, buildEntries, withEntryMarked,
SUB_NONE, SUB_STATUS, SUB_CONNECTIONS, SUB_APPEARANCE, SUB_BACKEND, SUB_SECTION,
} from '../common/SettingsModel';
import { statusStore } from '../common/StatusStore';
import { stageTrail } from '../common/StageTrail';
interface SaveSettingBody {
key: string;
value: string;
}
@Component
export struct SettingsPage {
@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 connections: ConnectionConfig[] = [];
@State currentId: string = '';
@State themeMode: string = 'system';
@State lang: string = 'zh';
@State bgImage: string = '';
@State bgOpacity: number = 0.25;
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack();
/** 未连接时是否已自动弹过连接页——避免用户关掉后又被 onNavigationModeChange 弹回来 */
private autoOpenedConn: boolean = false;
/** 外部请求打开某个二级页(聊天空态的“去设置连接”用) */
@StorageProp(K_SETTINGS_SUB) @Watch('onSubRequest') private subRequest: string = '';
// ===== backend key/value editor state =====
@State sections: SettingsSection[] = [];
/** plugin.* 配置项总数与涉及的插件个数(只用于在设置里给出去向提示) */
@State pluginKeyCount: number = 0;
@State pluginConfigCount: number = 0;
@State activeSection: string = 'core';
@State entries: SettingEntry[] = [];
@State loadingSettings: boolean = false;
@State settingsError: string = '';
@State savingCount: number = 0;
private metaStore: Record<string, SettingMetaRaw> = {};
private valuesStore: Record<string, string> = {};
@State toastMsg: string = '';
@State toastIsError: boolean = false;
aboutToAppear(): void {
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
registerNavStack(3, this.navStack, () => {
this.activeSub = SUB_NONE;
});
const s: AppSettings = connStore.getSettings();
this.themeMode = s.theme.length > 0 ? s.theme : 'system';
this.lang = s.lang;
this.bgImage = s.bgImage ?? '';
this.bgOpacity = s.bgOpacity ?? 0.25;
this.loadConnections();
this.loadBackendSettings();
// 状态 Tab 已删除,运行状态并入本页:顶部摘要卡 + 二级明细页
statusStore.init();
// 阶段轨迹由 SSE 喂入,与状态轮询是两条独立的数据源
stageTrail.init();
statusStore.refresh();
}
aboutToDisappear(): void {
unregisterNavStack(3);
}
private loadConnections(): void {
this.connections = connStore.getConnections();
const cur = connStore.getCurrentConnection();
this.currentId = cur !== null ? cur.id : '';
// 广播连接状态:聊天空态据此显示“去设置连接”
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
// 可能从聊天空态带着“打开连接页”的请求进来(本页尚未挂载时请求已写入)
const pending: string = AppStorage.get<string>(K_SETTINGS_SUB) ?? '';
if (pending.length > 0) {
AppStorage.set<string>(K_SETTINGS_SUB, '');
this.autoOpenedConn = true;
setTimeout(() => {
this.openSub(pending);
}, 0);
}
}
/**
* 宽屏右栏默认该展示哪一页:没连上就把“后端连接”给出来。
*
* 为什么不能只靠一级入口行:入口行在列表里,用户很容易略过;
* 而“未连接”恰恰是最需要直接看到连接表单的时刻。窄屏同理,
* 在 onNavigationModeChange(Stack) 里会把连接页直接推到面前。
*/
private initialSub(): string {
return apiClient.hasConnection() ? SUB_STATUS : SUB_CONNECTIONS;
}
/** 外部请求打开二级页(“去设置连接”) */
private onSubRequest(): void {
const id: string = this.subRequest;
if (id.length === 0) {
return;
}
AppStorage.set<string>(K_SETTINGS_SUB, '');
this.autoOpenedConn = true;
this.openSub(id);
}
private palette(): ThemePalette {
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
// ===================== 二级页面导航 =====================
/**
* 打开二级页面。
*
* 窄屏(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;
}
private currentConnName(): string {
for (let i = 0; i < this.connections.length; i++) {
if (this.connections[i].id === this.currentId) {
return this.connections[i].name;
}
}
return '未配置';
}
private themeLabel(): string {
if (this.themeMode === 'dark') {
return '深色';
}
if (this.themeMode === 'light') {
return '浅色';
}
return '跟随系统';
}
/** 核心 + 其他(不含 plugin.*,那些在插件详情页里) */
private coreKeyCount(): number {
let n: number = 0;
for (let i = 0; i < this.sections.length; i++) {
n = n + this.sections[i].count;
}
return n;
}
// ===================== backend kv editor =====================
private async loadBackendSettings(): Promise<void> {
if (!apiClient.hasConnection()) {
this.settingsError = noConnectionMessage();
return;
}
this.loadingSettings = true;
this.settingsError = '';
try {
const resp = await apiClient.getWithTimeout('/settings', 15000);
const parsed: ParsedSettings = parseSettingsPayload(resp.body);
this.metaStore = parsed.meta;
this.valuesStore = parsed.values;
const stats: SectionStats = buildSections(this.valuesStore, this.metaStore);
this.sections = stats.sections;
this.pluginKeyCount = stats.pluginKeyCount;
this.pluginConfigCount = stats.pluginConfigCount;
this.selectSection(pickInitialSection(this.sections));
} catch (e) {
this.settingsError = userMessage('settings.load', e);
}
this.loadingSettings = false;
}
private selectSection(secId: string): void {
this.activeSection = secId;
this.entries = buildEntries(this.valuesStore, this.metaStore, secId);
}
private findEntry(key: string): SettingEntry | undefined {
for (let i = 0; i < this.entries.length; i++) {
if (this.entries[i].key === key) {
return this.entries[i];
}
}
return undefined;
}
/** Persist one key via PUT /settings. bool is sent as "true"/"false" strings like the GUI. */
private async saveSetting(key: string, newValue: string): Promise<void> {
const entry: SettingEntry | undefined = this.findEntry(key);
if (entry === undefined) {
return;
}
let payload: string = newValue.trim();
if (entry.type === 'bool') {
payload = newValue === 'true' ? 'true' : 'false';
}
this.savingCount = this.savingCount + 1;
try {
const body: SaveSettingBody = { key: entry.key, value: payload };
await apiClient.request('/settings', 'PUT', JSON.stringify(body), 10000);
this.valuesStore[entry.key] = payload;
this.markEntry(entry.key, payload, false);
this.showToast('已保存: ' + entry.displayName, false);
} catch (e) {
this.showToast(userMessage('settings.save', e), true);
}
this.savingCount = this.savingCount - 1;
}
/** 已保存:读当前编辑值再落库(输入类控件的"保存"按钮) */
private saveCurrentValue(key: string): void {
this.saveSetting(key, this.currentValueOf(key));
}
private markEntry(key: string, value: string, dirty: boolean): void {
this.entries = withEntryMarked(this.entries, key, value, dirty);
}
private updateEntryValue(key: string, value: string): void {
const original: string = this.valuesStore[key] ?? '';
this.markEntry(key, value, value !== original);
}
private currentValueOf(key: string): string {
// read latest edited value from entries store (markEntry keeps copies)
const e: SettingEntry | undefined = this.findEntry(key);
return e !== undefined ? e.value : '';
}
private showToast(msg: string, isError: boolean): void {
// 颜色标记必须在 animateTo 之外先定好,否则第一帧会用上一条 toast 的配色
this.toastIsError = isError;
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = msg;
});
setTimeout(() => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = '';
});
}, 2500);
}
build() {
// Navigation 提供真实导航栈:系统侧滑返回 / 三键返回 / 无障碍返回
// 都会自动 pop 当前 NavDestination,无需自己拦 onBackPress。
//
// 宽屏(>=600vp)改用 Split 模式:navBar(这里的一级入口列表,含底部悬浮导航栏)
// 常驻左栏,NavDestination(二级页面)渲染在右栏,两栏同时可见。
Navigation(this.navStack) {
SettingsHome({
connCount: this.connections.length,
connName: this.currentConnName(),
sectionCount: this.sections.length,
coreKeys: this.coreKeyCount(),
busy: this.loadingSettings,
errorText: this.settingsError,
themeLabel: this.themeLabel(),
activeSub: this.activeSub,
isWide: this.isWide,
savingCount: this.savingCount,
toastMsg: this.toastMsg,
toastIsError: this.toastIsError,
onOpen: (id: string) => {
this.openSub(id);
},
})
}
.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 常驻可见,onNavBarStateChange 不再触发,
// 所以在这里统一维护"横滑锁":宽屏一律锁住(靠点击导航栏切换 Tab,
// 避免在右栏操作时误触发主 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(this.initialSub());
}
} else {
this.navStack.clear(false);
this.activeSub = SUB_NONE;
markSubPageOpen(false);
// 窄屏:未配置后端时直接推连接页,保证“设置里一定能找到连后端的入口”。
if (!this.autoOpenedConn && !apiClient.hasConnection()) {
this.autoOpenedConn = true;
this.openSub(SUB_CONNECTIONS);
}
}
})
}
/**
* 二级页面路由表:name 由 pushPathByName 传入。
* 各页面的外壳(SubPageLayer)与其内容一起放在对应组件里,
* 这里只做 id → 组件的分发。
*/
@Builder
SubDestination(name: string, param: object) {
NavDestination() {
if (name === SUB_STATUS) {
SubPageLayer({
title: '运行状态',
tab: 3,
onBack: () => {
this.closeSub();
},
showRefresh: true,
onRefresh: () => {
statusStore.refresh();
},
}) {
StatusDetailContent()
}
} else if (name === SUB_CONNECTIONS) {
ConnectionsPane({
connections: $connections,
currentId: $currentId,
onBack: () => {
this.closeSub();
},
onToast: (msg: string, isError: boolean) => {
this.showToast(msg, isError);
},
})
} else if (name === SUB_APPEARANCE) {
AppearancePane({
themeMode: $themeMode,
bgImage: $bgImage,
bgOpacity: $bgOpacity,
onBack: () => {
this.closeSub();
},
onToast: (msg: string, isError: boolean) => {
this.showToast(msg, isError);
},
})
} else if (name === SUB_BACKEND) {
BackendSectionsPane({
sections: this.sections,
pluginKeyCount: this.pluginKeyCount,
pluginConfigCount: this.pluginConfigCount,
busy: this.loadingSettings,
errorText: this.settingsError,
activeSection: this.activeSection,
highlightRows: this.isWide && this.activeSub === SUB_SECTION,
onBack: () => {
this.closeSub();
},
onRefresh: () => {
this.loadBackendSettings();
},
onOpenSection: (id: string) => {
this.selectSection(id);
this.openSub(SUB_SECTION);
},
})
} else if (name === SUB_SECTION) {
SectionEntriesPane({
sections: this.sections,
activeSection: this.activeSection,
entries: this.entries,
busy: this.loadingSettings,
onBack: () => {
this.closeSub();
},
onRefresh: () => {
this.loadBackendSettings();
},
onSaveValue: (key: string, value: string) => {
this.saveSetting(key, value);
},
onSaveCurrent: (key: string) => {
this.saveCurrentValue(key);
},
onEdit: (key: string, value: string) => {
this.updateEntryValue(key, value);
},
})
}
}
.hideTitleBar(true)
.backgroundColor(Color.Transparent)
}
}

View File

@ -0,0 +1,55 @@
{
"module": {
"name": "entry",
"type": "entry",
"description": "HologramAgent 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"
},
{
"name": "ohos.permission.READ_PASTEBOARD",
"reason": "$string:read_pasteboard_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "HologramAgent main entry",
"icon": "$media:layered_image",
"label": "$string:app_name",
// 启动页图标用透明底字形,配合随主题切换的 start_window_background,
// 浅色/深色模式下遮罩与图标都能对上,不再恒为深色样式。
"startWindowIcon": "$media:start_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": "#F1F3F5"
}
]
}

View File

@ -0,0 +1,12 @@
{
"string": [
{
"name": "app_name",
"value": "HologramAgent"
},
{
"name": "read_pasteboard_reason",
"value": "用于在应用前台按你的授权响应 agent 的剪贴板读取请求"
}
]
}

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

Some files were not shown because too many files have changed in this diff Show More