2 Commits

Author SHA1 Message Date
jiang 4c47841483 优化标题生成功能
Agent CI/CD / docker-image (push) Successful in 21s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-05-22 14:20:27 +08:00
jiang ab12d79d91 fix(results): support legacy render refs
Agent CI/CD / docker-image (push) Successful in 17s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-05-21 18:18:16 +08:00
7 changed files with 412 additions and 14 deletions
+3 -1
View File
@@ -15,7 +15,9 @@ export default tool({
.describe("Why this local render payload should be persisted as a render_ref."), .describe("Why this local render payload should be persisted as a render_ref."),
file_path: tool.schema file_path: tool.schema
.string() .string()
.describe("Absolute path to a local JSON file containing the render payload or a wrapper object with data."), .describe(
"Absolute path to a local JSON file containing the raw render payload, or a wrapper object with data, metadata, and location. If wrapper metadata/location is missing or stale, the resolver will normalize and write it back before storing the render_ref.",
),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; await initializePromise;
+88 -2
View File
@@ -1,5 +1,5 @@
import { config } from "../config.js"; import { config } from "../config.js";
import { readJsonFile } from "../utils/fileStore.js"; import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
import { import {
type ResultReferenceKind, type ResultReferenceKind,
type ResultReferenceRecord, type ResultReferenceRecord,
@@ -68,7 +68,15 @@ export class ResultReferenceResolver {
throw new Error(`render payload file not found: ${filePath}`); throw new Error(`render payload file not found: ${filePath}`);
} }
const payload = extractRenderJunctionPayload(raw); const payloadCandidate = normalizeRenderPayloadFile(raw, {
filePath,
projectId: input.projectId,
});
if (payloadCandidate.repaired) {
await atomicWriteJson(filePath, payloadCandidate.file);
}
const payload = extractRenderJunctionPayload(payloadCandidate.data);
if (!payload) { if (!payload) {
throw new Error("render payload file does not contain a valid junction render payload"); throw new Error("render payload file does not contain a valid junction render payload");
} }
@@ -192,6 +200,39 @@ const normalizeDataForKind = (
return data; return data;
}; };
const normalizeRenderPayloadFile = (
value: unknown,
context: { filePath: string; projectId?: string },
): { data: unknown; file: Record<string, unknown>; repaired: boolean } => {
if (!isRecord(value) || !("data" in value)) {
return {
data: value,
file: {
metadata: buildWrapperMetadata({}, value, context.projectId),
location: buildWrapperLocation(undefined, context.filePath),
data: value,
},
repaired: false,
};
}
const metadata = buildWrapperMetadata(value.metadata, value, context.projectId);
const location = buildWrapperLocation(value.location, context.filePath);
const next: Record<string, unknown> = {
...value,
metadata,
location,
};
return {
data: next.data,
file: next,
repaired:
JSON.stringify(metadata) !== JSON.stringify(value.metadata ?? null) ||
JSON.stringify(location) !== JSON.stringify(value.location ?? null),
};
};
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => { const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
if (!isRecord(value)) { if (!isRecord(value)) {
return null; return null;
@@ -221,3 +262,48 @@ const projectData = (data: unknown, maxItems: number) => {
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value); typeof value === "object" && value !== null && !Array.isArray(value);
const buildWrapperMetadata = (
value: unknown,
root: unknown,
fallbackProjectId?: string,
) => {
const metadata = isRecord(value) ? { ...value } : {};
const source = isRecord(root) ? root : {};
if (typeof metadata.createdAt !== "string" || metadata.createdAt.trim().length === 0) {
const createdAt =
typeof source.createdAt === "string" && source.createdAt.trim().length > 0
? source.createdAt.trim()
: new Date().toISOString();
metadata.createdAt = createdAt;
}
if (
typeof metadata.projectId !== "string" ||
metadata.projectId.trim().length === 0
) {
const projectId =
typeof source.projectId === "string" && source.projectId.trim().length > 0
? source.projectId.trim()
: fallbackProjectId;
if (projectId) {
metadata.projectId = projectId;
}
}
return metadata;
};
const buildWrapperLocation = (value: unknown, filePath: string) => {
if (isRecord(value)) {
return {
...value,
file_path: filePath,
};
}
return {
file_path: filePath,
};
};
+68 -4
View File
@@ -10,9 +10,11 @@ import {
listJsonFiles, listJsonFiles,
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
toProjectKey,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{16}$/; export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
export const RESULT_REFERENCE_KIND = { export const RESULT_REFERENCE_KIND = {
dynamicHttpResult: "dynamic-http-result", dynamicHttpResult: "dynamic-http-result",
@@ -138,12 +140,15 @@ export class ResultReferenceStore {
} }
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) { async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
if (!RESULT_REF_PATTERN.test(resultRef)) { const normalizedResultRef = normalizeResultRef(resultRef);
if (!normalizedResultRef) {
return null; return null;
} }
const rawRecord = await readJsonFile<unknown>(this.filePath(resultRef)); const rawRecord = await readJsonFile<unknown>(this.filePath(normalizedResultRef));
const record = normalizeResultReferenceRecord(rawRecord); const record =
normalizeResultReferenceRecord(rawRecord) ??
normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context);
if (!record) { if (!record) {
return null; return null;
} }
@@ -301,6 +306,65 @@ const normalizeResultReferenceSource = (
const isValidResultRef = (value: unknown): value is string => const isValidResultRef = (value: unknown): value is string =>
typeof value === "string" && RESULT_REF_PATTERN.test(value); typeof value === "string" && RESULT_REF_PATTERN.test(value);
const normalizeResultRef = (value: string) => {
const match = value.trim().match(RESULT_REF_FILE_PATTERN);
return match?.[1] ?? null;
};
const normalizeLegacyRenderReferenceRecord = (
value: unknown,
resultRef: string,
context: RetrievalContext,
): ResultReferenceRecord | null => {
const data = extractLegacyRenderPayload(value);
if (!data) {
return null;
}
const root = isRecord(value) ? value : {};
const metadata = isRecord(root.metadata) ? root.metadata : {};
const projectId = firstNonEmptyString(root.projectId, metadata.projectId);
const createdAt =
firstNonEmptyString(root.createdAt, metadata.createdAt) ?? new Date().toISOString();
return {
resultRef,
actorKey: context.actorKey,
clientSessionId: context.clientSessionId ?? "",
createdAt,
data,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
preview: buildPreview(data),
projectId,
projectKey: toProjectKey(projectId),
schemaVersion: 1,
sessionId: context.clientSessionId ?? resultRef,
sizeBytes: estimateBytes(data),
source: RESULT_REFERENCE_SOURCE.legacy,
traceId: "legacy-render-ref",
};
};
const extractLegacyRenderPayload = (value: unknown) => {
if (!isRecord(value)) {
return null;
}
const candidate = isRecord(value.data) ? value.data : value;
if (!isRecord(candidate.node_area_map)) {
return null;
}
return candidate;
};
const firstNonEmptyString = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
};
const isResultPreview = (value: unknown): value is ResultPreview => const isResultPreview = (value: unknown): value is ResultPreview =>
isRecord(value) && isRecord(value) &&
typeof value.count === "number" && typeof value.count === "number" &&
+1
View File
@@ -564,6 +564,7 @@ export const buildChatRouter = (
if (shouldGenerateTitle) { if (shouldGenerateTitle) {
sessionTitle = await generateSessionTitle(runtime, { sessionTitle = await generateSessionTitle(runtime, {
sessionId: binding.sessionId, sessionId: binding.sessionId,
latestAssistantMessage: assistantText,
latestUserMessage: parsed.data.message, latestUserMessage: parsed.data.message,
fallbackTitle: existingSessionTitle, fallbackTitle: existingSessionTitle,
}); });
+33 -5
View File
@@ -12,15 +12,29 @@ const TITLE_CONTEXT_MESSAGE_CHAR_LIMIT = 240;
const RESTORE_TURN_LIMIT = 8; const RESTORE_TURN_LIMIT = 8;
const RESTORE_MESSAGE_CHAR_LIMIT = 480; const RESTORE_MESSAGE_CHAR_LIMIT = 480;
const RESTORE_CONTEXT_CHAR_LIMIT = 3200; const RESTORE_CONTEXT_CHAR_LIMIT = 3200;
const DEFAULT_SESSION_TITLE = "新对话";
const buildSessionTitle = (message: string) => { const buildSessionTitle = (message: string) => {
const normalized = message.replace(/\s+/g, " ").trim(); const normalized = message.replace(/\s+/g, " ").trim();
if (!normalized) { if (!normalized) {
return "新对话"; return DEFAULT_SESSION_TITLE;
} }
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized; return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
}; };
const appendTitleContextMessage = (
lines: string[],
role: "用户" | "助手",
content: string | undefined,
maxLength = TITLE_CONTEXT_MESSAGE_CHAR_LIMIT,
) => {
const normalized = content?.replace(/\s+/g, " ").trim();
if (!normalized) {
return;
}
lines.push(`${role}${normalized.slice(0, maxLength)}`);
};
const buildTitleConversationContext = async ( const buildTitleConversationContext = async (
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
sessionId: string, sessionId: string,
@@ -67,7 +81,9 @@ const buildTitleConversationContext = async (
const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => { const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => {
const normalized = rawTitle const normalized = rawTitle
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.replace(/^标题[:]\s*/i, "")
.replace(/["'“”‘’`]/g, "") .replace(/["'“”‘’`]/g, "")
.replace(/[。!?!?,,、;;:]+$/g, "")
.trim(); .trim();
if (!normalized) { if (!normalized) {
return fallback; return fallback;
@@ -85,13 +101,24 @@ export const generateSessionTitle = async (
options: { options: {
sessionId: string; sessionId: string;
latestUserMessage: string; latestUserMessage: string;
latestAssistantMessage?: string;
fallbackTitle?: string; fallbackTitle?: string;
}, },
) => { ) => {
const fallback = options.fallbackTitle?.trim() || buildSessionTitle(options.latestUserMessage); const fallbackTitle = options.fallbackTitle?.trim();
const fallback =
fallbackTitle && fallbackTitle !== DEFAULT_SESSION_TITLE
? fallbackTitle
: buildSessionTitle(options.latestUserMessage);
let titleSessionId: string | undefined; let titleSessionId: string | undefined;
try { try {
const conversation = await buildTitleConversationContext(runtime, options.sessionId); const scopedContext: string[] = [];
appendTitleContextMessage(scopedContext, "用户", options.latestUserMessage, 480);
appendTitleContextMessage(scopedContext, "助手", options.latestAssistantMessage, 960);
const conversation =
scopedContext.length > 0
? scopedContext.join("\n")
: await buildTitleConversationContext(runtime, options.sessionId);
if (!conversation) { if (!conversation) {
return fallback; return fallback;
} }
@@ -104,8 +131,9 @@ export const generateSessionTitle = async (
[ [
"你是会话标题生成器。", "你是会话标题生成器。",
"请根据下面整段多轮对话生成一个 8-16 字中文标题。", "请根据下面整段多轮对话生成一个 8-16 字中文标题。",
"要求:简洁、可读、避免标点、不要引号、不要解释。", "要求:简洁、具体、可读、避免标点、不要引号、不要解释。",
"先理解完整对话,再概括核心任务或结论。", "优先概括用户当前真实需求和助手最终结论。",
"忽略系统提示、历史记忆、学习上下文、工具日志等元信息。",
"不要直接照抄用户任一条消息原文。", "不要直接照抄用户任一条消息原文。",
"只输出标题本身。", "只输出标题本身。",
"", "",
+183 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -177,6 +177,13 @@ describe("ResultReferenceResolver", () => {
filePath, filePath,
JSON.stringify( JSON.stringify(
{ {
metadata: {
createdAt: "2026-05-21T00:00:00.000Z",
projectId: "project-3",
},
location: {
file_path: filePath,
},
data: { data: {
node_area_map: { node_area_map: {
J1: "DMA-1", J1: "DMA-1",
@@ -232,4 +239,179 @@ describe("ResultReferenceResolver", () => {
}, },
}); });
}); });
it("repairs wrapper files that omit metadata and location", async () => {
const filePath = join(tempDir, "render-wrapper-missing-fields.json");
await writeFile(
filePath,
JSON.stringify(
{
data: {
node_area_map: {
J1: "DMA-1",
},
},
createdAt: "2026-05-21T00:00:00.000Z",
},
null,
2,
),
"utf8",
);
const record = await resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-4",
clientSessionId: "client-4",
projectId: "project-4",
projectKey: "project-key-4",
sessionId: "session-4",
source: RESULT_REFERENCE_SOURCE.migration,
traceId: "trace-4",
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
metadata?: Record<string, unknown>;
location?: Record<string, unknown>;
};
expect(repaired.metadata).toEqual({
createdAt: "2026-05-21T00:00:00.000Z",
projectId: "project-4",
});
expect(repaired.location).toEqual({
file_path: filePath,
});
});
it("repairs wrapper files whose location points elsewhere", async () => {
const filePath = join(tempDir, "render-wrapper-wrong-location.json");
await writeFile(
filePath,
JSON.stringify(
{
metadata: {
createdAt: "2026-05-21T00:00:00.000Z",
},
location: {
file_path: "/tmp/elsewhere.json",
source: "legacy",
},
data: {
node_area_map: {
J1: "DMA-1",
},
},
},
null,
2,
),
"utf8",
);
await resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-4",
clientSessionId: "client-4",
projectId: "project-4",
projectKey: "project-key-4",
sessionId: "session-4",
source: RESULT_REFERENCE_SOURCE.migration,
traceId: "trace-4",
});
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
metadata?: Record<string, unknown>;
location?: Record<string, unknown>;
};
expect(repaired.metadata).toEqual({
createdAt: "2026-05-21T00:00:00.000Z",
projectId: "project-4",
});
expect(repaired.location).toEqual({
file_path: filePath,
source: "legacy",
});
});
it("resolves legacy render payload files when callers include the json suffix", async () => {
const legacyRef = "res-c2fcee33-577e";
await writeFile(
join(tempDir, `${legacyRef}.json`),
JSON.stringify(
{
data: {
node_area_map: {
J1: "DMA-1",
J2: 2,
},
area_ids: ["DMA-1"],
},
createdAt: "2026-05-21T00:00:00.000Z",
projectId: "project-legacy-render",
},
null,
2,
),
"utf8",
);
const result = await resolver.getFullAuthorized(
`${legacyRef}.json`,
{
actorKey: "actor-legacy-render",
clientSessionId: "chat-legacy-render",
projectId: "project-legacy-render",
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
expect(result?.result_ref).toBe(legacyRef);
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.legacy);
expect(result?.data).toEqual({
node_area_map: {
J1: "DMA-1",
J2: "2",
},
area_ids: ["DMA-1"],
});
});
it("keeps legacy render payload files scoped to their project", async () => {
const legacyRef = "res-dddddddddddddddd";
await writeFile(
join(tempDir, `${legacyRef}.json`),
JSON.stringify(
{
data: {
node_area_map: {
J1: "DMA-1",
},
},
projectId: "project-allowed",
},
null,
2,
),
"utf8",
);
const result = await resolver.getFullAuthorized(
legacyRef,
{
actorKey: "actor-legacy-render",
clientSessionId: "chat-legacy-render",
projectId: "project-denied",
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
expect(result).toBeNull();
});
}); });
+36 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { shouldGenerateSessionTitle } from "../../src/routes/chatSession.js"; import {
generateSessionTitle,
shouldGenerateSessionTitle,
} from "../../src/routes/chatSession.js";
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
describe("shouldGenerateSessionTitle", () => { describe("shouldGenerateSessionTitle", () => {
it("allows auto-title generation for the first turn when the title was not edited", () => { it("allows auto-title generation for the first turn when the title was not edited", () => {
@@ -36,3 +40,34 @@ describe("shouldGenerateSessionTitle", () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe("generateSessionTitle", () => {
it("uses the current user and assistant turn instead of reading wrapped runtime context", async () => {
let titlePrompt = "";
const runtime = {
createSession: async () => ({ id: "title-session" }),
prompt: async (_sessionId: string, prompt: string) => {
titlePrompt = prompt;
},
waitForSessionIdle: async () => undefined,
messages: async () => [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "标题:泵站压力异常排查。" }],
},
],
abortSession: async () => undefined,
} as unknown as OpencodeRuntimeAdapter;
const title = await generateSessionTitle(runtime, {
sessionId: "chat-session",
latestUserMessage: "检查一下三号泵站最近压力波动的原因",
latestAssistantMessage: "三号泵站压力波动主要与夜间阀门开度变化有关。",
fallbackTitle: "新对话",
});
expect(title).toBe("泵站压力异常排查");
expect(titlePrompt).toContain("用户:检查一下三号泵站最近压力波动的原因");
expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。");
});
});