mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.91.0
This commit is contained in:
@@ -94,6 +94,12 @@ export interface AsterSessionInfo {
|
||||
working_dir?: string;
|
||||
}
|
||||
|
||||
export interface AsterTodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
active_form?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TauriMessageContent(匹配后端 TauriMessageContent 枚举)
|
||||
*/
|
||||
@@ -135,6 +141,7 @@ export interface AsterSessionDetail {
|
||||
turns?: AgentThreadTurn[];
|
||||
items?: AgentThreadItem[];
|
||||
queued_turns?: QueuedTurnSnapshot[];
|
||||
todo_items?: AsterTodoItem[];
|
||||
}
|
||||
|
||||
export interface AgentTurnConfigSnapshot {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { ContextMemoryAPI } from "./contextMemory";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("contextMemory API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应代理基础记忆命令", async () => {
|
||||
vi.mocked(safeInvoke)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce([{ id: "m1", title: "任务" }])
|
||||
.mockResolvedValueOnce("上下文")
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({ session_id: "session-1", active_memories: 2 })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
ContextMemoryAPI.saveMemoryEntry({
|
||||
session_id: "session-1",
|
||||
file_type: "task_plan",
|
||||
title: "计划",
|
||||
content: "内容",
|
||||
tags: ["任务"],
|
||||
priority: 3,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
ContextMemoryAPI.getSessionMemories("session-1"),
|
||||
).resolves.toEqual([expect.objectContaining({ id: "m1" })]);
|
||||
await expect(ContextMemoryAPI.getMemoryContext("session-1")).resolves.toBe(
|
||||
"上下文",
|
||||
);
|
||||
await expect(
|
||||
ContextMemoryAPI.recordError({
|
||||
session_id: "session-1",
|
||||
error_description: "错误",
|
||||
attempted_solution: "方案",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
ContextMemoryAPI.shouldAvoidOperation("session-1", "重复操作"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
ContextMemoryAPI.markErrorResolved({
|
||||
session_id: "session-1",
|
||||
error_description: "错误",
|
||||
resolution: "已修复",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(ContextMemoryAPI.getMemoryStats("session-1")).resolves.toEqual(
|
||||
expect.objectContaining({ active_memories: 2 }),
|
||||
);
|
||||
await expect(
|
||||
ContextMemoryAPI.cleanupExpiredMemories(),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("应通过辅助方法复用基础命令", async () => {
|
||||
vi.mocked(safeInvoke)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
await ContextMemoryAPI.saveTaskPlan("session-2", "计划", "内容");
|
||||
await ContextMemoryAPI.saveFinding("session-2", "发现", "内容", ["关键"]);
|
||||
await ContextMemoryAPI.logProgress("session-2", "进度", "完成一半");
|
||||
await ContextMemoryAPI.apply2ActionRule("session-2", "发现了线索");
|
||||
await expect(
|
||||
ContextMemoryAPI.recordErrorWithCheck(
|
||||
"session-2",
|
||||
"失败",
|
||||
"重试",
|
||||
"再次点击",
|
||||
),
|
||||
).resolves.toEqual({ shouldAvoid: false });
|
||||
|
||||
expect(safeInvoke).toHaveBeenCalledWith("save_memory_entry", {
|
||||
request: expect.objectContaining({ file_type: "task_plan" }),
|
||||
});
|
||||
expect(safeInvoke).toHaveBeenCalledWith("save_memory_entry", {
|
||||
request: expect.objectContaining({ file_type: "findings" }),
|
||||
});
|
||||
expect(safeInvoke).toHaveBeenCalledWith("save_memory_entry", {
|
||||
request: expect.objectContaining({ file_type: "progress" }),
|
||||
});
|
||||
expect(safeInvoke).toHaveBeenCalledWith("record_error", {
|
||||
request: expect.objectContaining({ session_id: "session-2" }),
|
||||
});
|
||||
expect(safeInvoke).toHaveBeenCalledWith("should_avoid_operation", {
|
||||
sessionId: "session-2",
|
||||
operationDescription: "再次点击",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* 上下文记忆管理 API
|
||||
*
|
||||
* 基于文件系统的持久化记忆系统,解决 AI Agent 的上下文丢失、目标漂移、错误重复问题
|
||||
*/
|
||||
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
session_id: string;
|
||||
file_type: MemoryFileType;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
priority: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export type MemoryFileType =
|
||||
| "task_plan"
|
||||
| "findings"
|
||||
| "progress"
|
||||
| "error_log";
|
||||
|
||||
export interface MemoryStats {
|
||||
session_id: string;
|
||||
active_memories: number;
|
||||
archived_memories: number;
|
||||
unresolved_errors: number;
|
||||
resolved_errors: number;
|
||||
memory_by_type: Record<MemoryFileType, number>;
|
||||
last_updated: number;
|
||||
}
|
||||
|
||||
export interface SaveMemoryRequest {
|
||||
session_id: string;
|
||||
file_type: MemoryFileType;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface RecordErrorRequest {
|
||||
session_id: string;
|
||||
error_description: string;
|
||||
attempted_solution: string;
|
||||
}
|
||||
|
||||
export interface ResolveErrorRequest {
|
||||
session_id: string;
|
||||
error_description: string;
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文记忆管理 API 类
|
||||
*/
|
||||
export class ContextMemoryAPI {
|
||||
/**
|
||||
* 保存记忆条目
|
||||
*/
|
||||
static async saveMemoryEntry(request: SaveMemoryRequest): Promise<void> {
|
||||
return safeInvoke<void>("save_memory_entry", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话记忆
|
||||
*/
|
||||
static async getSessionMemories(
|
||||
sessionId: string,
|
||||
fileType?: MemoryFileType,
|
||||
): Promise<MemoryEntry[]> {
|
||||
return safeInvoke<MemoryEntry[]>("get_session_memories", {
|
||||
sessionId,
|
||||
fileType: fileType || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记忆上下文(用于 AI 上下文)
|
||||
*/
|
||||
static async getMemoryContext(sessionId: string): Promise<string> {
|
||||
return safeInvoke<string>("get_memory_context", { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误
|
||||
*/
|
||||
static async recordError(request: RecordErrorRequest): Promise<void> {
|
||||
return safeInvoke<void>("record_error", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该避免某个操作(3次错误协议)
|
||||
*/
|
||||
static async shouldAvoidOperation(
|
||||
sessionId: string,
|
||||
operationDescription: string,
|
||||
): Promise<boolean> {
|
||||
return safeInvoke<boolean>("should_avoid_operation", {
|
||||
sessionId,
|
||||
operationDescription,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记错误已解决
|
||||
*/
|
||||
static async markErrorResolved(request: ResolveErrorRequest): Promise<void> {
|
||||
return safeInvoke<void>("mark_error_resolved", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记忆统计信息
|
||||
*/
|
||||
static async getMemoryStats(sessionId: string): Promise<MemoryStats> {
|
||||
return safeInvoke<MemoryStats>("get_memory_stats", { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期记忆
|
||||
*/
|
||||
static async cleanupExpiredMemories(): Promise<void> {
|
||||
return safeInvoke<void>("cleanup_expired_memories");
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存任务计划记忆
|
||||
*/
|
||||
static async saveTaskPlan(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
priority: number = 3,
|
||||
): Promise<void> {
|
||||
return this.saveMemoryEntry({
|
||||
session_id: sessionId,
|
||||
file_type: "task_plan",
|
||||
title,
|
||||
content,
|
||||
tags: ["任务计划"],
|
||||
priority,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存研究发现
|
||||
*/
|
||||
static async saveFinding(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
tags: string[] = [],
|
||||
priority: number = 4,
|
||||
): Promise<void> {
|
||||
return this.saveMemoryEntry({
|
||||
session_id: sessionId,
|
||||
file_type: "findings",
|
||||
title,
|
||||
content,
|
||||
tags: ["发现", ...tags],
|
||||
priority,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录进度
|
||||
*/
|
||||
static async logProgress(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
return this.saveMemoryEntry({
|
||||
session_id: sessionId,
|
||||
file_type: "progress",
|
||||
title,
|
||||
content,
|
||||
tags: ["进度"],
|
||||
priority: 2,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用 2-Action 规则:每2次视觉操作后保存发现
|
||||
*/
|
||||
static async apply2ActionRule(
|
||||
sessionId: string,
|
||||
finding: string,
|
||||
): Promise<void> {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
return this.saveFinding(
|
||||
sessionId,
|
||||
`2-Action 规则发现 (${timestamp})`,
|
||||
finding,
|
||||
["2-Action规则", "自动保存"],
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误并检查是否需要避免重复操作
|
||||
*/
|
||||
static async recordErrorWithCheck(
|
||||
sessionId: string,
|
||||
errorDescription: string,
|
||||
attemptedSolution: string,
|
||||
operationDescription?: string,
|
||||
): Promise<{ shouldAvoid: boolean }> {
|
||||
// 记录错误
|
||||
await this.recordError({
|
||||
session_id: sessionId,
|
||||
error_description: errorDescription,
|
||||
attempted_solution: attemptedSolution,
|
||||
});
|
||||
|
||||
// 检查是否应该避免该操作
|
||||
const shouldAvoid = operationDescription
|
||||
? await this.shouldAvoidOperation(sessionId, operationDescription)
|
||||
: false;
|
||||
|
||||
return { shouldAvoid };
|
||||
}
|
||||
}
|
||||
|
||||
export default ContextMemoryAPI;
|
||||
@@ -1,14 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import {
|
||||
cleanupMemory,
|
||||
getMemoryAutoIndex,
|
||||
getMemoryEffectiveSources,
|
||||
getMemoryOverview,
|
||||
getMemoryStats,
|
||||
requestMemoryAnalysis,
|
||||
toggleMemoryAuto,
|
||||
updateMemoryAutoNote,
|
||||
analyzeContextMemory,
|
||||
cleanupContextMemory,
|
||||
getContextMemoryAutoIndex,
|
||||
getContextMemoryEffectiveSources,
|
||||
getContextMemoryOverview,
|
||||
getContextMemoryStats,
|
||||
toggleContextMemoryAuto,
|
||||
updateContextMemoryAutoNote,
|
||||
} from "./memoryRuntime";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
@@ -20,7 +20,7 @@ describe("memoryRuntime API", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应代理记忆查询命令", async () => {
|
||||
it("应通过 context memory 命名代理记忆查询命令", async () => {
|
||||
vi.mocked(safeInvoke).mockImplementation(async (command) => {
|
||||
switch (command) {
|
||||
case "memory_runtime_get_stats":
|
||||
@@ -44,22 +44,22 @@ describe("memoryRuntime API", () => {
|
||||
}
|
||||
});
|
||||
|
||||
await expect(getMemoryStats()).resolves.toEqual(
|
||||
await expect(getContextMemoryStats()).resolves.toEqual(
|
||||
expect.objectContaining({ total_entries: 1 }),
|
||||
);
|
||||
await expect(requestMemoryAnalysis()).resolves.toEqual(
|
||||
await expect(analyzeContextMemory()).resolves.toEqual(
|
||||
expect.objectContaining({ analyzed_sessions: 1 }),
|
||||
);
|
||||
await expect(cleanupMemory()).resolves.toEqual(
|
||||
await expect(cleanupContextMemory()).resolves.toEqual(
|
||||
expect.objectContaining({ cleaned_entries: 1 }),
|
||||
);
|
||||
await expect(getMemoryOverview(200)).resolves.toEqual(
|
||||
await expect(getContextMemoryOverview(200)).resolves.toEqual(
|
||||
expect.objectContaining({ entries: [] }),
|
||||
);
|
||||
await expect(getMemoryEffectiveSources()).resolves.toEqual(
|
||||
await expect(getContextMemoryEffectiveSources()).resolves.toEqual(
|
||||
expect.objectContaining({ sources: [] }),
|
||||
);
|
||||
await expect(getMemoryAutoIndex()).resolves.toEqual(
|
||||
await expect(getContextMemoryAutoIndex()).resolves.toEqual(
|
||||
expect.objectContaining({ items: [] }),
|
||||
);
|
||||
|
||||
@@ -81,15 +81,65 @@ describe("memoryRuntime API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("应代理自动记忆开关与写入命令", async () => {
|
||||
it("应暴露清晰的 context memory 命名", async () => {
|
||||
vi.mocked(safeInvoke).mockImplementation(async (command) => {
|
||||
switch (command) {
|
||||
case "memory_runtime_get_stats":
|
||||
return { total_entries: 9 };
|
||||
case "memory_runtime_request_analysis":
|
||||
return { analyzed_sessions: 3 };
|
||||
case "memory_runtime_cleanup":
|
||||
return { cleaned_entries: 4 };
|
||||
case "memory_runtime_get_overview":
|
||||
return { stats: {}, categories: [], entries: [] };
|
||||
case "memory_get_effective_sources":
|
||||
return { sources: [] };
|
||||
case "memory_get_auto_index":
|
||||
return { items: [] };
|
||||
case "memory_toggle_auto":
|
||||
return { enabled: true };
|
||||
case "memory_update_auto_note":
|
||||
return { items: [] };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
await expect(getContextMemoryStats()).resolves.toEqual(
|
||||
expect.objectContaining({ total_entries: 9 }),
|
||||
);
|
||||
await expect(analyzeContextMemory()).resolves.toEqual(
|
||||
expect.objectContaining({ analyzed_sessions: 3 }),
|
||||
);
|
||||
await expect(cleanupContextMemory()).resolves.toEqual(
|
||||
expect.objectContaining({ cleaned_entries: 4 }),
|
||||
);
|
||||
await expect(getContextMemoryOverview()).resolves.toEqual(
|
||||
expect.objectContaining({ entries: [] }),
|
||||
);
|
||||
await expect(getContextMemoryEffectiveSources()).resolves.toEqual(
|
||||
expect.objectContaining({ sources: [] }),
|
||||
);
|
||||
await expect(getContextMemoryAutoIndex()).resolves.toEqual(
|
||||
expect.objectContaining({ items: [] }),
|
||||
);
|
||||
await expect(toggleContextMemoryAuto(true)).resolves.toEqual(
|
||||
expect.objectContaining({ enabled: true }),
|
||||
);
|
||||
await expect(updateContextMemoryAutoNote("note")).resolves.toEqual(
|
||||
expect.objectContaining({ items: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("应代理 context memory 自动记忆开关与写入命令", async () => {
|
||||
vi.mocked(safeInvoke)
|
||||
.mockResolvedValueOnce({ enabled: true })
|
||||
.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await expect(toggleMemoryAuto(true)).resolves.toEqual(
|
||||
await expect(toggleContextMemoryAuto(true)).resolves.toEqual(
|
||||
expect.objectContaining({ enabled: true }),
|
||||
);
|
||||
await expect(updateMemoryAutoNote("note", "topic")).resolves.toEqual(
|
||||
await expect(updateContextMemoryAutoNote("note", "topic")).resolves.toEqual(
|
||||
expect.objectContaining({ items: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,17 +28,17 @@ export type {
|
||||
MemoryStatsResponse,
|
||||
} from "./memoryRuntimeTypes";
|
||||
|
||||
export async function getMemoryOverview(
|
||||
export async function getContextMemoryOverview(
|
||||
limit?: number,
|
||||
): Promise<MemoryOverviewResponse> {
|
||||
return safeInvoke("memory_runtime_get_overview", { limit });
|
||||
}
|
||||
|
||||
export async function getMemoryStats(): Promise<MemoryStatsResponse> {
|
||||
export async function getContextMemoryStats(): Promise<MemoryStatsResponse> {
|
||||
return safeInvoke("memory_runtime_get_stats");
|
||||
}
|
||||
|
||||
export async function requestMemoryAnalysis(
|
||||
export async function analyzeContextMemory(
|
||||
fromTimestamp?: number,
|
||||
toTimestamp?: number,
|
||||
): Promise<MemoryAnalysisResult> {
|
||||
@@ -48,11 +48,11 @@ export async function requestMemoryAnalysis(
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupMemory(): Promise<CleanupMemoryResult> {
|
||||
export async function cleanupContextMemory(): Promise<CleanupMemoryResult> {
|
||||
return safeInvoke("memory_runtime_cleanup");
|
||||
}
|
||||
|
||||
export async function getMemoryEffectiveSources(
|
||||
export async function getContextMemoryEffectiveSources(
|
||||
workingDir?: string,
|
||||
activeRelativePath?: string,
|
||||
): Promise<EffectiveMemorySourcesResponse> {
|
||||
@@ -62,19 +62,19 @@ export async function getMemoryEffectiveSources(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMemoryAutoIndex(
|
||||
export async function getContextMemoryAutoIndex(
|
||||
workingDir?: string,
|
||||
): Promise<AutoMemoryIndexResponse> {
|
||||
return safeInvoke("memory_get_auto_index", { workingDir });
|
||||
}
|
||||
|
||||
export async function toggleMemoryAuto(
|
||||
export async function toggleContextMemoryAuto(
|
||||
enabled: boolean,
|
||||
): Promise<MemoryAutoToggleResponse> {
|
||||
return safeInvoke("memory_toggle_auto", { enabled });
|
||||
}
|
||||
|
||||
export async function updateMemoryAutoNote(
|
||||
export async function updateContextMemoryAutoNote(
|
||||
note: string,
|
||||
topic?: string,
|
||||
workingDir?: string,
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface OpenClawEnvironmentDiagnostics {
|
||||
whereCandidates?: string[];
|
||||
supplementalSearchDirs?: string[];
|
||||
supplementalCommandCandidates?: string[];
|
||||
gitWhereCandidates?: string[];
|
||||
gitSupplementalSearchDirs?: string[];
|
||||
gitSupplementalCommandCandidates?: string[];
|
||||
}
|
||||
|
||||
export interface OpenClawActionResult {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { ToolHooksAPI } from "./toolHooks";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("toolHooks API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应代理钩子规则管理命令", async () => {
|
||||
vi.mocked(safeInvoke)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce([{ id: "rule-1", name: "规则1" }])
|
||||
.mockResolvedValueOnce({ "rule-1": { execution_count: 3 } })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const rule = ToolHooksAPI.createCustomRule(
|
||||
"rule-1",
|
||||
"规则1",
|
||||
"说明",
|
||||
"session_start",
|
||||
[],
|
||||
[],
|
||||
);
|
||||
|
||||
await expect(
|
||||
ToolHooksAPI.executeHooks({
|
||||
trigger: "session_start",
|
||||
context: { session_id: "session-1", message_count: 0, metadata: {} },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(ToolHooksAPI.addHookRule(rule)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
ToolHooksAPI.removeHookRule("rule-1"),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
ToolHooksAPI.toggleHookRule("rule-1", true),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(ToolHooksAPI.getHookRules()).resolves.toEqual([
|
||||
expect.objectContaining({ id: "rule-1" }),
|
||||
]);
|
||||
await expect(ToolHooksAPI.getHookExecutionStats()).resolves.toEqual(
|
||||
expect.objectContaining({ "rule-1": expect.any(Object) }),
|
||||
);
|
||||
await expect(
|
||||
ToolHooksAPI.clearHookExecutionStats(),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("应基于上下文辅助方法生成 executeHooks 请求", async () => {
|
||||
vi.mocked(safeInvoke)
|
||||
.mockResolvedValue(undefined)
|
||||
.mockResolvedValue(undefined)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await ToolHooksAPI.triggerSessionStart("session-1", { source: "test" });
|
||||
await ToolHooksAPI.triggerPreToolUse(
|
||||
"session-1",
|
||||
"read_file",
|
||||
{ path: "/tmp/a.ts" },
|
||||
"读取文件",
|
||||
2,
|
||||
);
|
||||
await ToolHooksAPI.triggerStop("session-1", 5, { source: "test" });
|
||||
|
||||
expect(safeInvoke).toHaveBeenNthCalledWith(1, "execute_hooks", {
|
||||
request: expect.objectContaining({
|
||||
trigger: "session_start",
|
||||
context: expect.objectContaining({
|
||||
session_id: "session-1",
|
||||
metadata: expect.objectContaining({ source: "test" }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(safeInvoke).toHaveBeenNthCalledWith(2, "execute_hooks", {
|
||||
request: expect.objectContaining({
|
||||
trigger: "pre_tool_use",
|
||||
context: expect.objectContaining({
|
||||
tool_name: "read_file",
|
||||
message_count: 2,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(safeInvoke).toHaveBeenNthCalledWith(3, "execute_hooks", {
|
||||
request: expect.objectContaining({
|
||||
trigger: "stop",
|
||||
context: expect.objectContaining({
|
||||
message_count: 5,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,344 +0,0 @@
|
||||
/**
|
||||
* 工具钩子管理 API
|
||||
*
|
||||
* 提供工具执行前后的钩子机制,用于自动化上下文记忆管理
|
||||
*/
|
||||
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
export type HookTrigger =
|
||||
| "session_start"
|
||||
| "pre_tool_use"
|
||||
| "post_tool_use"
|
||||
| "stop";
|
||||
|
||||
export interface HookRule {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: HookTrigger;
|
||||
conditions: HookCondition[];
|
||||
actions: HookAction[];
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type HookCondition =
|
||||
| { tool_name_equals: string }
|
||||
| { tool_name_contains: string }
|
||||
| { message_contains: string }
|
||||
| { message_count_greater_than: number }
|
||||
| { error_count_greater_than: number }
|
||||
| { custom: { condition_type: string; parameters: Record<string, string> } };
|
||||
|
||||
export type HookAction =
|
||||
| {
|
||||
save_finding: {
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
priority: number;
|
||||
};
|
||||
}
|
||||
| { update_task_plan: { title: string; content: string; priority: number } }
|
||||
| { log_progress: { title: string; content: string } }
|
||||
| { record_error: { error_description: string; attempted_solution: string } }
|
||||
| { custom: { action_type: string; parameters: Record<string, string> } };
|
||||
|
||||
export interface HookExecutionStats {
|
||||
execution_count: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
last_execution_at: number;
|
||||
average_execution_time_ms: number;
|
||||
}
|
||||
|
||||
export interface HookContextData {
|
||||
session_id: string;
|
||||
tool_name?: string;
|
||||
tool_parameters?: Record<string, string>;
|
||||
tool_result?: string;
|
||||
message_content?: string;
|
||||
message_count: number;
|
||||
error_info?: string;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExecuteHooksRequest {
|
||||
trigger: HookTrigger;
|
||||
context: HookContextData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具钩子管理 API 类
|
||||
*/
|
||||
export class ToolHooksAPI {
|
||||
/**
|
||||
* 执行钩子
|
||||
*/
|
||||
static async executeHooks(request: ExecuteHooksRequest): Promise<void> {
|
||||
return safeInvoke<void>("execute_hooks", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加钩子规则
|
||||
*/
|
||||
static async addHookRule(rule: HookRule): Promise<void> {
|
||||
return safeInvoke<void>("add_hook_rule", { rule });
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除钩子规则
|
||||
*/
|
||||
static async removeHookRule(ruleId: string): Promise<void> {
|
||||
return safeInvoke<void>("remove_hook_rule", { ruleId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用钩子规则
|
||||
*/
|
||||
static async toggleHookRule(ruleId: string, enabled: boolean): Promise<void> {
|
||||
return safeInvoke<void>("toggle_hook_rule", { ruleId, enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有钩子规则
|
||||
*/
|
||||
static async getHookRules(): Promise<HookRule[]> {
|
||||
return safeInvoke<HookRule[]>("get_hook_rules");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钩子执行统计
|
||||
*/
|
||||
static async getHookExecutionStats(): Promise<
|
||||
Record<string, HookExecutionStats>
|
||||
> {
|
||||
return safeInvoke<Record<string, HookExecutionStats>>(
|
||||
"get_hook_execution_stats",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理钩子执行统计
|
||||
*/
|
||||
static async clearHookExecutionStats(): Promise<void> {
|
||||
return safeInvoke<void>("clear_hook_execution_stats");
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发会话开始钩子
|
||||
*/
|
||||
static async triggerSessionStart(
|
||||
sessionId: string,
|
||||
metadata: Record<string, string> = {},
|
||||
): Promise<void> {
|
||||
return this.executeHooks({
|
||||
trigger: "session_start",
|
||||
context: {
|
||||
session_id: sessionId,
|
||||
message_count: 0,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
...metadata,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发工具使用前钩子
|
||||
*/
|
||||
static async triggerPreToolUse(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolParameters: Record<string, string> = {},
|
||||
messageContent?: string,
|
||||
messageCount: number = 0,
|
||||
): Promise<void> {
|
||||
return this.executeHooks({
|
||||
trigger: "pre_tool_use",
|
||||
context: {
|
||||
session_id: sessionId,
|
||||
tool_name: toolName,
|
||||
tool_parameters: toolParameters,
|
||||
message_content: messageContent,
|
||||
message_count: messageCount,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: toolName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发工具使用后钩子
|
||||
*/
|
||||
static async triggerPostToolUse(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolResult: string,
|
||||
toolParameters: Record<string, string> = {},
|
||||
messageContent?: string,
|
||||
messageCount: number = 0,
|
||||
errorInfo?: string,
|
||||
): Promise<void> {
|
||||
return this.executeHooks({
|
||||
trigger: "post_tool_use",
|
||||
context: {
|
||||
session_id: sessionId,
|
||||
tool_name: toolName,
|
||||
tool_parameters: toolParameters,
|
||||
tool_result: toolResult,
|
||||
message_content: messageContent,
|
||||
message_count: messageCount,
|
||||
error_info: errorInfo,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: toolName,
|
||||
has_error: errorInfo ? "true" : "false",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发会话停止钩子
|
||||
*/
|
||||
static async triggerStop(
|
||||
sessionId: string,
|
||||
messageCount: number,
|
||||
metadata: Record<string, string> = {},
|
||||
): Promise<void> {
|
||||
return this.executeHooks({
|
||||
trigger: "stop",
|
||||
context: {
|
||||
session_id: sessionId,
|
||||
message_count: messageCount,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
session_end: "true",
|
||||
...metadata,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建自定义钩子规则
|
||||
*/
|
||||
static createCustomRule(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
trigger: HookTrigger,
|
||||
conditions: HookCondition[],
|
||||
actions: HookAction[],
|
||||
priority: number = 100,
|
||||
): HookRule {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
trigger,
|
||||
conditions,
|
||||
actions,
|
||||
enabled: true,
|
||||
priority,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建重要发现自动保存规则
|
||||
*/
|
||||
static createImportantFindingRule(): HookRule {
|
||||
return this.createCustomRule(
|
||||
"important-finding-auto-save",
|
||||
"重要发现自动保存",
|
||||
"检测到重要信息时自动保存到 findings.md",
|
||||
"post_tool_use",
|
||||
[{ message_contains: "重要" }, { message_contains: "发现" }],
|
||||
[
|
||||
{
|
||||
save_finding: {
|
||||
title: "重要发现 (自动检测)",
|
||||
content: "检测到重要信息,已自动保存",
|
||||
tags: ["重要", "自动保存"],
|
||||
priority: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建错误自动记录规则
|
||||
*/
|
||||
static createErrorAutoRecordRule(): HookRule {
|
||||
return this.createCustomRule(
|
||||
"error-auto-record",
|
||||
"错误自动记录",
|
||||
"检测到错误时自动记录到错误日志",
|
||||
"post_tool_use",
|
||||
[{ message_contains: "错误" }],
|
||||
[
|
||||
{
|
||||
record_error: {
|
||||
error_description: "检测到错误",
|
||||
attempted_solution: "正在尝试解决",
|
||||
},
|
||||
},
|
||||
],
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 2-Action 规则
|
||||
*/
|
||||
static create2ActionRule(): HookRule {
|
||||
return this.createCustomRule(
|
||||
"2-action-rule",
|
||||
"2-Action 规则",
|
||||
"每2次视觉操作后自动保存发现",
|
||||
"post_tool_use",
|
||||
[{ tool_name_contains: "view" }],
|
||||
[
|
||||
{
|
||||
save_finding: {
|
||||
title: "2-Action 规则触发",
|
||||
content: "视觉操作完成,自动保存发现",
|
||||
tags: ["2-Action规则", "视觉操作"],
|
||||
priority: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加默认钩子规则
|
||||
*/
|
||||
static async addDefaultRules(): Promise<void> {
|
||||
const rules = [
|
||||
this.createImportantFindingRule(),
|
||||
this.createErrorAutoRecordRule(),
|
||||
this.create2ActionRule(),
|
||||
];
|
||||
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
await this.addHookRule(rule);
|
||||
} catch (error) {
|
||||
console.warn(`添加钩子规则失败: ${rule.name}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ToolHooksAPI;
|
||||
@@ -1,145 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import {
|
||||
configureProvider,
|
||||
createSession,
|
||||
deleteSession,
|
||||
generateEventName,
|
||||
getMessages,
|
||||
getSession,
|
||||
listSessions,
|
||||
parseStreamEvent,
|
||||
renameSession,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
} from "./unified-chat";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("unified-chat API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应代理会话与消息命令并转换消息结构", async () => {
|
||||
vi.mocked(safeInvoke)
|
||||
.mockResolvedValueOnce({ id: "session-1", title: "测试会话" })
|
||||
.mockResolvedValueOnce([{ id: "session-1", title: "测试会话" }])
|
||||
.mockResolvedValueOnce({ id: "session-1", title: "测试会话" })
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
session_id: "session-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "你好" }],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(createSession({ mode: "general" } as never)).resolves.toEqual(
|
||||
expect.objectContaining({ id: "session-1" }),
|
||||
);
|
||||
await expect(listSessions()).resolves.toEqual([
|
||||
expect.objectContaining({ id: "session-1" }),
|
||||
]);
|
||||
await expect(getSession("session-1")).resolves.toEqual(
|
||||
expect.objectContaining({ id: "session-1" }),
|
||||
);
|
||||
await expect(deleteSession("session-1")).resolves.toBe(true);
|
||||
await expect(renameSession("session-1", "新标题")).resolves.toBeUndefined();
|
||||
await expect(getMessages("session-1")).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
content: "你好",
|
||||
}),
|
||||
]);
|
||||
await expect(
|
||||
sendMessage({ sessionId: "session-1", content: "hello" } as never),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(stopGeneration("session-1")).resolves.toBe(true);
|
||||
await expect(
|
||||
configureProvider("session-1", "openai", "gpt-4o"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("应解析流事件并生成事件名", () => {
|
||||
expect(parseStreamEvent({ type: "text_delta", text: "hi" })).toEqual({
|
||||
type: "text_delta",
|
||||
text: "hi",
|
||||
});
|
||||
expect(
|
||||
parseStreamEvent({ type: "tool_end", tool_id: "tool-1", result: "done" }),
|
||||
).toEqual({
|
||||
type: "tool_end",
|
||||
tool_id: "tool-1",
|
||||
result: "done",
|
||||
});
|
||||
expect(
|
||||
parseStreamEvent({
|
||||
type: "harness_event",
|
||||
kind: "artifact_created",
|
||||
session_id: "session-1",
|
||||
stage: "drafting",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "harness_event",
|
||||
event: {
|
||||
kind: "artifact_created",
|
||||
sessionId: "session-1",
|
||||
runId: undefined,
|
||||
correlationId: undefined,
|
||||
theme: undefined,
|
||||
stage: "drafting",
|
||||
summary: undefined,
|
||||
artifact: undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseStreamEvent({
|
||||
type: "artifact_snapshot",
|
||||
artifact_id: "artifact-1",
|
||||
file_path: "draft.md",
|
||||
content: "# 标题",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "artifact_snapshot",
|
||||
artifact: {
|
||||
artifactId: "artifact-1",
|
||||
filePath: "draft.md",
|
||||
content: "# 标题",
|
||||
metadata: undefined,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseStreamEvent({
|
||||
type: "artifact_snapshot",
|
||||
artifact: {
|
||||
artifactId: "artifact-2",
|
||||
filePath: "nested.md",
|
||||
content: "nested content",
|
||||
metadata: { complete: false },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "artifact_snapshot",
|
||||
artifact: {
|
||||
artifactId: "artifact-2",
|
||||
filePath: "nested.md",
|
||||
content: "nested content",
|
||||
metadata: { complete: false },
|
||||
},
|
||||
});
|
||||
expect(parseStreamEvent({ type: "unknown" })).toBeNull();
|
||||
expect(generateEventName("session-1")).toMatch(
|
||||
/^unified-chat-stream-session-1-/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,372 +0,0 @@
|
||||
/**
|
||||
* @file unified-chat.ts
|
||||
* @description 统一对话 API 封装
|
||||
* @module lib/api/unified-chat
|
||||
*
|
||||
* 封装所有统一对话相关的 Tauri 命令调用
|
||||
*/
|
||||
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import type {
|
||||
ChatMode,
|
||||
ChatMessage,
|
||||
SessionResponse,
|
||||
CreateSessionRequest,
|
||||
SendMessageRequest,
|
||||
StreamEvent,
|
||||
ToolCall,
|
||||
ToolEndEvent,
|
||||
FinalDoneEvent,
|
||||
HarnessArtifactSnapshot,
|
||||
HarnessEventPayload,
|
||||
} from "@/types/chat";
|
||||
|
||||
// ============================================================================
|
||||
// 会话管理 API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
*/
|
||||
export async function createSession(
|
||||
request: CreateSessionRequest,
|
||||
): Promise<SessionResponse> {
|
||||
return safeInvoke<SessionResponse>("chat_create_session", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
export async function listSessions(
|
||||
mode?: ChatMode,
|
||||
): Promise<SessionResponse[]> {
|
||||
return safeInvoke<SessionResponse[]>("chat_list_sessions", { mode });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话详情
|
||||
*/
|
||||
export async function getSession(sessionId: string): Promise<SessionResponse> {
|
||||
return safeInvoke<SessionResponse>("chat_get_session", { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
export async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
return safeInvoke<boolean>("chat_delete_session", { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名会话
|
||||
*/
|
||||
export async function renameSession(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
return safeInvoke<void>("chat_rename_session", { sessionId, title });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 消息管理 API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取会话消息列表
|
||||
*/
|
||||
export async function getMessages(
|
||||
sessionId: string,
|
||||
limit?: number,
|
||||
): Promise<ChatMessage[]> {
|
||||
const messages = await safeInvoke<
|
||||
Array<{
|
||||
id: number;
|
||||
session_id: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
tool_calls?: unknown;
|
||||
tool_call_id?: string;
|
||||
metadata?: unknown;
|
||||
created_at: string;
|
||||
}>
|
||||
>("chat_get_messages", { sessionId, limit });
|
||||
|
||||
// 转换后端格式为前端格式
|
||||
return messages.map(convertBackendMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(流式)
|
||||
*/
|
||||
export async function sendMessage(request: SendMessageRequest): Promise<void> {
|
||||
return safeInvoke<void>("chat_send_message", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止生成
|
||||
*/
|
||||
export async function stopGeneration(sessionId: string): Promise<boolean> {
|
||||
return safeInvoke<boolean>("chat_stop_generation", { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置会话的 Provider
|
||||
*/
|
||||
export async function configureProvider(
|
||||
sessionId: string,
|
||||
providerType: string,
|
||||
model: string,
|
||||
): Promise<void> {
|
||||
return safeInvoke<void>("chat_configure_provider", {
|
||||
sessionId,
|
||||
providerType,
|
||||
model,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 转换后端消息格式为前端格式
|
||||
*/
|
||||
function convertBackendMessage(msg: {
|
||||
id: number;
|
||||
session_id: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
tool_calls?: unknown;
|
||||
tool_call_id?: string;
|
||||
metadata?: unknown;
|
||||
created_at: string;
|
||||
}): ChatMessage {
|
||||
// 提取文本内容
|
||||
let textContent = "";
|
||||
if (typeof msg.content === "string") {
|
||||
textContent = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
textContent = msg.content
|
||||
.filter(
|
||||
(part): part is { type: "text"; text: string } =>
|
||||
typeof part === "object" && part !== null && part.type === "text",
|
||||
)
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
} else if (typeof msg.content === "object" && msg.content !== null) {
|
||||
// 尝试从对象中提取文本
|
||||
const contentObj = msg.content as Record<string, unknown>;
|
||||
if (typeof contentObj.text === "string") {
|
||||
textContent = contentObj.text;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
sessionId: msg.session_id,
|
||||
role: msg.role as ChatMessage["role"],
|
||||
content: textContent,
|
||||
contentBlocks: Array.isArray(msg.content)
|
||||
? msg.content.map(convertContentBlock)
|
||||
: [{ type: "text" as const, text: textContent }],
|
||||
toolCalls: msg.tool_calls ? convertToolCalls(msg.tool_calls) : undefined,
|
||||
toolCallId: msg.tool_call_id,
|
||||
status: "complete",
|
||||
metadata: msg.metadata as ChatMessage["metadata"],
|
||||
createdAt: msg.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换内容块
|
||||
*/
|
||||
function convertContentBlock(
|
||||
block: unknown,
|
||||
): NonNullable<ChatMessage["contentBlocks"]>[number] {
|
||||
if (typeof block !== "object" || block === null) {
|
||||
return { type: "text", text: String(block) };
|
||||
}
|
||||
|
||||
const b = block as Record<string, unknown>;
|
||||
|
||||
switch (b.type) {
|
||||
case "text":
|
||||
return { type: "text", text: String(b.text || "") };
|
||||
case "image":
|
||||
return { type: "image", url: String(b.url || ""), alt: b.alt as string };
|
||||
case "file":
|
||||
return {
|
||||
type: "file",
|
||||
path: String(b.path || ""),
|
||||
name: String(b.name || ""),
|
||||
};
|
||||
case "canvas":
|
||||
return {
|
||||
type: "canvas",
|
||||
canvasType: String(b.canvasType || ""),
|
||||
content: String(b.content || ""),
|
||||
};
|
||||
default:
|
||||
return { type: "text", text: JSON.stringify(block) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换工具调用
|
||||
*/
|
||||
function convertToolCalls(toolCalls: unknown): ChatMessage["toolCalls"] {
|
||||
if (!Array.isArray(toolCalls)) return undefined;
|
||||
|
||||
return toolCalls.map((tc) => {
|
||||
const call = tc as Record<string, unknown>;
|
||||
return {
|
||||
id: String(call.id || ""),
|
||||
name: String(call.name || ""),
|
||||
arguments: call.arguments as string,
|
||||
status: (call.status as ToolCall["status"]) || "completed",
|
||||
result: call.result as ToolCall["result"],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析流式事件
|
||||
*/
|
||||
export function parseStreamEvent(payload: unknown): StreamEvent | null {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = payload as Record<string, unknown>;
|
||||
const type = event.type as string;
|
||||
|
||||
switch (type) {
|
||||
case "TextDelta":
|
||||
case "text_delta":
|
||||
return {
|
||||
type: "text_delta",
|
||||
text: String(event.text || event.content || ""),
|
||||
};
|
||||
|
||||
case "ThinkingDelta":
|
||||
case "thinking_delta":
|
||||
return {
|
||||
type: "thinking_delta",
|
||||
text: String(event.text || event.content || ""),
|
||||
};
|
||||
|
||||
case "ToolStart":
|
||||
case "tool_start":
|
||||
return {
|
||||
type: "tool_start",
|
||||
tool_id: String(event.tool_id || event.id || ""),
|
||||
tool_name: String(event.tool_name || event.name || ""),
|
||||
arguments: event.arguments as string,
|
||||
};
|
||||
|
||||
case "ToolEnd":
|
||||
case "tool_end":
|
||||
return {
|
||||
type: "tool_end",
|
||||
tool_id: String(event.tool_id || event.id || ""),
|
||||
result: event.result as ToolEndEvent["result"],
|
||||
};
|
||||
|
||||
case "HarnessEvent":
|
||||
case "harness_event":
|
||||
return {
|
||||
type: "harness_event",
|
||||
event: {
|
||||
kind: String(event.kind || event.event_kind || "unknown"),
|
||||
sessionId:
|
||||
(event.session_id as string) || (event.sessionId as string),
|
||||
runId: (event.run_id as string) || (event.runId as string),
|
||||
correlationId:
|
||||
(event.correlation_id as string) ||
|
||||
(event.correlationId as string),
|
||||
theme: event.theme as string,
|
||||
stage: event.stage as string,
|
||||
summary: event.summary as string,
|
||||
artifact: (event.artifact ||
|
||||
(event.snapshot as HarnessArtifactSnapshot) ||
|
||||
undefined) as HarnessArtifactSnapshot | undefined,
|
||||
metadata: event.metadata as HarnessEventPayload["metadata"],
|
||||
},
|
||||
};
|
||||
|
||||
case "ArtifactSnapshot":
|
||||
case "artifact_snapshot":
|
||||
{
|
||||
const nestedArtifact =
|
||||
event.artifact && typeof event.artifact === "object"
|
||||
? (event.artifact as Record<string, unknown>)
|
||||
: undefined;
|
||||
return {
|
||||
type: "artifact_snapshot",
|
||||
artifact: {
|
||||
artifactId: String(
|
||||
nestedArtifact?.artifactId ||
|
||||
nestedArtifact?.artifact_id ||
|
||||
event.artifact_id ||
|
||||
event.artifactId ||
|
||||
event.id ||
|
||||
"artifact-unknown",
|
||||
),
|
||||
filePath:
|
||||
(nestedArtifact?.filePath as string | undefined) ||
|
||||
(nestedArtifact?.file_path as string | undefined) ||
|
||||
(event.file_path as string | undefined) ||
|
||||
(event.filePath as string | undefined),
|
||||
content:
|
||||
(nestedArtifact?.content as string | undefined) ||
|
||||
(event.content as string | undefined),
|
||||
metadata:
|
||||
(nestedArtifact?.metadata as Record<string, unknown> | undefined) ||
|
||||
(event.metadata as Record<string, unknown> | undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "ActionRequired":
|
||||
case "action_required":
|
||||
return {
|
||||
type: "action_required",
|
||||
request_id: String(event.request_id || ""),
|
||||
action_type: String(event.action_type || ""),
|
||||
tool_name: event.tool_name as string,
|
||||
arguments: event.arguments as string,
|
||||
prompt: event.prompt as string,
|
||||
questions: event.questions as unknown[],
|
||||
requested_schema: event.requested_schema,
|
||||
};
|
||||
|
||||
case "Done":
|
||||
case "done":
|
||||
return { type: "done" };
|
||||
|
||||
case "FinalDone":
|
||||
case "final_done":
|
||||
return {
|
||||
type: "final_done",
|
||||
usage: event.usage as FinalDoneEvent["usage"],
|
||||
};
|
||||
|
||||
case "Error":
|
||||
case "error":
|
||||
return {
|
||||
type: "error",
|
||||
message: String(event.message || "Unknown error"),
|
||||
};
|
||||
|
||||
default:
|
||||
console.warn("[parseStreamEvent] 未知事件类型:", type);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一事件名称
|
||||
*/
|
||||
export function generateEventName(sessionId: string): string {
|
||||
return `unified-chat-stream-${sessionId}-${Date.now()}`;
|
||||
}
|
||||
@@ -63,22 +63,22 @@
|
||||
"legacyCommandSurfaceMonitors": [
|
||||
{
|
||||
"id": "agent-create-session-compat-command",
|
||||
"classification": "compat",
|
||||
"description": "agent_create_session compat 命令前端边界",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已零引用的旧 agent_create_session 命令边界",
|
||||
"commands": ["agent_create_session"],
|
||||
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "agent-session-message-legacy-command",
|
||||
"classification": "deprecated",
|
||||
"description": "旧 agent session message 命令前端边界",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已零引用的旧 agent session message 命令边界",
|
||||
"commands": ["agent_get_session_messages"],
|
||||
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "agent-session-compat-commands",
|
||||
"classification": "deprecated",
|
||||
"description": "旧 agent session compat 命令前端边界",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已零引用的旧 agent session compat 命令边界",
|
||||
"commands": [
|
||||
"agent_list_sessions",
|
||||
"agent_get_session",
|
||||
@@ -91,8 +91,8 @@
|
||||
"legacyHelperSurfaceMonitors": [
|
||||
{
|
||||
"id": "agent-legacy-session-api-helpers",
|
||||
"classification": "deprecated",
|
||||
"description": "旧 Agent session compat helper 直连回流",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已零引用的旧 Agent session compat helper 回流",
|
||||
"helpers": [
|
||||
"createAgentSession",
|
||||
"listAgentSessions",
|
||||
@@ -102,24 +102,24 @@
|
||||
"deleteAgentSession",
|
||||
"generateAgentTitle"
|
||||
],
|
||||
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "agent-legacy-stream-action-helpers",
|
||||
"classification": "deprecated",
|
||||
"description": "旧 Aster stream/action helper 直连回流",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已零引用的旧 Aster stream/action helper 回流",
|
||||
"helpers": [
|
||||
"sendAsterMessageStream",
|
||||
"confirmAsterAction",
|
||||
"submitAsterElicitationResponse",
|
||||
"stopAsterSession"
|
||||
],
|
||||
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "aster-session-helper-direct-usage",
|
||||
"classification": "deprecated",
|
||||
"description": "前端 direct Aster session helper 回流",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已零引用的前端 direct Aster session helper 回流",
|
||||
"helpers": [
|
||||
"createAsterSession",
|
||||
"listAsterSessions",
|
||||
@@ -127,7 +127,7 @@
|
||||
"deleteAsterSession",
|
||||
"renameAsterSession"
|
||||
],
|
||||
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
|
||||
"allowedPaths": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inferModelCapabilities,
|
||||
inferVisionCapability,
|
||||
} from "./inferModelCapabilities";
|
||||
|
||||
describe("inferModelCapabilities", () => {
|
||||
it("应将 gpt-5.4 识别为支持视觉的模型", () => {
|
||||
expect(
|
||||
inferVisionCapability({
|
||||
modelId: "gpt-5.4",
|
||||
providerId: "codex",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("应避免将生图模型误判为视觉聊天模型", () => {
|
||||
expect(
|
||||
inferVisionCapability({
|
||||
modelId: "gemini-3-pro-image-preview",
|
||||
providerId: "gemini",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("应保留 thinking 模型的推理能力推断", () => {
|
||||
expect(
|
||||
inferModelCapabilities({
|
||||
modelId: "gpt-5.4-thinking",
|
||||
providerId: "openai",
|
||||
}),
|
||||
).toMatchObject({
|
||||
vision: true,
|
||||
reasoning: true,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ModelCapabilities } from "@/lib/types/modelRegistry";
|
||||
|
||||
const REASONING_TOKEN_PATTERN = /(^|[._/-])(thinking|reasoning)(?=$|[._/-])/i;
|
||||
const VISION_HINT_PATTERN =
|
||||
/\b(vision|multimodal|multi-modal|omni|image-input|image understanding)\b/i;
|
||||
const NON_VISION_PATTERN =
|
||||
/\b(embedding|embed|rerank|tts|stt|transcribe|transcription|speech|audio|moderation)\b/i;
|
||||
const IMAGE_GENERATION_PATTERN =
|
||||
/\b(imagen|dall-e|dalle|stable[ -]?diffusion|sdxl|sd3|midjourney|mj|flux|image[ -]?generation|image-gen|image-preview)\b/i;
|
||||
const OPENAI_VISION_PATTERN =
|
||||
/\b(gpt-5(?:[._/-]|\b)|gpt-4o(?:[._/-]|\b)|gpt-4\.1(?:[._/-]|\b)|gpt-4\.5(?:[._/-]|\b)|gpt-5.*codex)\b/i;
|
||||
const GEMINI_VISION_PATTERN = /\bgemini(?:[._/-]|\b)/i;
|
||||
const CLAUDE_VISION_PATTERN = /\bclaude(?:[._/-]|\b)/i;
|
||||
const QWEN_VISION_PATTERN = /\bqwen(?:[._/-]|\b).*(vl|vision)|\bqvq\b/i;
|
||||
const GLM_VISION_PATTERN = /\bglm-[\w.-]*v[\w.-]*\b/i;
|
||||
|
||||
const normalize = (value?: string | null): string =>
|
||||
(value || "").trim().toLowerCase();
|
||||
|
||||
function buildSearchText(parts: Array<string | null | undefined>): string {
|
||||
return parts
|
||||
.map((part) => normalize(part))
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function inferReasoningCapability(modelId: string): boolean {
|
||||
return REASONING_TOKEN_PATTERN.test(modelId.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function inferVisionCapability(params: {
|
||||
modelId: string;
|
||||
providerId?: string | null;
|
||||
family?: string | null;
|
||||
description?: string | null;
|
||||
}): boolean {
|
||||
const { modelId, providerId, family, description } = params;
|
||||
const text = buildSearchText([modelId, family, description]);
|
||||
const provider = normalize(providerId);
|
||||
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (NON_VISION_PATTERN.test(text) || IMAGE_GENERATION_PATTERN.test(text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (VISION_HINT_PATTERN.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (OPENAI_VISION_PATTERN.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (provider === "codex" || provider === "openai") {
|
||||
return OPENAI_VISION_PATTERN.test(text);
|
||||
}
|
||||
|
||||
if (provider === "gemini") {
|
||||
return GEMINI_VISION_PATTERN.test(text);
|
||||
}
|
||||
|
||||
if (provider === "anthropic" || provider === "claude") {
|
||||
return CLAUDE_VISION_PATTERN.test(text);
|
||||
}
|
||||
|
||||
if (provider === "qwen" || provider === "alibaba") {
|
||||
return QWEN_VISION_PATTERN.test(text);
|
||||
}
|
||||
|
||||
if (provider === "zhipuai") {
|
||||
return GLM_VISION_PATTERN.test(text);
|
||||
}
|
||||
|
||||
return (
|
||||
GEMINI_VISION_PATTERN.test(text) ||
|
||||
CLAUDE_VISION_PATTERN.test(text) ||
|
||||
QWEN_VISION_PATTERN.test(text) ||
|
||||
GLM_VISION_PATTERN.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
export function inferModelCapabilities(params: {
|
||||
modelId: string;
|
||||
providerId?: string | null;
|
||||
family?: string | null;
|
||||
description?: string | null;
|
||||
}): ModelCapabilities {
|
||||
const { modelId, providerId, family, description } = params;
|
||||
return {
|
||||
vision: inferVisionCapability({
|
||||
modelId,
|
||||
providerId,
|
||||
family,
|
||||
description,
|
||||
}),
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: inferReasoningCapability(modelId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
|
||||
import { resolveVisionModel } from "./visionModelResolver";
|
||||
|
||||
function createModel(
|
||||
id: string,
|
||||
overrides: Partial<EnhancedModelMetadata> = {},
|
||||
): EnhancedModelMetadata {
|
||||
return {
|
||||
id,
|
||||
display_name: id,
|
||||
provider_id: "zhipuai",
|
||||
provider_name: "Zhipu AI",
|
||||
family: id,
|
||||
tier: "pro",
|
||||
capabilities: {
|
||||
vision: false,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: false,
|
||||
},
|
||||
pricing: null,
|
||||
limits: {
|
||||
context_length: null,
|
||||
max_output_tokens: null,
|
||||
requests_per_minute: null,
|
||||
tokens_per_minute: null,
|
||||
},
|
||||
status: "active",
|
||||
release_date: "2026-01-01",
|
||||
is_latest: false,
|
||||
description: null,
|
||||
source: "local",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveVisionModel", () => {
|
||||
it("当前模型已支持视觉时应保持不变", () => {
|
||||
const models = [
|
||||
createModel("glm-4.6v-flash", {
|
||||
capabilities: {
|
||||
vision: true,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: false,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = resolveVisionModel({
|
||||
currentModelId: "glm-4.6v-flash",
|
||||
models,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
targetModelId: "glm-4.6v-flash",
|
||||
switched: false,
|
||||
reason: "already_vision",
|
||||
});
|
||||
});
|
||||
|
||||
it("当前模型未收录但模型名可推断支持视觉时应保持不变", () => {
|
||||
const models = [
|
||||
createModel("gpt-5.3-codex", {
|
||||
provider_id: "codex",
|
||||
provider_name: "Codex",
|
||||
capabilities: {
|
||||
vision: true,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: true,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = resolveVisionModel({
|
||||
currentModelId: "gpt-5.4",
|
||||
models,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
targetModelId: "gpt-5.4",
|
||||
switched: false,
|
||||
reason: "already_vision",
|
||||
});
|
||||
});
|
||||
|
||||
it("应优先选择支持视觉的聊天模型,而不是纯生图模型", () => {
|
||||
const models = [
|
||||
createModel("gemini-3-pro-image-preview", {
|
||||
family: "gemini-3-pro-image",
|
||||
capabilities: {
|
||||
vision: true,
|
||||
tools: false,
|
||||
streaming: true,
|
||||
json_mode: false,
|
||||
function_calling: false,
|
||||
reasoning: false,
|
||||
},
|
||||
description: "image generation model",
|
||||
is_latest: true,
|
||||
}),
|
||||
createModel("glm-4.6v-flash", {
|
||||
family: "glm-4.6v",
|
||||
tier: "mini",
|
||||
capabilities: {
|
||||
vision: true,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: false,
|
||||
},
|
||||
release_date: "2026-02-01",
|
||||
is_latest: true,
|
||||
}),
|
||||
createModel("glm-4.7", {
|
||||
family: "glm-4.7",
|
||||
capabilities: {
|
||||
vision: false,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: true,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = resolveVisionModel({
|
||||
currentModelId: "glm-4.7",
|
||||
models,
|
||||
});
|
||||
|
||||
expect(result.targetModelId).toBe("glm-4.6v-flash");
|
||||
expect(result.switched).toBe(true);
|
||||
expect(result.reason).toBe("fallback_latest");
|
||||
});
|
||||
|
||||
it("没有可用视觉聊天模型时应返回 no_vision_model", () => {
|
||||
const models = [
|
||||
createModel("glm-4.7"),
|
||||
createModel("gemini-3-pro-image-preview", {
|
||||
capabilities: {
|
||||
vision: true,
|
||||
tools: false,
|
||||
streaming: true,
|
||||
json_mode: false,
|
||||
function_calling: false,
|
||||
reasoning: false,
|
||||
},
|
||||
description: "image generation model",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = resolveVisionModel({
|
||||
currentModelId: "glm-4.7",
|
||||
models,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
targetModelId: "glm-4.7",
|
||||
switched: false,
|
||||
reason: "no_vision_model",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
|
||||
import { inferVisionCapability } from "./inferModelCapabilities";
|
||||
|
||||
export type VisionResolveReason =
|
||||
| "already_vision"
|
||||
| "matched"
|
||||
| "fallback_latest"
|
||||
| "no_vision_model";
|
||||
|
||||
export interface VisionResolveResult {
|
||||
targetModelId: string;
|
||||
switched: boolean;
|
||||
reason: VisionResolveReason;
|
||||
}
|
||||
|
||||
interface ResolveVisionModelParams {
|
||||
currentModelId: string;
|
||||
models: EnhancedModelMetadata[];
|
||||
}
|
||||
|
||||
const IMAGE_GENERATION_KEYWORDS = [
|
||||
"imagen",
|
||||
"dall-e",
|
||||
"stable-diffusion",
|
||||
"stable diffusion",
|
||||
"sdxl",
|
||||
"sd3",
|
||||
"midjourney",
|
||||
"mj",
|
||||
"flux",
|
||||
"image generation",
|
||||
"image-gen",
|
||||
];
|
||||
|
||||
const TIER_WEIGHT: Record<EnhancedModelMetadata["tier"], number> = {
|
||||
mini: 1,
|
||||
pro: 2,
|
||||
max: 3,
|
||||
};
|
||||
|
||||
const normalize = (value?: string | null): string =>
|
||||
(value || "").trim().toLowerCase();
|
||||
|
||||
const findModelMeta = (
|
||||
modelId: string,
|
||||
models: EnhancedModelMetadata[],
|
||||
): EnhancedModelMetadata | undefined => {
|
||||
const normalizedId = normalize(modelId);
|
||||
return models.find((model) => normalize(model.id) === normalizedId);
|
||||
};
|
||||
|
||||
const buildSearchText = (model: EnhancedModelMetadata): string =>
|
||||
[
|
||||
model.id,
|
||||
model.display_name,
|
||||
model.family || "",
|
||||
model.description || "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
const isLikelyImageGenerationModel = (
|
||||
model: EnhancedModelMetadata,
|
||||
): boolean => {
|
||||
const text = buildSearchText(model);
|
||||
if (!IMAGE_GENERATION_KEYWORDS.some((keyword) => text.includes(keyword))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!model.capabilities.tools &&
|
||||
!model.capabilities.function_calling &&
|
||||
!model.capabilities.json_mode
|
||||
);
|
||||
};
|
||||
|
||||
const supportsVision = (
|
||||
model: EnhancedModelMetadata | undefined,
|
||||
fallbackModelId?: string,
|
||||
): boolean => {
|
||||
if (model?.capabilities.vision) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!fallbackModelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return inferVisionCapability({
|
||||
modelId: fallbackModelId,
|
||||
providerId: model?.provider_id,
|
||||
family: model?.family,
|
||||
description: model?.description,
|
||||
});
|
||||
};
|
||||
|
||||
const capabilityScore = (model: EnhancedModelMetadata): number => {
|
||||
let score = 0;
|
||||
if (model.capabilities.tools) score += 5;
|
||||
if (model.capabilities.function_calling) score += 4;
|
||||
if (model.capabilities.json_mode) score += 3;
|
||||
if (model.capabilities.reasoning) score += 2;
|
||||
if (model.capabilities.streaming) score += 1;
|
||||
return score;
|
||||
};
|
||||
|
||||
function compareReleaseDateDesc(
|
||||
left: EnhancedModelMetadata,
|
||||
right: EnhancedModelMetadata,
|
||||
): number {
|
||||
if (left.release_date && right.release_date) {
|
||||
return right.release_date.localeCompare(left.release_date);
|
||||
}
|
||||
if (left.release_date && !right.release_date) return -1;
|
||||
if (!left.release_date && right.release_date) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function resolveVisionModel(
|
||||
params: ResolveVisionModelParams,
|
||||
): VisionResolveResult {
|
||||
const { currentModelId, models } = params;
|
||||
const currentModel = findModelMeta(currentModelId, models);
|
||||
|
||||
if (supportsVision(currentModel, currentModelId)) {
|
||||
return {
|
||||
targetModelId: currentModel?.id || currentModelId,
|
||||
switched: false,
|
||||
reason: "already_vision",
|
||||
};
|
||||
}
|
||||
|
||||
const currentFamily = normalize(currentModel?.family);
|
||||
const candidates = models.filter(
|
||||
(model) => model.capabilities.vision && !isLikelyImageGenerationModel(model),
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
targetModelId: currentModelId,
|
||||
switched: false,
|
||||
reason: "no_vision_model",
|
||||
};
|
||||
}
|
||||
|
||||
const sortedCandidates = [...candidates].sort((left, right) => {
|
||||
const leftSameFamily = currentFamily.length > 0 && normalize(left.family) === currentFamily;
|
||||
const rightSameFamily =
|
||||
currentFamily.length > 0 && normalize(right.family) === currentFamily;
|
||||
if (leftSameFamily !== rightSameFamily) {
|
||||
return leftSameFamily ? -1 : 1;
|
||||
}
|
||||
|
||||
const capabilityDelta = capabilityScore(right) - capabilityScore(left);
|
||||
if (capabilityDelta !== 0) {
|
||||
return capabilityDelta;
|
||||
}
|
||||
|
||||
if (left.is_latest !== right.is_latest) {
|
||||
return left.is_latest ? -1 : 1;
|
||||
}
|
||||
|
||||
const tierDelta = TIER_WEIGHT[right.tier] - TIER_WEIGHT[left.tier];
|
||||
if (tierDelta !== 0) {
|
||||
return tierDelta;
|
||||
}
|
||||
|
||||
const releaseDelta = compareReleaseDateDesc(left, right);
|
||||
if (releaseDelta !== 0) {
|
||||
return releaseDelta;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
const target = sortedCandidates[0];
|
||||
const reason =
|
||||
currentFamily.length > 0 && normalize(target.family) === currentFamily
|
||||
? "matched"
|
||||
: "fallback_latest";
|
||||
|
||||
return {
|
||||
targetModelId: target.id,
|
||||
switched: normalize(target.id) !== normalize(currentModelId),
|
||||
reason,
|
||||
};
|
||||
}
|
||||
@@ -724,6 +724,9 @@ const defaultMocks: Record<string, any> = {
|
||||
whereCandidates: [],
|
||||
supplementalSearchDirs: ["/opt/homebrew/bin", "/usr/local/bin"],
|
||||
supplementalCommandCandidates: [],
|
||||
gitWhereCandidates: [],
|
||||
gitSupplementalSearchDirs: [],
|
||||
gitSupplementalCommandCandidates: [],
|
||||
},
|
||||
tempArtifacts: [],
|
||||
}),
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
/**
|
||||
* 三阶段工作流管理器
|
||||
*
|
||||
* 基于 planning-with-files 的核心机制,实现:
|
||||
* - Pre-Action → Action → Post-Action 三阶段工作流
|
||||
* - 自动化上下文工程和错误学习
|
||||
* - 2-Action 规则和 3次错误协议
|
||||
*/
|
||||
|
||||
import { ContextMemoryAPI } from "../api/contextMemory";
|
||||
import { ToolHooksAPI } from "../api/toolHooks";
|
||||
|
||||
export interface WorkflowPhase {
|
||||
number: number;
|
||||
name: string;
|
||||
status: "pending" | "in_progress" | "complete";
|
||||
tasks: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConfig {
|
||||
sessionId: string;
|
||||
projectName: string;
|
||||
goal: string;
|
||||
phases: WorkflowPhase[];
|
||||
}
|
||||
|
||||
export interface ActionContext {
|
||||
sessionId: string;
|
||||
actionType: string;
|
||||
actionDescription: string;
|
||||
toolName?: string;
|
||||
toolParameters?: Record<string, string>;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三阶段工作流管理器
|
||||
*/
|
||||
export class ThreeStageWorkflowManager {
|
||||
private sessionId: string;
|
||||
private visualOperationCount: number = 0;
|
||||
private errorAttempts: Map<string, number> = new Map();
|
||||
|
||||
constructor(sessionId: string) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化工作流
|
||||
*/
|
||||
async initializeWorkflow(config: WorkflowConfig): Promise<void> {
|
||||
// 触发会话开始钩子
|
||||
await ToolHooksAPI.triggerSessionStart(this.sessionId, {
|
||||
project_name: config.projectName,
|
||||
goal: config.goal,
|
||||
});
|
||||
|
||||
// 创建任务计划
|
||||
const taskPlanContent = this.generateTaskPlanContent(config);
|
||||
await ContextMemoryAPI.saveTaskPlan(
|
||||
this.sessionId,
|
||||
`任务计划: ${config.projectName}`,
|
||||
taskPlanContent,
|
||||
5,
|
||||
);
|
||||
|
||||
// 创建初始发现记录
|
||||
await ContextMemoryAPI.saveFinding(
|
||||
this.sessionId,
|
||||
"工作流初始化",
|
||||
`三阶段工作流已初始化\n项目: ${config.projectName}\n目标: ${config.goal}`,
|
||||
["初始化", "工作流"],
|
||||
3,
|
||||
);
|
||||
|
||||
// 记录初始进度
|
||||
await ContextMemoryAPI.logProgress(
|
||||
this.sessionId,
|
||||
"工作流启动",
|
||||
`三阶段工作流已启动,共 ${config.phases.length} 个阶段`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-Action 阶段:执行操作前的上下文刷新
|
||||
*/
|
||||
async preAction(context: ActionContext): Promise<string> {
|
||||
// 触发 Pre-Tool-Use 钩子
|
||||
await ToolHooksAPI.triggerPreToolUse(
|
||||
context.sessionId,
|
||||
context.toolName || context.actionType,
|
||||
context.toolParameters || {},
|
||||
context.actionDescription,
|
||||
context.messageCount,
|
||||
);
|
||||
|
||||
// 获取当前记忆上下文
|
||||
const memoryContext = await ContextMemoryAPI.getMemoryContext(
|
||||
context.sessionId,
|
||||
);
|
||||
|
||||
// 检查是否应该避免该操作(3次错误协议)
|
||||
const shouldAvoid = await ContextMemoryAPI.shouldAvoidOperation(
|
||||
context.sessionId,
|
||||
context.actionDescription,
|
||||
);
|
||||
|
||||
if (shouldAvoid) {
|
||||
const warning = `⚠️ 3次错误协议警告: 该操作已失败3次,建议更换方法\n操作: ${context.actionDescription}`;
|
||||
|
||||
await ContextMemoryAPI.recordError({
|
||||
session_id: context.sessionId,
|
||||
error_description: `重复失败操作: ${context.actionDescription}`,
|
||||
attempted_solution: "触发3次错误协议,建议更换方法",
|
||||
});
|
||||
|
||||
return `${warning}\n\n当前上下文:\n${memoryContext}`;
|
||||
}
|
||||
|
||||
// 记录上下文刷新
|
||||
await ContextMemoryAPI.logProgress(
|
||||
context.sessionId,
|
||||
"Pre-Action 上下文刷新",
|
||||
`准备执行: ${context.actionDescription}`,
|
||||
);
|
||||
|
||||
return `🔄 Pre-Action 上下文刷新完成\n\n准备执行: ${context.actionDescription}\n\n当前记忆上下文:\n${memoryContext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action 阶段:执行实际操作
|
||||
*/
|
||||
async executeAction(
|
||||
context: ActionContext,
|
||||
actionResult: string,
|
||||
): Promise<void> {
|
||||
// 记录操作执行
|
||||
await ContextMemoryAPI.logProgress(
|
||||
context.sessionId,
|
||||
`执行操作: ${context.actionType}`,
|
||||
`操作描述: ${context.actionDescription}\n结果: ${actionResult.substring(0, 200)}${actionResult.length > 200 ? "..." : ""}`,
|
||||
);
|
||||
|
||||
// 如果是视觉操作,增加计数
|
||||
if (this.isVisualOperation(context.actionType)) {
|
||||
this.visualOperationCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-Action 阶段:操作后的状态更新
|
||||
*/
|
||||
async postAction(
|
||||
context: ActionContext,
|
||||
actionResult: string,
|
||||
error?: string,
|
||||
): Promise<string> {
|
||||
let message = "📝 Post-Action 状态更新:\n\n";
|
||||
|
||||
// 处理错误情况
|
||||
if (error) {
|
||||
const errorKey = context.actionDescription;
|
||||
const attemptCount = (this.errorAttempts.get(errorKey) || 0) + 1;
|
||||
this.errorAttempts.set(errorKey, attemptCount);
|
||||
|
||||
const { shouldAvoid } = await ContextMemoryAPI.recordErrorWithCheck(
|
||||
context.sessionId,
|
||||
error,
|
||||
`尝试次数: ${attemptCount}`,
|
||||
context.actionDescription,
|
||||
);
|
||||
|
||||
message += `🚨 错误记录 (第${attemptCount}次尝试): ${error}\n`;
|
||||
|
||||
if (shouldAvoid) {
|
||||
message += `⚠️ 已达到3次错误限制,建议更换方法\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// 触发 Post-Tool-Use 钩子
|
||||
await ToolHooksAPI.triggerPostToolUse(
|
||||
context.sessionId,
|
||||
context.toolName || context.actionType,
|
||||
actionResult,
|
||||
context.toolParameters || {},
|
||||
context.actionDescription,
|
||||
context.messageCount,
|
||||
error,
|
||||
);
|
||||
|
||||
// 应用 2-Action 规则
|
||||
if (this.visualOperationCount >= 2) {
|
||||
await this.apply2ActionRule(actionResult);
|
||||
message += `🎯 2-Action 规则已应用 (视觉操作计数: ${this.visualOperationCount})\n`;
|
||||
this.visualOperationCount = 0; // 重置计数
|
||||
}
|
||||
|
||||
// 提醒更新状态
|
||||
message += `\n💡 提醒:\n`;
|
||||
message += `- 如果完成了某个阶段,请更新任务计划状态\n`;
|
||||
message += `- 有新发现请记录到 findings.md\n`;
|
||||
message += `- 重要进展请更新 progress.md\n`;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用 2-Action 规则
|
||||
*/
|
||||
private async apply2ActionRule(actionResult: string): Promise<void> {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const finding = `2-Action 规则触发 (${timestamp})\n\n最近操作结果:\n${actionResult.substring(0, 500)}${actionResult.length > 500 ? "..." : ""}`;
|
||||
|
||||
await ContextMemoryAPI.apply2ActionRule(this.sessionId, finding);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新阶段状态
|
||||
*/
|
||||
async updatePhaseStatus(
|
||||
phaseNumber: number,
|
||||
status: "pending" | "in_progress" | "complete",
|
||||
notes?: string,
|
||||
): Promise<void> {
|
||||
const statusText = {
|
||||
pending: "待开始",
|
||||
in_progress: "进行中",
|
||||
complete: "已完成",
|
||||
}[status];
|
||||
|
||||
await ContextMemoryAPI.saveTaskPlan(
|
||||
this.sessionId,
|
||||
`阶段 ${phaseNumber} 状态更新`,
|
||||
`阶段 ${phaseNumber} 状态已更新为: ${statusText}${notes ? `\n备注: ${notes}` : ""}`,
|
||||
4,
|
||||
);
|
||||
|
||||
await ContextMemoryAPI.logProgress(
|
||||
this.sessionId,
|
||||
`阶段 ${phaseNumber} 状态更新`,
|
||||
`状态: ${statusText}${notes ? `\n备注: ${notes}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录重要发现
|
||||
*/
|
||||
async recordFinding(
|
||||
title: string,
|
||||
content: string,
|
||||
tags: string[] = [],
|
||||
): Promise<void> {
|
||||
await ContextMemoryAPI.saveFinding(
|
||||
this.sessionId,
|
||||
title,
|
||||
content,
|
||||
["发现", ...tags],
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录决策
|
||||
*/
|
||||
async recordDecision(decision: string, rationale: string): Promise<void> {
|
||||
await ContextMemoryAPI.saveFinding(
|
||||
this.sessionId,
|
||||
`决策: ${decision}`,
|
||||
`决策内容: ${decision}\n\n决策理由:\n${rationale}`,
|
||||
["决策", "重要"],
|
||||
5,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务完成状态
|
||||
*/
|
||||
async checkCompletion(): Promise<{ isComplete: boolean; summary: string }> {
|
||||
const stats = await ContextMemoryAPI.getMemoryStats(this.sessionId);
|
||||
const memories = await ContextMemoryAPI.getSessionMemories(this.sessionId);
|
||||
|
||||
// 简单的完成度检查逻辑
|
||||
const taskPlanMemories = memories.filter(
|
||||
(m) => m.file_type === "task_plan",
|
||||
);
|
||||
const hasCompletedPhases = taskPlanMemories.some(
|
||||
(m) => m.content.includes("已完成") || m.content.includes("complete"),
|
||||
);
|
||||
|
||||
const summary =
|
||||
`📊 任务完成状态检查:\n\n` +
|
||||
`- 活跃记忆: ${stats.active_memories} 个\n` +
|
||||
`- 未解决错误: ${stats.unresolved_errors} 个\n` +
|
||||
`- 已解决错误: ${stats.resolved_errors} 个\n` +
|
||||
`- 是否有已完成阶段: ${hasCompletedPhases ? "是" : "否"}\n\n` +
|
||||
`${stats.unresolved_errors > 0 ? "⚠️ 仍有未解决的错误需要处理" : "✅ 无未解决错误"}`;
|
||||
|
||||
return {
|
||||
isComplete: hasCompletedPhases && stats.unresolved_errors === 0,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束工作流
|
||||
*/
|
||||
async finalizeWorkflow(): Promise<string> {
|
||||
const { isComplete, summary } = await this.checkCompletion();
|
||||
|
||||
// 触发停止钩子
|
||||
await ToolHooksAPI.triggerStop(this.sessionId, 0, {
|
||||
workflow_complete: isComplete.toString(),
|
||||
});
|
||||
|
||||
// 保存会话摘要
|
||||
await ContextMemoryAPI.saveFinding(
|
||||
this.sessionId,
|
||||
"工作流会话摘要",
|
||||
`三阶段工作流已结束\n\n${summary}`,
|
||||
["摘要", "会话结束"],
|
||||
5,
|
||||
);
|
||||
|
||||
return `🎉 三阶段工作流已结束\n\n${summary}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成任务计划内容
|
||||
*/
|
||||
private generateTaskPlanContent(config: WorkflowConfig): string {
|
||||
let content = `# 任务计划: ${config.projectName}\n\n`;
|
||||
content += `## 目标\n${config.goal}\n\n`;
|
||||
content += `## 当前阶段\n阶段 1\n\n`;
|
||||
content += `## 阶段列表\n\n`;
|
||||
|
||||
config.phases.forEach((phase) => {
|
||||
content += `### 阶段 ${phase.number}: ${phase.name}\n`;
|
||||
phase.tasks.forEach((task) => {
|
||||
content += `- [ ] ${task}\n`;
|
||||
});
|
||||
content += `- **状态**: ${phase.status}\n\n`;
|
||||
});
|
||||
|
||||
content += `## 关键问题\n`;
|
||||
content += `1. [需要回答的重要问题]\n`;
|
||||
content += `2. [另一个关键问题]\n\n`;
|
||||
|
||||
content += `## 已做决策\n`;
|
||||
content += `| 决策 | 理由 |\n`;
|
||||
content += `|------|------|\n`;
|
||||
content += `| | |\n\n`;
|
||||
|
||||
content += `## 遇到的错误\n`;
|
||||
content += `| 错误 | 尝试次数 | 解决方案 |\n`;
|
||||
content += `|------|----------|----------|\n`;
|
||||
content += `| | 1 | |\n\n`;
|
||||
|
||||
content += `## 注意事项\n`;
|
||||
content += `- **2-Action 规则**: 每2次视觉操作后立即保存发现\n`;
|
||||
content += `- **3次错误协议**: 永不重复相同的失败操作\n`;
|
||||
content += `- **上下文刷新**: 重要决策前重新阅读计划文件\n`;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视觉操作
|
||||
*/
|
||||
private isVisualOperation(actionType: string): boolean {
|
||||
const visualActions = [
|
||||
"view",
|
||||
"read",
|
||||
"browse",
|
||||
"search",
|
||||
"screenshot",
|
||||
"image",
|
||||
];
|
||||
return visualActions.some((action) =>
|
||||
actionType.toLowerCase().includes(action),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话统计
|
||||
*/
|
||||
async getSessionStats(): Promise<{
|
||||
memoryStats: any;
|
||||
visualOperationCount: number;
|
||||
errorAttempts: Record<string, number>;
|
||||
}> {
|
||||
const memoryStats = await ContextMemoryAPI.getMemoryStats(this.sessionId);
|
||||
|
||||
return {
|
||||
memoryStats,
|
||||
visualOperationCount: this.visualOperationCount,
|
||||
errorAttempts: Object.fromEntries(this.errorAttempts),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ThreeStageWorkflowManager;
|
||||
Reference in New Issue
Block a user