## 版本概述 发布 **v2.2.1** 版本,核心聚焦于 **AI Agent 连续运维记忆合并、实体对齐演进与前端交互体验增强**。彻底解决在复杂运维场景下,连续排查导致工作日志碎片化刷屏、记忆提炼过于草率简写、配置更新冲突并存以及前端卡片展示等痛点。 --- ### 更新内容 1. **任务快照锚定与连续运维任务合并**: - 逆序定位本轮任务起点用户诉求,确保复杂长交互(超过 16 步)不丢失初始核心目标与显式参数; - 引入时序紧邻度与连续任务强合并指引(30分钟内),指导大模型优先采用 `mode: "update_latest"` 承前启后地合并上一阶段成果,彻底解决排障过程中工作日志碎片化与 10 条配额被刷爆的问题; - 数据库 `UserDBDO` 支持原子合并更新最新一条工作历程,并在模型缺省时自动容错兜底。 2. **知识实体对齐与版本平滑演进**: - 提炼时注入当前服务器已存的知识项与凭据清单,强制约束修改配置或更换凭据时复用完全相同的 key 名; - 结合底层 SQLite 原生 `UNIQUE(user_id, server_id, key)` 与 `ON CONFLICT DO UPDATE` 实现原子更新,杜绝同一配置产生多条冲突脏数据; - 支持 `action: "delete"` 标记,允许用户明确废弃某项实体时在后台原子清理。 3. **长短时记忆职责彻底解耦**: - 会话内摘要(`this.state.summary`)专职保留未完结的任务目标、用户决策与待办上下文,彻底剔除冗长具体的命令流水账; - System Prompt 中对“当前会话未决任务与决策摘要”与“服务器近期工作历程与操作备忘”进行语义与注入层隔离,消除同类信息双份冗余堆叠。 4. **真实凭据流通与通用协议规范**: - 彻底移除 Agent 工具输出中的破坏性正则硬脱敏代码,解除密钥与 Token 自动回填流转的阻碍; - 移除非标准 `reasoning_effort` 参数,仅保留标准 `temperature: 0.1`,杜绝 OpenAI 官方 GPT-4o 等模型报 HTTP 400 校验错误; - 前置清洗 `<think>` 思考标签,避免 DeepSeek-R1 / QwQ 等推理模型思考阶段的草稿代码块被错误截获; - 补充循环步数上限与异常终止时的阶段性记忆提炼兜底,并增加纯问候本地快速熔断与并发排队补偿。 5. **前端工作历程呈现与交互增强**: - 放宽提炼摘要字数限制至 100 字内(title 扩至 20 字内),引导大模型在不超过限制的前提下详实具体地记录排查背景、配置变更、服务状态及验证结果,消除草率简略; - 前端工作历程卡片应用 `-webkit-line-clamp: 2` 配合文本省略(`...`),保持整洁紧凑布局; - 支持鼠标悬停浮动展示完整文本 Tooltip,并支持点击直接展开/收起完整摘要内容。 --- ### 提交列表 - `d99bd7b` release: 发布 v2.2.1 AI Agent 连续记忆合并与交互增强版本 - `542381a` feat: 放宽工作历程摘要长度至100字并支持前端两行截断悬停与点击展开 - `8d55927` refactor: 重构与优化 AI Agent 上下文管理与长期记忆系统 --- ### 验证结果 全套本地门禁全部通过(`pnpm run verify`): - **TypeScript 类型检查**:双端全部通过(0 errors) - **单元与集成测试**:69 个测试文件、784 项用例全部通过(100% pass) - **前端生产构建**:Vite 生产构建与单文件 HTML 内联成功 - **Playwright E2E & 无障碍测试**:75 个端到端测试全部通过(100% pass)
261 lines
9.1 KiB
TypeScript
261 lines
9.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
extractDistillationJson,
|
|
formatCurrentTimeAnchor,
|
|
formatTimestampWithRelative,
|
|
isSensitiveKeyOrValue,
|
|
KNOWLEDGE_KEY_MAX_LENGTH,
|
|
KNOWLEDGE_VALUE_MAX_LENGTH,
|
|
normalizeKnowledgeInput,
|
|
normalizeWorkLogInput,
|
|
WORK_LOG_SUMMARY_MAX_LENGTH,
|
|
WORK_LOG_TITLE_MAX_LENGTH,
|
|
} from '../src/server-memory-schema';
|
|
|
|
describe('server-memory-schema', () => {
|
|
it('formats current time anchor for prompt', () => {
|
|
// 2026-03-30 14:30:00 (Monday)
|
|
const fixedTime = new Date('2026-03-30T14:30:00Z').getTime();
|
|
const anchorZh = formatCurrentTimeAnchor(fixedTime, 'zh-CN');
|
|
const anchorEn = formatCurrentTimeAnchor(fixedTime, 'en-US');
|
|
|
|
expect(anchorZh).toContain('2026-');
|
|
expect(anchorEn).toContain('2026-');
|
|
});
|
|
|
|
it('formats timestamp with relative day context', () => {
|
|
const base = new Date('2026-03-30T12:00:00').getTime();
|
|
const today = new Date('2026-03-30T09:15:00').getTime();
|
|
const yesterday = new Date('2026-03-29T16:20:00').getTime();
|
|
const twoDaysAgo = new Date('2026-03-28T10:00:00').getTime();
|
|
|
|
expect(formatTimestampWithRelative(today, base, 'zh-CN')).toContain('今天');
|
|
expect(formatTimestampWithRelative(yesterday, base, 'zh-CN')).toContain('昨天');
|
|
expect(formatTimestampWithRelative(twoDaysAgo, base, 'zh-CN')).toContain('前天');
|
|
|
|
expect(formatTimestampWithRelative(today, base, 'en-US')).toContain('Today');
|
|
expect(formatTimestampWithRelative(yesterday, base, 'en-US')).toContain('Yesterday');
|
|
expect(formatTimestampWithRelative(twoDaysAgo, base, 'en-US')).toContain('2 days ago');
|
|
});
|
|
|
|
it('formats current time anchor and relative timestamps with custom user timezone', () => {
|
|
// 2026-09-08 12:00:00 UTC = 2026-09-08 20:00:00 Asia/Shanghai = 2026-09-08 08:00:00 America/New_York
|
|
const ts = new Date('2026-09-08T12:00:00Z').getTime();
|
|
|
|
const anchorShanghai = formatCurrentTimeAnchor(ts, 'zh-CN', 'Asia/Shanghai');
|
|
expect(anchorShanghai).toContain('2026-09-08 20:00:00');
|
|
expect(anchorShanghai).toContain('时区: Asia/Shanghai');
|
|
|
|
const anchorNY = formatCurrentTimeAnchor(ts, 'en-US', 'America/New_York');
|
|
expect(anchorNY).toContain('2026-09-08 08:00:00');
|
|
expect(anchorNY).toContain('Timezone: America/New_York');
|
|
|
|
const relativeShanghai = formatTimestampWithRelative(ts, ts + 3600_000, 'zh-CN', 'Asia/Shanghai');
|
|
expect(relativeShanghai).toContain('20:00 (今天)');
|
|
|
|
const relativeNY = formatTimestampWithRelative(ts, ts + 3600_000, 'en-US', 'America/New_York');
|
|
expect(relativeNY).toContain('08:00 (Today)');
|
|
});
|
|
|
|
it('validates work log inputs', () => {
|
|
expect(normalizeWorkLogInput({})).toEqual({ ok: false, error: 'titleRequired' });
|
|
expect(normalizeWorkLogInput({ title: ' ' })).toEqual({ ok: false, error: 'titleRequired' });
|
|
expect(normalizeWorkLogInput({ title: 'Check hardware' })).toEqual({ ok: false, error: 'summaryRequired' });
|
|
|
|
const longTitle = 'a'.repeat(WORK_LOG_TITLE_MAX_LENGTH + 1);
|
|
expect(normalizeWorkLogInput({ title: longTitle, summary: 'done' })).toEqual({
|
|
ok: false,
|
|
error: 'titleTooLong',
|
|
});
|
|
|
|
const longSummary = 'b'.repeat(WORK_LOG_SUMMARY_MAX_LENGTH + 1);
|
|
expect(normalizeWorkLogInput({ title: 'title', summary: longSummary })).toEqual({
|
|
ok: false,
|
|
error: 'summaryTooLong',
|
|
});
|
|
|
|
const valid = normalizeWorkLogInput({
|
|
title: ' 查看服务器硬件信息 ',
|
|
summary: ' CPU 占用正常,内存余量充足 ',
|
|
});
|
|
expect(valid).toEqual({
|
|
ok: true,
|
|
value: {
|
|
mode: 'create',
|
|
title: '查看服务器硬件信息',
|
|
summary: 'CPU 占用正常,内存余量充足',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('validates and categorizes knowledge and credential inputs', () => {
|
|
expect(normalizeKnowledgeInput({})).toEqual({ ok: false, error: 'keyRequired' });
|
|
expect(normalizeKnowledgeInput({ key: 'deploy_token' })).toEqual({ ok: false, error: 'valueRequired' });
|
|
|
|
const longKey = 'k'.repeat(KNOWLEDGE_KEY_MAX_LENGTH + 1);
|
|
expect(normalizeKnowledgeInput({ key: longKey, value: 'v' })).toEqual({
|
|
ok: false,
|
|
error: 'keyTooLong',
|
|
});
|
|
|
|
const longVal = 'v'.repeat(KNOWLEDGE_VALUE_MAX_LENGTH + 1);
|
|
expect(normalizeKnowledgeInput({ key: 'k', value: longVal })).toEqual({
|
|
ok: false,
|
|
error: 'valueTooLong',
|
|
});
|
|
|
|
// Auto-infers 'credential' category for keys or tokens
|
|
const cred = normalizeKnowledgeInput({
|
|
key: 'deploy_token',
|
|
value: 'ghp_abcdef1234567890abcdef12345678901234',
|
|
});
|
|
expect(cred).toEqual({
|
|
ok: true,
|
|
value: {
|
|
action: 'set',
|
|
category: 'credential',
|
|
key: 'deploy_token',
|
|
value: 'ghp_abcdef1234567890abcdef12345678901234',
|
|
},
|
|
});
|
|
|
|
// Respects explicit category
|
|
const config = normalizeKnowledgeInput({
|
|
category: 'config',
|
|
key: 'app_port',
|
|
value: '8080',
|
|
});
|
|
expect(config).toEqual({
|
|
ok: true,
|
|
value: {
|
|
action: 'set',
|
|
category: 'config',
|
|
key: 'app_port',
|
|
value: '8080',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('detects sensitive keys or values for UI masking', () => {
|
|
expect(isSensitiveKeyOrValue('db_password', '123456')).toBe(true);
|
|
expect(isSensitiveKeyOrValue('api_key', 'some-key')).toBe(true);
|
|
expect(isSensitiveKeyOrValue('token', 'ghp_12345')).toBe(true);
|
|
expect(isSensitiveKeyOrValue('normal_key', 'normal_val')).toBe(false);
|
|
});
|
|
|
|
it('extracts distillation JSON robustly across formats and thought wrappers', () => {
|
|
// 1. Pure JSON
|
|
const pure = extractDistillationJson('{"workLog": {"title": "T1", "summary": "S1"}}');
|
|
expect(pure).toEqual({ workLog: { title: 'T1', summary: 'S1' } });
|
|
|
|
// 2. Markdown codeblock
|
|
const md = extractDistillationJson('```json\n{"workLog": {"title": "T2", "summary": "S2"}}\n```');
|
|
expect(md).toEqual({ workLog: { title: 'T2', summary: 'S2' } });
|
|
|
|
// 3. With <think> tag and markdown block
|
|
const withThinkAndMd = extractDistillationJson(
|
|
'<think>用户进行了硬件查看</think>\n```json\n{"workLog": {"title": "T3", "summary": "S3"}}\n```'
|
|
);
|
|
expect(withThinkAndMd).toEqual({ workLog: { title: 'T3', summary: 'S3' } });
|
|
|
|
// 4. With <thought> tag and raw JSON without codeblock
|
|
const withThoughtNoBlock = extractDistillationJson(
|
|
'<thought>分析过程如下:完成</thought>\n{"workLog": {"title": "T4", "summary": "S4"}}'
|
|
);
|
|
expect(withThoughtNoBlock).toEqual({ workLog: { title: 'T4', summary: 'S4' } });
|
|
|
|
// 5. Natural language wrapper around JSON
|
|
const naturalLang = extractDistillationJson(
|
|
'经过分析,本次任务总结如下:\n{"workLog": {"title": "T5", "summary": "S5"}}\n请查阅。'
|
|
);
|
|
expect(naturalLang).toEqual({ workLog: { title: 'T5', summary: 'S5' } });
|
|
|
|
// 6. Invalid or empty content
|
|
expect(extractDistillationJson('')).toBeNull();
|
|
expect(extractDistillationJson('not a json string')).toBeNull();
|
|
expect(extractDistillationJson('<think>only thought</think>')).toBeNull();
|
|
|
|
// 7. With draft inside <think> and real final JSON in markdown block outside
|
|
const withDraftInThink = extractDistillationJson(
|
|
'<think>草稿如下:\n```json\n{"workLog": {"title": "Draft", "summary": "Draft summary"}}\n```\n最终结论如下:</think>\n```json\n{"workLog": {"title": "Final", "summary": "Final summary"}}\n```'
|
|
);
|
|
expect(withDraftInThink).toEqual({ workLog: { title: 'Final', summary: 'Final summary' } });
|
|
});
|
|
|
|
it('normalizes workLog input supporting create and update_latest modes', () => {
|
|
const createLog = normalizeWorkLogInput({
|
|
mode: 'create',
|
|
title: '新建任务',
|
|
summary: '新建任务摘要',
|
|
});
|
|
expect(createLog).toEqual({
|
|
ok: true,
|
|
value: {
|
|
mode: 'create',
|
|
title: '新建任务',
|
|
summary: '新建任务摘要',
|
|
},
|
|
});
|
|
|
|
const updateLog = normalizeWorkLogInput({
|
|
mode: 'update_latest',
|
|
title: '更新任务',
|
|
summary: '更新任务摘要',
|
|
});
|
|
expect(updateLog).toEqual({
|
|
ok: true,
|
|
value: {
|
|
mode: 'update_latest',
|
|
title: '更新任务',
|
|
summary: '更新任务摘要',
|
|
},
|
|
});
|
|
|
|
// 缺省 mode 默认降级为 create
|
|
const defaultLog = normalizeWorkLogInput({
|
|
title: '默认任务',
|
|
summary: '默认任务摘要',
|
|
});
|
|
expect(defaultLog).toEqual({
|
|
ok: true,
|
|
value: {
|
|
mode: 'create',
|
|
title: '默认任务',
|
|
summary: '默认任务摘要',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('normalizes knowledge input supporting set and delete actions', () => {
|
|
const setAction = normalizeKnowledgeInput({
|
|
action: 'set',
|
|
category: 'config',
|
|
key: 'port',
|
|
value: '8080',
|
|
});
|
|
expect(setAction).toEqual({
|
|
ok: true,
|
|
value: {
|
|
action: 'set',
|
|
category: 'config',
|
|
key: 'port',
|
|
value: '8080',
|
|
},
|
|
});
|
|
|
|
const deleteAction = normalizeKnowledgeInput({
|
|
action: 'delete',
|
|
key: 'port',
|
|
});
|
|
expect(deleteAction).toEqual({
|
|
ok: true,
|
|
value: {
|
|
action: 'delete',
|
|
category: 'note',
|
|
key: 'port',
|
|
value: '',
|
|
},
|
|
});
|
|
});
|
|
});
|