Files
HomeAgent/cmd/ohos/HologramAgent/entry/src/main/ets/common/BridgeCaps.ets
JianFeeeee f256098c9f 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:客户端与内核版本一致
2026-10-22 20:49:44 +08:00

429 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));
}