## 版本概述 发布 **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)
486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { inferLocationHintMock } = vi.hoisted(() => ({
|
|
inferLocationHintMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/worker/ip-geo', () => ({
|
|
inferLocationHint: inferLocationHintMock,
|
|
}));
|
|
|
|
import type { Env } from '../../src/types';
|
|
import { UserDBDO } from '../../src/worker/user-db';
|
|
|
|
interface WorkLogRowRecord {
|
|
id: number;
|
|
user_id: number;
|
|
server_id: number;
|
|
title: string;
|
|
summary: string;
|
|
created_at: number;
|
|
updated_at: number;
|
|
}
|
|
|
|
interface KnowledgeRowRecord {
|
|
id: number;
|
|
user_id: number;
|
|
server_id: number;
|
|
category: string;
|
|
key: string;
|
|
value: string;
|
|
created_at: number;
|
|
updated_at: number;
|
|
}
|
|
|
|
class FakeSql {
|
|
workLogs: WorkLogRowRecord[] = [];
|
|
knowledge: KnowledgeRowRecord[] = [];
|
|
servers: Array<{ id: number; user_id: number; name: string }> = [
|
|
{ id: 1, user_id: 10, name: 'Prod Server' },
|
|
{ id: 2, user_id: 20, name: 'Other User Server' },
|
|
];
|
|
private nextLogId = 1;
|
|
private nextKnowledgeId = 1;
|
|
statements: Array<{ query: string; values: unknown[] }> = [];
|
|
|
|
exec(query: string, ...values: unknown[]): { toArray: () => unknown[] } {
|
|
this.statements.push({ query, values });
|
|
const q = query.replace(/\s+/g, ' ');
|
|
|
|
if (
|
|
q.includes('CREATE TABLE') ||
|
|
q.includes('CREATE INDEX') ||
|
|
q.includes('PRAGMA table_info') ||
|
|
q.includes('DROP TABLE')
|
|
) {
|
|
if (q.includes('PRAGMA table_info(servers)')) {
|
|
return { toArray: () => [{ name: 'region' }, { name: 'inferred_hint' }] as unknown[] };
|
|
}
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.includes('SELECT user_id FROM servers WHERE id = ?')) {
|
|
const serverId = values[0];
|
|
const s = this.servers.find((srv) => srv.id === serverId);
|
|
return { toArray: () => (s ? [{ user_id: s.user_id }] : []) };
|
|
}
|
|
|
|
if (q.includes('FROM servers WHERE id = ?')) {
|
|
const serverId = values[0];
|
|
const s = this.servers.find((srv) => srv.id === serverId);
|
|
if (!s) return { toArray: () => [] };
|
|
return {
|
|
toArray: () => [
|
|
{
|
|
id: s.id,
|
|
user_id: s.user_id,
|
|
name: s.name,
|
|
host: '1.2.3.4',
|
|
port: 22,
|
|
username: 'root',
|
|
auth_method: 'password',
|
|
region: null,
|
|
inferred_hint: null,
|
|
tags: '[]',
|
|
os: null,
|
|
jump_server_id: null,
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (q.startsWith('UPDATE servers SET')) {
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
// Work logs query
|
|
if (q.includes('FROM server_work_logs WHERE server_id = ? AND user_id = ?')) {
|
|
const [serverId, userId] = values as [number, number];
|
|
const rows = this.workLogs
|
|
.filter((l) => l.server_id === serverId && l.user_id === userId)
|
|
.sort((a, b) => b.updated_at - a.updated_at);
|
|
return { toArray: () => rows as unknown[] };
|
|
}
|
|
|
|
if (q.includes('SELECT user_id FROM server_work_logs WHERE id = ? AND server_id = ?')) {
|
|
const [id, serverId] = values as [number, number];
|
|
const found = this.workLogs.filter((l) => l.id === id && l.server_id === serverId);
|
|
return { toArray: () => found as unknown[] };
|
|
}
|
|
|
|
if (q.includes('SELECT id FROM server_work_logs') && q.includes('ORDER BY updated_at DESC LIMIT 1')) {
|
|
const [serverId, userId] = values as [number, number];
|
|
const sorted = this.workLogs
|
|
.filter((l) => l.server_id === serverId && l.user_id === userId)
|
|
.sort((a, b) => b.updated_at - a.updated_at);
|
|
return { toArray: () => (sorted.length > 0 ? [{ id: sorted[0].id }] : []) };
|
|
}
|
|
|
|
if (q.startsWith('UPDATE server_work_logs SET title = ?, summary = ?, updated_at = ? WHERE id = ?')) {
|
|
const [title, summary, updatedAt, id] = values as [string, string, number, number];
|
|
const idx = this.workLogs.findIndex((l) => l.id === id);
|
|
if (idx >= 0) {
|
|
this.workLogs[idx] = { ...this.workLogs[idx], title, summary, updated_at: updatedAt };
|
|
}
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.startsWith('INSERT INTO server_work_logs')) {
|
|
const [userId, serverId, title, summary, createdAt, updatedAt] = values as [
|
|
number,
|
|
number,
|
|
string,
|
|
string,
|
|
number,
|
|
number,
|
|
];
|
|
const row: WorkLogRowRecord = {
|
|
id: this.nextLogId++,
|
|
user_id: userId,
|
|
server_id: serverId,
|
|
title,
|
|
summary,
|
|
created_at: createdAt,
|
|
updated_at: updatedAt,
|
|
};
|
|
this.workLogs.push(row);
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.startsWith('DELETE FROM server_work_logs WHERE id = ? AND server_id = ?')) {
|
|
const [id, serverId, userId] = values as [number, number, number];
|
|
this.workLogs = this.workLogs.filter(
|
|
(l) => !(l.id === id && l.server_id === serverId && l.user_id === userId)
|
|
);
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.startsWith('DELETE FROM server_work_logs WHERE server_id = ? AND user_id = ? AND id NOT IN')) {
|
|
const [serverId, userId] = values as [number, number];
|
|
const sLogs = this.workLogs
|
|
.filter((l) => l.server_id === serverId && l.user_id === userId)
|
|
.sort((a, b) => b.updated_at - a.updated_at);
|
|
const keep = new Set(sLogs.slice(0, 10).map((l) => l.id));
|
|
this.workLogs = this.workLogs.filter(
|
|
(l) => !(l.server_id === serverId && l.user_id === userId && !keep.has(l.id))
|
|
);
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.startsWith('DELETE FROM server_work_logs WHERE server_id = ?')) {
|
|
const serverId = values[0];
|
|
this.workLogs = this.workLogs.filter((l) => l.server_id !== serverId);
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
// Knowledge query
|
|
if (q.includes('FROM server_knowledge WHERE server_id = ? AND user_id = ? AND key = ?')) {
|
|
const [serverId, userId, key] = values as [number, number, string];
|
|
const rows = this.knowledge.filter(
|
|
(k) => k.server_id === serverId && k.user_id === userId && k.key === key
|
|
);
|
|
return { toArray: () => rows as unknown[] };
|
|
}
|
|
|
|
if (q.includes('FROM server_knowledge WHERE server_id = ? AND user_id = ?')) {
|
|
const [serverId, userId] = values as [number, number];
|
|
const rows = this.knowledge
|
|
.filter((k) => k.server_id === serverId && k.user_id === userId)
|
|
.sort((a, b) => b.updated_at - a.updated_at);
|
|
return { toArray: () => rows as unknown[] };
|
|
}
|
|
|
|
if (q.includes('SELECT user_id FROM server_knowledge WHERE id = ? AND server_id = ?')) {
|
|
const [id, serverId] = values as [number, number];
|
|
const found = this.knowledge.filter((k) => k.id === id && k.server_id === serverId);
|
|
return { toArray: () => found as unknown[] };
|
|
}
|
|
|
|
if (q.startsWith('DELETE FROM server_knowledge WHERE user_id = ? AND server_id = ? AND key = ?')) {
|
|
const [userId, serverId, key] = values as [number, number, string];
|
|
this.knowledge = this.knowledge.filter(
|
|
(k) => !(k.user_id === userId && k.server_id === serverId && k.key === key)
|
|
);
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.startsWith('INSERT INTO server_knowledge')) {
|
|
const [userId, serverId, category, key, value, createdAt, updatedAt] = values as [
|
|
number,
|
|
number,
|
|
string,
|
|
string,
|
|
string,
|
|
number,
|
|
number,
|
|
];
|
|
const existingIdx = this.knowledge.findIndex(
|
|
(k) => k.user_id === userId && k.server_id === serverId && k.key === key
|
|
);
|
|
if (existingIdx >= 0) {
|
|
this.knowledge[existingIdx] = {
|
|
...this.knowledge[existingIdx],
|
|
category,
|
|
value,
|
|
updated_at: updatedAt,
|
|
};
|
|
} else {
|
|
const row: KnowledgeRowRecord = {
|
|
id: this.nextKnowledgeId++,
|
|
user_id: userId,
|
|
server_id: serverId,
|
|
category,
|
|
key,
|
|
value,
|
|
created_at: createdAt,
|
|
updated_at: updatedAt,
|
|
};
|
|
this.knowledge.push(row);
|
|
}
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.startsWith('DELETE FROM server_knowledge WHERE id = ? AND server_id = ?')) {
|
|
const [id, serverId, userId] = values as [number, number, number];
|
|
this.knowledge = this.knowledge.filter(
|
|
(k) => !(k.id === id && k.server_id === serverId && k.user_id === userId)
|
|
);
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.startsWith('DELETE FROM server_knowledge WHERE server_id = ? AND user_id = ? AND id NOT IN')) {
|
|
const [serverId, userId] = values as [number, number];
|
|
const sK = this.knowledge
|
|
.filter((k) => k.server_id === serverId && k.user_id === userId)
|
|
.sort((a, b) => b.updated_at - a.updated_at);
|
|
const keep = new Set(sK.slice(0, 50).map((k) => k.id));
|
|
this.knowledge = this.knowledge.filter(
|
|
(k) => !(k.server_id === serverId && k.user_id === userId && !keep.has(k.id))
|
|
);
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.startsWith('DELETE FROM server_knowledge WHERE server_id = ?')) {
|
|
const serverId = values[0];
|
|
this.knowledge = this.knowledge.filter((k) => k.server_id !== serverId);
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
if (q.startsWith('DELETE FROM servers WHERE id = ?')) {
|
|
return { toArray: () => [] };
|
|
}
|
|
|
|
return { toArray: () => [] };
|
|
}
|
|
}
|
|
|
|
describe('UserDBDO unified server memory', () => {
|
|
let fakeSql: FakeSql;
|
|
let userDb: UserDBDO;
|
|
|
|
beforeEach(() => {
|
|
fakeSql = new FakeSql();
|
|
userDb = new UserDBDO(
|
|
{
|
|
storage: {
|
|
sql: fakeSql,
|
|
get: vi.fn(),
|
|
put: vi.fn(),
|
|
delete: vi.fn(),
|
|
},
|
|
} as never,
|
|
{} as Env
|
|
);
|
|
});
|
|
|
|
it('fetches server memory with ownership enforcement', async () => {
|
|
// Other user's server -> 403
|
|
const resForbidden = await userDb.fetch(
|
|
new Request('http://internal/internal/servers/2/memory?user_id=10')
|
|
);
|
|
expect(resForbidden.status).toBe(403);
|
|
|
|
// Non-existent server -> 404
|
|
const resNotFound = await userDb.fetch(
|
|
new Request('http://internal/internal/servers/999/memory?user_id=10')
|
|
);
|
|
expect(resNotFound.status).toBe(404);
|
|
|
|
// Owned server -> 200
|
|
const res = await userDb.fetch(
|
|
new Request('http://internal/internal/servers/1/memory?user_id=10')
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const data = (await res.json()) as { workLogs: unknown[]; knowledge: unknown[] };
|
|
expect(data.workLogs).toHaveLength(0);
|
|
expect(data.knowledge).toHaveLength(0);
|
|
});
|
|
|
|
it('saves and deletes a work log', async () => {
|
|
// Valid work log -> 201
|
|
const resCreate = await userDb.fetch(
|
|
new Request('http://internal/internal/servers/1/work-logs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
user_id: 10,
|
|
title: '巡检服务器硬件',
|
|
summary: 'CPU与内存占用正常',
|
|
}),
|
|
})
|
|
);
|
|
expect(resCreate.status).toBe(201);
|
|
const created = (await resCreate.json()) as WorkLogRowRecord;
|
|
expect(created.title).toBe('巡检服务器硬件');
|
|
|
|
// Delete with wrong user -> 403
|
|
const resForbidden = await userDb.fetch(
|
|
new Request(`http://internal/internal/servers/1/work-logs/${created.id}?user_id=99`, {
|
|
method: 'DELETE',
|
|
})
|
|
);
|
|
expect(resForbidden.status).toBe(403);
|
|
|
|
// Delete with owner -> 200
|
|
const resDel = await userDb.fetch(
|
|
new Request(`http://internal/internal/servers/1/work-logs/${created.id}?user_id=10`, {
|
|
method: 'DELETE',
|
|
})
|
|
);
|
|
expect(resDel.status).toBe(200);
|
|
expect(fakeSql.workLogs).toHaveLength(0);
|
|
});
|
|
|
|
it('saves and deletes knowledge and credentials', async () => {
|
|
// Save deploy token -> 201
|
|
const resCreate = await userDb.fetch(
|
|
new Request('http://internal/internal/servers/1/knowledge', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
user_id: 10,
|
|
category: 'credential',
|
|
key: 'deploy_token',
|
|
value: 'ghp_secret_token_123',
|
|
}),
|
|
})
|
|
);
|
|
expect(resCreate.status).toBe(201);
|
|
const created = (await resCreate.json()) as KnowledgeRowRecord;
|
|
expect(created.key).toBe('deploy_token');
|
|
expect(created.value).toBe('ghp_secret_token_123');
|
|
|
|
// Delete with owner -> 200
|
|
const resDel = await userDb.fetch(
|
|
new Request(`http://internal/internal/servers/1/knowledge/${created.id}?user_id=10`, {
|
|
method: 'DELETE',
|
|
})
|
|
);
|
|
expect(resDel.status).toBe(200);
|
|
expect(fakeSql.knowledge).toHaveLength(0);
|
|
});
|
|
|
|
it('handles batch save of memory from AI session', async () => {
|
|
const resBatch = await userDb.fetch(
|
|
new Request('http://internal/internal/servers/1/memory/batch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
user_id: 10,
|
|
workLog: {
|
|
title: '部署应用并更新配置',
|
|
summary: '构建镜像并成功启动,端口正常监听',
|
|
},
|
|
knowledge: [
|
|
{ category: 'credential', key: 'api_token', value: 'sk-123456' },
|
|
{ category: 'config', key: 'app_port', value: '3000' },
|
|
],
|
|
}),
|
|
})
|
|
);
|
|
expect(resBatch.status).toBe(200);
|
|
expect(fakeSql.workLogs).toHaveLength(1);
|
|
expect(fakeSql.knowledge).toHaveLength(2);
|
|
});
|
|
|
|
it('supports update_latest mode for work log and delete action for knowledge in batch save', async () => {
|
|
// 1. 先有一条工作日志和一条知识项
|
|
fakeSql.workLogs = [
|
|
{
|
|
id: 101,
|
|
user_id: 10,
|
|
server_id: 1,
|
|
title: '初始排查',
|
|
summary: '正在检查端口',
|
|
created_at: 1000,
|
|
updated_at: 1000,
|
|
},
|
|
];
|
|
fakeSql.knowledge = [
|
|
{
|
|
id: 201,
|
|
user_id: 10,
|
|
server_id: 1,
|
|
category: 'config',
|
|
key: 'app_port',
|
|
value: '3000',
|
|
created_at: 1000,
|
|
updated_at: 1000,
|
|
},
|
|
];
|
|
|
|
// 2. 批量调用:以 update_latest 更新工作日志,以 delete 删除废弃的知识项
|
|
const resUpdate = await userDb.fetch(
|
|
new Request('http://internal/internal/servers/1/memory/batch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
user_id: 10,
|
|
workLog: {
|
|
mode: 'update_latest',
|
|
title: '排查并修复端口冲突',
|
|
summary: '已将冲突端口修改为 3001 并成功启动',
|
|
},
|
|
knowledge: [
|
|
{ action: 'delete', key: 'app_port' },
|
|
{ category: 'config', key: 'new_app_port', value: '3001' },
|
|
],
|
|
}),
|
|
})
|
|
);
|
|
expect(resUpdate.status).toBe(200);
|
|
|
|
// 工作日志未增加,标题与内容已原子更新
|
|
expect(fakeSql.workLogs).toHaveLength(1);
|
|
expect(fakeSql.workLogs[0].title).toBe('排查并修复端口冲突');
|
|
expect(fakeSql.workLogs[0].summary).toBe('已将冲突端口修改为 3001 并成功启动');
|
|
|
|
// 旧知识项已删除,新知识项已添加
|
|
expect(fakeSql.knowledge).toHaveLength(1);
|
|
expect(fakeSql.knowledge[0].key).toBe('new_app_port');
|
|
expect(fakeSql.knowledge[0].value).toBe('3001');
|
|
});
|
|
|
|
it('drops legacy tables on init and cleans up on server delete without 500 error', async () => {
|
|
const droppedMemories = fakeSql.statements.some((s) =>
|
|
s.query.includes('DROP TABLE IF EXISTS server_memories')
|
|
);
|
|
const droppedCheckpoints = fakeSql.statements.some((s) =>
|
|
s.query.includes('DROP TABLE IF EXISTS server_task_checkpoints')
|
|
);
|
|
expect(droppedMemories).toBe(true);
|
|
expect(droppedCheckpoints).toBe(true);
|
|
|
|
const resDel = await userDb.fetch(
|
|
new Request('http://internal/internal/servers/1', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ user_id: 10 }),
|
|
})
|
|
);
|
|
expect(resDel.status).toBe(200);
|
|
});
|
|
});
|