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