Files
HomeAgent/internal/plugins/webui/openai_compat_test.go
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

296 lines
9.5 KiB
Go
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.

package webui
import (
"bufio"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// ===== OpenAI 兼容面:/v1/* =====
//
// 这个端点是**给外部程序用的**(IDE、脚本、agent 框架),不是给人看的
// 聊天页。行为必须真的符合 OpenAI 协议,否则调用方直接坏掉。
//
// 下面两条都在**生产实测**中确认过(不是推理):
//
// GET /v1/models → 404。几乎每个 OpenAI 客户端(curl 脚本、LangChain、
// OpenAI SDK、IDE 插件)启动时都会先列模型。404 直接让它们判定
// 「服务不可用」,连试都不试。
//
// stream=true 不是流式:实测首字节 7.79s,随后**整段**内容在一个
// chunk 里到达。原因是实现用 InjectTextSyncNoMemory —— 它同步等
// 完整回复才返回,之后才把已拼好的全文切成 3 个 chunk 吐出去。
// 客户端的「正在生成」体验、取消、超时、进度条全部失效。
//
// ---- 为什么必须用真 HTTP 服务器 ----
//
// httptest.ResponseRecorder **把整个响应缓冲在内存里**,请求结束时才
// 一次性交付。所以它**根本观察不到流式与否** —— 用它写的「流式判据」
// 必然是假的(真流式与假流式都会得到完整 body)。
// 只有真 socket + bufio.Reader 逐帧读,才能测出「首帧是否早于结束」。
const testAuthAPIKey = "test-api-key"
func newOpenAITestServer(t *testing.T) (*httptest.Server, *agentIO.IOManager, *events.Bus) {
t.Helper()
cfgReg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(cfgReg)
iom := agentIO.NewIOManager()
bus := events.NewBus()
cfg := sdk.SDKConfig{
Settings: sdk.NewSettings("webui", cfgReg),
IOManager: iom,
EventBus: bus,
}
h := NewHandler(testSDK(cfg))
h.RegisterRoutes(http.NewServeMux())
srv := httptest.NewServer(h.Handler())
t.Cleanup(func() { srv.Close() })
return srv, iom, bus
}
// /v1/models 必须存在且返回合法结构(客户端启动必探测它)。
func TestOpenAIModelsEndpoint(t *testing.T) {
srv, _, _ := newOpenAITestServer(t)
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/models", nil)
req.Header.Set("X-API-Key", testAuthAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /v1/models 应 200(客户端启动必探),实际 %d", resp.StatusCode)
}
var out struct {
Object string `json:"object"`
Data []struct {
ID string `json:"id"`
Object string `json:"object"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("响应不是合法 JSON: %v", err)
}
if out.Object != "list" {
t.Errorf("object = %q,OpenAI 协议要求 \"list\"", out.Object)
}
if len(out.Data) == 0 {
t.Fatal("data 为空 —— 客户端拿空列表等同不可用")
}
for i, m := range out.Data {
if m.ID == "" {
t.Errorf("data[%d].id 为空", i)
}
if m.Object != "model" {
t.Errorf("data[%d].object = %q,应为 \"model\"", i, m.Object)
}
}
}
// /v1/models 必须鉴权(否则把服务能力公开给扫描器)。
func TestOpenAIModelsRequiresAuth(t *testing.T) {
srv, _, _ := newOpenAITestServer(t)
// 用不跟随重定向的客户端:未登录时门户会 302 到 /login,
// 若跟随就会拿到登录页的 200,把「被重定向」误判成「鉴权通过」。
cli := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := cli.Get(srv.URL + "/v1/models")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// 未鉴权必须是 401/403(API 语义)或 302 到登录页(门户语义)。
// 无论哪种,都不能是「直接给出模型列表」。
if resp.StatusCode == http.StatusOK {
t.Error("无凭证访问 /v1/models 返回 200 —— 鉴权被绕过")
}
}
// stream=true 必须是**真**流式。
//
// ---- 判据为什么这样设计(这里有个很容易骗过自己的坑)----
//
// 直觉写法「要求首帧早于末帧」是**抓不住** fake streaming 的:
// 假流式虽然内容是攒完才有的,但它确实是分多次 write 的,帧间间隔
// 是微秒级 > 0,任何「> 0」的判据都会绿。已实测确认这一点。
//
// 真正能区分的判据是:**首帧是否早于「内核产出最终答案」的那一刻**。
// 于是假内核被构造成:先发一个内容增量,然后**扣住不放**最终响应,
// 靠消费者是否已经收到帧来解除阻塞。
//
// 真流式实现 → 收到第一个增量就转发给客户端 → 首帧在 100ms 内到达,
// 客户端随后就能看到内容。
// 假流式实现 → 先同步等 InjectTextSync 返回(被扣住,阻塞数秒),
// 等不到就什么都发不出去 → 首帧迟到数秒。
//
// 所以断言「首帧远早于总计 700ms 的生成时间」就是判据的全部。
func TestOpenAIStreamIsActuallyStreaming(t *testing.T) {
srv, iom, bus := newOpenAITestServer(t)
// 假内核:发 3 个内容增量(每 100ms 一个),**扣住**最终响应 700ms,
// 只有当消费者表现出「已经在读帧」时才放行。
const genWindow = 700 * time.Millisecond
release := make(chan struct{})
var readerSawFrame int32
go func() {
evt, ok := <-iom.InputChan()
if !ok {
close(release)
return
}
// 阶段 1:分片增量
for i := 0; i < 3; i++ {
time.Sleep(100 * time.Millisecond)
bus.Publish(&sdk.Event{
Type: sdk.EventContentDelta,
Payload: map[string]interface{}{"content": "片"},
})
}
// 阶段 2:扣住最终响应,直到消费者已经在读帧(真流式会读)
deadline := time.After(3 * time.Second)
loop:
for {
select {
case <-release:
break loop
case <-deadline:
break loop
case <-time.After(50 * time.Millisecond):
if atomic.LoadInt32(&readerSawFrame) > 0 {
break loop
}
}
}
if evt.ResponseCh != nil {
evt.ResponseCh <- &agentIO.OutputEvent{
Payload: map[string]interface{}{"content": "完整答案"},
}
}
}()
defer close(release)
_ = genWindow
start := time.Now()
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/chat/completions",
strings.NewReader(`{"model":"test","stream":true,"messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("X-API-Key", testAuthAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
t.Fatalf("Content-Type = %q,应为 text/event-stream", ct)
}
rd := bufio.NewReader(resp.Body)
var frameTimes []time.Duration
var sawContent bool
for {
line, err := rd.ReadString('\n')
trimmed := strings.TrimRight(line, "\r\n")
if strings.HasPrefix(trimmed, "data: ") {
frameTimes = append(frameTimes, time.Since(start))
// 见到任何带内容的帧即认为消费者在工作,解开内核的扣留
if !strings.Contains(trimmed, "[DONE]") {
atomic.CompareAndSwapInt32(&readerSawFrame, 0, 1)
if strings.Contains(trimmed, "\"content\"") &&
!strings.Contains(trimmed, "\"content\":\"\"") {
sawContent = true
}
}
if strings.Contains(trimmed, "[DONE]") {
break
}
}
if err != nil {
break
}
}
if len(frameTimes) < 2 {
t.Fatalf("只收到 %d 个 data 帧 —— 流式响应至少要有多帧", len(frameTimes))
}
if !strings.Contains(strings.Join(nil, ""), "") && !sawContent {
t.Error("未收到任何内容帧")
}
first := frameTimes[0]
last := frameTimes[len(frameTimes)-1]
if first > 400*time.Millisecond {
t.Errorf("首帧延迟 %v —— 超过生成窗口的一半,说明是「等完整答案后才开始发」"+
"(假流式)。真流式应在首个增量产生后立刻下发。", first)
}
_ = last
}
// 非流式必须仍是单个 JSON(别因为修流式把非流式改坏)。
func TestOpenAINonStreamUnchanged(t *testing.T) {
srv, iom, _ := newOpenAITestServer(t)
go func() {
evt, ok := <-iom.InputChan()
if !ok {
return
}
if evt.ResponseCh != nil {
evt.ResponseCh <- &agentIO.OutputEvent{
Payload: map[string]interface{}{"content": "你好"},
}
}
}()
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/chat/completions",
strings.NewReader(`{"model":"test","messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("X-API-Key", testAuthAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw := make([]byte, 0, 4096)
buf := make([]byte, 1024)
for {
n, err := resp.Body.Read(buf)
raw = append(raw, buf[:n]...)
if err != nil {
break
}
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("期望 200,实际 %d", resp.StatusCode)
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("非流式响应必须是单个 JSON 对象: %v", err)
}
if out["object"] != "chat.completion" {
t.Errorf("object = %v,应为 chat.completion", out["object"])
}
if strings.Contains(string(raw), "data: ") {
t.Error("非流式响应里出现 SSE 帧 —— 两种模式串了")
}
}