mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
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:
221
cmd/ohos/HologramAgent/entry/src/main/ets/common/ApiClient.ets
Normal file
221
cmd/ohos/HologramAgent/entry/src/main/ets/common/ApiClient.ets
Normal 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();
|
||||
@ -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 '';
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
428
cmd/ohos/HologramAgent/entry/src/main/ets/common/BridgeCaps.ets
Normal file
428
cmd/ohos/HologramAgent/entry/src/main/ets/common/BridgeCaps.ets
Normal 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));
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
223
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatFormat.ets
Normal file
223
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatFormat.ets
Normal 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%)';
|
||||
}
|
||||
119
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatHistory.ets
Normal file
119
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatHistory.ets
Normal 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;
|
||||
}
|
||||
206
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatSession.ets
Normal file
206
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatSession.ets
Normal 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();
|
||||
}
|
||||
213
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatSse.ets
Normal file
213
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatSse.ets
Normal 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
|
||||
}
|
||||
}
|
||||
602
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatStore.ets
Normal file
602
cmd/ohos/HologramAgent/entry/src/main/ets/common/ChatStore.ets
Normal 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();
|
||||
236
cmd/ohos/HologramAgent/entry/src/main/ets/common/ConnStore.ets
Normal file
236
cmd/ohos/HologramAgent/entry/src/main/ets/common/ConnStore.ets
Normal 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();
|
||||
359
cmd/ohos/HologramAgent/entry/src/main/ets/common/Constants.ets
Normal file
359
cmd/ohos/HologramAgent/entry/src/main/ets/common/Constants.ets
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
174
cmd/ohos/HologramAgent/entry/src/main/ets/common/PluginApi.ets
Normal file
174
cmd/ohos/HologramAgent/entry/src/main/ets/common/PluginApi.ets
Normal 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;
|
||||
}
|
||||
@ -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(' · ');
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
258
cmd/ohos/HologramAgent/entry/src/main/ets/common/SseClient.ets
Normal file
258
cmd/ohos/HologramAgent/entry/src/main/ets/common/SseClient.ets
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
180
cmd/ohos/HologramAgent/entry/src/main/ets/common/StageTrail.ets
Normal file
180
cmd/ohos/HologramAgent/entry/src/main/ets/common/StageTrail.ets
Normal 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;
|
||||
}
|
||||
502
cmd/ohos/HologramAgent/entry/src/main/ets/common/StatusStore.ets
Normal file
502
cmd/ohos/HologramAgent/entry/src/main/ets/common/StatusStore.ets
Normal 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();
|
||||
}
|
||||
178
cmd/ohos/HologramAgent/entry/src/main/ets/common/UserError.ets
Normal file
178
cmd/ohos/HologramAgent/entry/src/main/ets/common/UserError.ets
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user