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

## ① 全面改名 HomeAgent → HologramAgent

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

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

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

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

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

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

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

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

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

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

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

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

## 判据

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

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

## 验证

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

View File

@ -0,0 +1,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));
}