feat(editor): save leptos island and page aggregate alignment progress
- switch main document flow toward leptos tiptap island host and generated runtime assets - align page aggregate loading, page head single-source updates, and AI tool result recovery - add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHermesRuntimeToolResultRequest,
|
||||
parseHermesToolPreviewArgs,
|
||||
readHermesToolArgsFromEvent,
|
||||
readHermesToolResultFromEvent,
|
||||
} from "./tool-result-recovery";
|
||||
|
||||
describe("tool-result-recovery", () => {
|
||||
it("应从 JSON preview 中解析工具参数", () => {
|
||||
expect(
|
||||
parseHermesToolPreviewArgs('{"blockId":"block_1","text":"新的正文","mode":"replace"}', "doc_replace_range"),
|
||||
).toEqual({
|
||||
blockId: "block_1",
|
||||
text: "新的正文",
|
||||
mode: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
it("应从 slash preview 中恢复 text 参数", () => {
|
||||
expect(parseHermesToolPreviewArgs("slash_run /rename doc-1 新标题", "slash_run")).toEqual({
|
||||
text: "/rename doc-1 新标题",
|
||||
});
|
||||
});
|
||||
|
||||
it("应优先读取 Hermes 事件里直接给出的 argsJson", () => {
|
||||
expect(
|
||||
readHermesToolArgsFromEvent(
|
||||
{
|
||||
preview: "ignored",
|
||||
argsJson: { text: "/rename doc-1 直接参数" },
|
||||
},
|
||||
"slash_run",
|
||||
),
|
||||
).toEqual({
|
||||
text: "/rename doc-1 直接参数",
|
||||
});
|
||||
});
|
||||
|
||||
it("应读取 Hermes 事件里直接给出的结构化 result", () => {
|
||||
expect(
|
||||
readHermesToolResultFromEvent({
|
||||
result: {
|
||||
ok: true,
|
||||
parsed: {
|
||||
command: "rename_doc",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
parsed: {
|
||||
command: "rename_doc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("应构造 mnote-web runtime tool result 请求体", () => {
|
||||
expect(
|
||||
buildHermesRuntimeToolResultRequest({
|
||||
userId: "user-1",
|
||||
tool: "doc_replace_range",
|
||||
argsJson: {
|
||||
blockId: "block_1",
|
||||
text: "新的正文",
|
||||
mode: "replace",
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: "block_1",
|
||||
type: "paragraph",
|
||||
content: "旧正文",
|
||||
},
|
||||
],
|
||||
requestId: "req-1",
|
||||
traceId: "trace-1",
|
||||
target: {
|
||||
pageId: "doc-1",
|
||||
blockId: "block_1",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "tool",
|
||||
context: {
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
requestId: "req-1",
|
||||
traceId: "trace-1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next_route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
},
|
||||
tool: {
|
||||
tool: "doc_replace_range",
|
||||
kind: "command",
|
||||
mode: "result",
|
||||
argsJson: {
|
||||
blockId: "block_1",
|
||||
text: "新的正文",
|
||||
mode: "replace",
|
||||
},
|
||||
target: {
|
||||
workspaceId: null,
|
||||
pageId: "doc-1",
|
||||
blockId: "block_1",
|
||||
},
|
||||
reason: null,
|
||||
refs: [],
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: "block_1",
|
||||
type: "paragraph",
|
||||
content: "旧正文",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
type PlainObject = Record<string, unknown>;
|
||||
|
||||
function isPlainObject(value: unknown): value is PlainObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function safeParseObject(raw: string): PlainObject | null {
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (typeof parsed === "string") {
|
||||
return safeParseObject(parsed);
|
||||
}
|
||||
return isPlainObject(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJsonCandidate(raw: string): string | null {
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
if (text.startsWith("{") && text.endsWith("}")) {
|
||||
return text;
|
||||
}
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
if (start === -1 || end <= start) {
|
||||
return null;
|
||||
}
|
||||
return text.slice(start, end + 1);
|
||||
}
|
||||
|
||||
export function parseHermesToolPreviewArgs(
|
||||
preview: string,
|
||||
tool?: string | null,
|
||||
): PlainObject | null {
|
||||
const direct = safeParseObject(preview);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const candidate = extractJsonCandidate(preview);
|
||||
if (candidate) {
|
||||
const parsed = safeParseObject(candidate);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (tool === "slash_run") {
|
||||
const slashMatch = preview.match(/\/(?:new|new-doc|newdoc|rename|rename-doc|renamedoc)\b[\s\S]*/i);
|
||||
if (slashMatch) {
|
||||
return { text: slashMatch[0].trim() };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readHermesToolArgsFromEvent(
|
||||
event: unknown,
|
||||
tool?: string | null,
|
||||
): PlainObject | null {
|
||||
if (!isPlainObject(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directArgs =
|
||||
event.argsJson ??
|
||||
event.args_json ??
|
||||
event.args;
|
||||
if (isPlainObject(directArgs)) {
|
||||
return directArgs;
|
||||
}
|
||||
if (typeof directArgs === "string") {
|
||||
const parsed = parseHermesToolPreviewArgs(directArgs, tool);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const preview = typeof event.preview === "string" ? event.preview : "";
|
||||
return parseHermesToolPreviewArgs(preview, tool);
|
||||
}
|
||||
|
||||
export function readHermesToolResultFromEvent(event: unknown): unknown | null {
|
||||
if (!isPlainObject(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ("result" in event && event.result != null) {
|
||||
return event.result;
|
||||
}
|
||||
if ("data" in event && event.data != null) {
|
||||
return event.data;
|
||||
}
|
||||
if (typeof event.output === "string") {
|
||||
const parsed = safeParseObject(event.output);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildHermesRuntimeToolResultRequest(input: {
|
||||
userId: string;
|
||||
tool: string;
|
||||
argsJson: PlainObject;
|
||||
data?: unknown;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
workspaceId?: string | null;
|
||||
target?: {
|
||||
workspaceId?: string | null;
|
||||
pageId?: string | null;
|
||||
blockId?: string | null;
|
||||
} | null;
|
||||
reason?: string | null;
|
||||
refs?: string[];
|
||||
}): PlainObject {
|
||||
return {
|
||||
kind: "tool",
|
||||
context: {
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
requestId: input.requestId,
|
||||
traceId: input.traceId,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: input.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next_route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
},
|
||||
tool: {
|
||||
tool: input.tool,
|
||||
kind: "command",
|
||||
mode: "result",
|
||||
argsJson: input.argsJson,
|
||||
target: input.target
|
||||
? {
|
||||
workspaceId: input.target.workspaceId ?? null,
|
||||
pageId: input.target.pageId ?? null,
|
||||
blockId: input.target.blockId ?? null,
|
||||
}
|
||||
: null,
|
||||
reason: input.reason ?? null,
|
||||
refs: Array.isArray(input.refs) ? input.refs : [],
|
||||
},
|
||||
...(typeof input.data === "undefined" ? {} : { data: input.data }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user