chore: land tree view-state, vault, Pi module split, and repo hygiene

Persist PageTree expand state via control-plane view-state and align
chevron/DOM with restored expansion; keep Sidex-style shallow page-tree
scan and drop the unused recursive scanner that only added cargo noise.

Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi
into a module package, and retire Hermes/ACP/OpenHub recycle + root
harness evidence from the index while gitignoring recycle and local
diag dumps.

Archive superseded design/bugs docs under old/, point architecture at
ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor
regressions so the working tree can stay clean.
This commit is contained in:
Agent Board
2026-07-21 05:13:05 +08:00
parent 6f9c7d3b58
commit b798f628ee
264 changed files with 17480 additions and 17314 deletions
@@ -1,53 +0,0 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as aiSessions from "../aiSessions.js";
import type * as auth from "../auth.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
aiSessions: typeof aiSessions;
auth: typeof auth;
users: typeof users;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};
@@ -1,23 +0,0 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();
@@ -1,60 +0,0 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
@@ -1,143 +0,0 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
@@ -1,93 +0,0 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;
@@ -1,433 +0,0 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
const runtimeRunArgs = {
schema: v.literal("mnote.acp_runtime_run.v1"),
source: v.literal("acp"),
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
documentId: v.string(),
sessionId: v.string(),
runId: v.string(),
title: v.optional(v.union(v.string(), v.null())),
profile: v.string(),
acpRuntime: v.string(),
traceId: v.string(),
status: v.string(),
runtime: v.any(),
payload: v.any(),
retention: v.any(),
createdAt: v.number(),
updatedAt: v.number(),
};
const runtimeEventArgs = {
schema: v.literal("mnote.acp_runtime_event.v1"),
source: v.literal("acp"),
eventId: v.string(),
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
documentId: v.string(),
sessionId: v.string(),
runId: v.string(),
profile: v.string(),
acpRuntime: v.string(),
eventType: v.string(),
payload: v.any(),
createdAt: v.number(),
};
function isoFromMillis(value: number) {
return new Date(value).toISOString();
}
async function requireScopedUser(ctx: any, expectedUserId: string) {
const identity = await ctx.auth.getUserIdentity();
const subject = String(identity?.subject ?? "");
const identityUserId = subject.split("|")[0] || "";
if (identityUserId && identityUserId !== expectedUserId) {
throw new Error("无权限");
}
if (!identityUserId && !expectedUserId.trim()) {
throw new Error("未登录");
}
return expectedUserId;
}
function rowToClient(row: any) {
return {
id: row.id,
userId: row.user_id,
workspaceId: row.workspace_id,
documentId: row.document_id,
sessionId: row.session_id,
runId: row.run_id,
profile: row.profile,
title: row.title,
acpRuntime: row.acp_runtime,
source: row.source,
status: row.status,
traceId: row.trace_id,
runtime: row.runtime,
usage: row.usage ?? null,
payload: row.payload,
retention: row.retention,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export const upsertRuntimeRun = mutation({
args: runtimeRunArgs,
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const nowIso = isoFromMillis(args.updatedAt);
const existing = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
.first();
const patch = {
id: `acp_run:${args.runId}`,
user_id: userId,
workspace_id: workspaceId,
document_id: args.documentId,
session_id: args.sessionId,
run_id: args.runId,
title: args.title ?? null,
profile: args.profile,
acp_runtime: args.acpRuntime,
source: args.source,
status: args.status,
trace_id: args.traceId,
runtime: args.runtime,
usage: null,
payload: args.payload,
retention: args.retention,
updated_at: nowIso,
deleted_at: null,
};
if (existing) {
if (String(existing.user_id) !== userId) {
throw new Error("无权限");
}
await ctx.db.patch(existing._id, patch);
return { ok: true, id: existing.id, runId: args.runId, updated: true };
}
await ctx.db.insert("acp_runtime_runs", {
...patch,
created_at: isoFromMillis(args.createdAt),
});
return { ok: true, id: patch.id, runId: args.runId, created: true };
},
});
export const getRuntimeRun = query({
args: {
userId: v.string(),
runId: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const row = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
.first();
if (!row || row.deleted_at !== null || String(row.user_id) !== userId) {
return null;
}
return rowToClient(row);
},
});
export const listRuntimeRuns = query({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
documentId: v.optional(v.union(v.string(), v.null())),
sessionId: v.optional(v.union(v.string(), v.null())),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const limit = Math.min(Math.max(args.limit ?? 50, 1), 100);
let rows;
if (args.sessionId) {
rows = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_workspace_session", (q: any) =>
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", args.sessionId),
)
.order("desc")
.take(limit);
} else if (args.documentId) {
rows = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_workspace_document", (q: any) =>
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("document_id", args.documentId),
)
.order("desc")
.take(limit);
} else {
rows = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_created_at", (q: any) => q.eq("user_id", userId))
.order("desc")
.take(limit);
}
return rows
.filter((row: any) => row.deleted_at === null)
.map(rowToClient);
},
});
export const appendRuntimeEvent = mutation({
args: runtimeEventArgs,
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const existingRun = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
.first();
if (existingRun && String(existingRun.user_id) !== userId) {
throw new Error("无权限");
}
await ctx.db.insert("acp_runtime_events", {
id: args.eventId,
user_id: userId,
workspace_id: workspaceId,
document_id: args.documentId,
session_id: args.sessionId,
run_id: args.runId,
profile: args.profile,
acp_runtime: args.acpRuntime,
source: args.source,
event_type: args.eventType,
payload: args.payload,
created_at: isoFromMillis(args.createdAt),
});
const usage = usageFromRuntimeEvent(args.eventType, args.payload);
if (existingRun && usage) {
await ctx.db.patch(existingRun._id, {
usage,
updated_at: isoFromMillis(args.createdAt),
});
}
return { ok: true, id: args.eventId, runId: args.runId };
},
});
function usageFromRuntimeEvent(eventType: string, payload: any) {
if (eventType === "usage.updated") {
return {
source: "usage_update",
contextUsed: Number(payload?.used ?? payload?.contextUsed ?? 0),
contextSize: Number(payload?.size ?? payload?.contextSize ?? 0),
};
}
if (eventType === "run.completed" && payload?.usage && typeof payload.usage === "object") {
return {
source: "run_completed",
...payload.usage,
};
}
return null;
}
export const listRuntimeEvents = query({
args: {
userId: v.string(),
runId: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const limit = Math.min(Math.max(args.limit ?? 100, 1), 500);
const rows = await ctx.db
.query("acp_runtime_events")
.withIndex("by_run_created_at", (q: any) => q.eq("run_id", args.runId))
.order("asc")
.take(limit);
return rows
.filter((row: any) => String(row.user_id) === userId)
.map((row: any) => ({
id: row.id,
userId: row.user_id,
workspaceId: row.workspace_id,
documentId: row.document_id,
sessionId: row.session_id,
runId: row.run_id,
profile: row.profile,
acpRuntime: row.acp_runtime,
source: row.source,
eventType: row.event_type,
payload: row.payload,
createdAt: row.created_at,
}));
},
});
function titleFromRun(row: any) {
const payload = row?.payload ?? {};
const raw = String(payload.message ?? payload.input ?? payload.title ?? "").trim();
if (!raw) return "当前页问答";
return raw.length > 32 ? `${raw.slice(0, 32)}...` : raw;
}
async function sessionRows(ctx: any, userId: string, workspaceId: string | null, sessionId: string) {
return await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_workspace_session", (q: any) =>
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", sessionId),
)
.collect();
}
export const renameRuntimeSession = mutation({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
sessionId: v.string(),
title: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const title = args.title.trim();
if (!title) throw new Error("标题不能为空");
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
for (const row of rows) {
if (row.deleted_at === null) {
await ctx.db.patch(row._id, {
title,
updated_at: new Date().toISOString(),
});
}
}
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
},
});
export const autoTitleRuntimeSession = mutation({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
sessionId: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
const title = titleFromRun(rows.find((row: any) => row.deleted_at === null));
for (const row of rows) {
if (row.deleted_at === null) {
await ctx.db.patch(row._id, {
title,
updated_at: new Date().toISOString(),
});
}
}
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
},
});
export const deleteRuntimeSession = mutation({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
sessionId: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
const deletedAt = new Date().toISOString();
for (const row of rows) {
if (row.deleted_at === null) {
await ctx.db.patch(row._id, {
deleted_at: deletedAt,
updated_at: deletedAt,
});
}
}
return { ok: true, sessionId: args.sessionId, deleted: rows.length };
},
});
function searchText(row: any) {
const payload = row?.payload ?? {};
return [
row.title,
row.session_id,
row.run_id,
row.profile,
row.status,
payload.message,
payload.input,
payload.title,
]
.map((value) => String(value ?? ""))
.join(" ")
.toLowerCase();
}
function snippetFor(row: any, q: string) {
const payload = row?.payload ?? {};
const raw = String(row.title ?? payload.message ?? payload.input ?? row.session_id ?? "").trim();
if (!raw) return "";
const index = raw.toLowerCase().indexOf(q.toLowerCase());
if (index < 0) return raw.length > 80 ? `${raw.slice(0, 80)}...` : raw;
const start = Math.max(0, index - 24);
const end = Math.min(raw.length, index + q.length + 56);
return `${start > 0 ? "..." : ""}${raw.slice(start, end)}${end < raw.length ? "..." : ""}`;
}
export const searchRuntimeSessions = query({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
q: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const q = args.q.trim().toLowerCase();
if (!q) return [];
const limit = Math.min(Math.max(args.limit ?? 20, 1), 50);
const rows = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_created_at", (query: any) => query.eq("user_id", userId))
.order("desc")
.take(500);
const seen = new Set<string>();
const results = [];
for (const row of rows) {
if (row.deleted_at !== null) continue;
if ((row.workspace_id ?? null) !== workspaceId) continue;
if (!searchText(row).includes(q)) continue;
if (seen.has(row.session_id)) continue;
seen.add(row.session_id);
results.push({
sessionId: row.session_id,
runId: row.run_id,
workspaceId: row.workspace_id,
documentId: row.document_id,
title: row.title ?? titleFromRun(row),
profile: row.profile,
status: row.status,
snippet: snippetFor(row, args.q),
score: 1,
});
if (results.length >= limit) break;
}
return results;
},
});
@@ -1,8 +0,0 @@
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
],
};
@@ -1,19 +0,0 @@
import { Password } from "@convex-dev/auth/providers/Password";
import { convexAuth } from "@convex-dev/auth/server";
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Password({
validatePasswordRequirements: (password: string) => {
if (password.length < 8) {
throw new Error("密码至少需要 8 个字符");
}
},
profile(params) {
const email = typeof params.email === "string" ? params.email : String(params.email ?? "");
const name = typeof params.name === "string" ? params.name.trim() : "";
return { email, name };
},
}),
],
});
@@ -1,55 +0,0 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { authTables } from "@convex-dev/auth/server";
export default defineSchema({
...authTables,
acp_runtime_runs: defineTable({
id: v.string(),
user_id: v.string(),
workspace_id: v.union(v.string(), v.null()),
document_id: v.string(),
session_id: v.string(),
run_id: v.string(),
title: v.union(v.string(), v.null()),
profile: v.string(),
acp_runtime: v.string(),
source: v.literal("acp"),
status: v.string(),
trace_id: v.string(),
runtime: v.any(),
usage: v.any(),
payload: v.any(),
retention: v.any(),
created_at: v.string(),
updated_at: v.string(),
deleted_at: v.union(v.string(), v.null()),
})
.index("by_run_id", ["run_id"])
.index("by_user_workspace_document", ["user_id", "workspace_id", "document_id", "created_at"])
.index("by_user_workspace_session", ["user_id", "workspace_id", "session_id", "created_at"])
.index("by_user_created_at", ["user_id", "created_at"]),
acp_runtime_events: defineTable({
id: v.string(),
user_id: v.string(),
workspace_id: v.union(v.string(), v.null()),
document_id: v.string(),
session_id: v.string(),
run_id: v.string(),
profile: v.string(),
acp_runtime: v.string(),
source: v.literal("acp"),
event_type: v.string(),
payload: v.any(),
created_at: v.string(),
})
.index("by_run_created_at", ["run_id", "created_at"])
.index("by_user_workspace_session_created_at", [
"user_id",
"workspace_id",
"session_id",
"created_at",
]),
});
@@ -1,77 +0,0 @@
import { query } from "./_generated/server";
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
function normalizeAccount(value: string) {
return value.trim().toLowerCase();
}
export const resolveLoginAccount = query({
args: {
account: v.string(),
},
handler: async (ctx, args) => {
const account = normalizeAccount(args.account);
if (!account) {
return null;
}
const users = await ctx.db.query("users").collect();
const user = users.find((row: any) => {
const email = normalizeAccount(String(row.email ?? ""));
const name = normalizeAccount(String(row.name ?? ""));
return email === account || name === account;
});
if (!user) {
return null;
}
return {
userId: user._id,
email: String((user as any).email ?? ""),
name: String((user as any).name ?? ""),
};
},
});
function normalizeUsername(raw: string): string {
const username = String(raw ?? "").trim();
if (!username) throw new Error("用户名不能为空");
if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符");
if (/\s/.test(username)) throw new Error("用户名不能包含空格");
return username;
}
export const currentUser = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (userId === null) {
return null;
}
return await ctx.db.get(userId);
},
});
export const setMyUsername = mutation({
args: { username: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (userId === null) throw new Error("未登录");
const username = normalizeUsername(args.username);
const existing = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("name"), username))
.first();
if (existing && String(existing._id) !== String(userId)) {
throw new Error("用户名已被占用");
}
await ctx.db.patch(userId, { name: username });
return { ok: true, username };
},
});
@@ -1,56 +0,0 @@
# Convex 自托管(Windows 本机)
本目录用于在本机通过 Docker Compose 启动 Convex backend + dashboard,并使用 backend 自身的持久化卷存储(包含数据库与文件存储)。
在 local-first 方案下,这套 Convex 自托管环境只定位为历史数据导出、显式 cloud source / compat 和可选 sync replica 运行底座;默认 auth、授权、分享、同步状态、AI policy、ACP/Hermes runtime session 等控制面已由 Rust SQLite control-plane 承接。页面正文、默认附件和本地 AI 会话全文不再以它作为早期产品默认数据真相。
## 当前 functions 边界
- 仓库根 `convex/` 已退役并软删除到 `recycle/20260522-convex-runtime-retirement/convex/`;它只作为审计、迁移对照或历史恢复素材,不再是 active deploy source。
- `recycle/wolai-frontend/convex/` 是退役前端随带的历史实现,不允许作为当前部署 fallback。
- `scripts/run-convex-deploy.js` 已随根 functions 源码软删除到 `recycle/20260522-convex-runtime-retirement/scripts/run-convex-deploy.js`。如需重新部署 Convex functions,必须先明确新的 cloud/compat 部署边界,不应直接恢复旧根 `convex/` 作为默认控制面。
## 启动
1. 确保仓库根目录 `.env.all` 存在(全局唯一 env 文件)。
2. 在本目录执行:
```bash
docker compose up -d
```
## 生成 Dashboard / CLI admin key
在本目录执行:
```bash
docker compose exec backend ./generate_admin_key.sh
```
## 访问地址(默认)
- Dashboard`http://localhost:6791`
- Backend`http://127.0.0.1:3210`
- HTTP Actions`http://127.0.0.1:3211`
- 文件存储:由 Convex 管理(Dashboard 的 Files 页面可查看)
## 快速验证(可选)
### 1) 验证默认启动不依赖 Convex functions 源码
当前默认 hot 启动链路不应要求仓库根存在 `convex/`
```bash
test ! -d convex
npm run desktop:hot
```
### 2) 验证固定开发用户(阶段 3)
启动前端后访问:`http://127.0.0.1:3000/api/dev/whoami`
### 3) 验证异步任务骨架(阶段 5)
启动前端后:
- `POST http://127.0.0.1:3000/api/dev/jobs/demo`body 可选:`{ "ms": 800 }`)创建 demo job
- `GET http://127.0.0.1:3000/api/dev/jobs/demo?id=<jobId>` 查询状态(queued/running/succeeded/failed
@@ -1,38 +0,0 @@
name: mnote-convex
services:
backend:
# 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。
# local-first 下该卷只承载控制面、cloud source、compat 和 sync replica
# 不作为本地默认页面正文、附件或 AI 会话全文真相。
image: ghcr.io/get-convex/convex-backend@sha256:2143ad479a997802e74ac52f5f1ce3d6e75309b1965e36069d1c939166e210eb
stop_grace_period: 10s
stop_signal: SIGINT
ulimits:
nofile:
soft: 65535
hard: 65535
ports:
- "3210:3210"
- "3211:3211"
volumes:
- convex_data_v1:/convex/data
env_file:
- ../../.env.all
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
interval: 5s
start_period: 10s
dashboard:
image: ghcr.io/get-convex/convex-dashboard@sha256:a1fa8df3d320d1691c74bb9df7bb2c536bdef91ff3f5307b717dba97eec793d1
stop_grace_period: 10s
stop_signal: SIGINT
ports:
- "6791:6791"
depends_on:
backend:
condition: service_healthy
volumes:
convex_data_v1:
@@ -1,38 +0,0 @@
#!/usr/bin/env node
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff";
const repoRoot = "/mnt/Data1T/mnote";
const rootConvexDir = path.join(repoRoot, "convex");
const requiredRootFiles = ["schema.ts", "aiSessions.ts"];
for (const fileName of requiredRootFiles) {
const filePath = path.join(rootConvexDir, fileName);
if (!fs.existsSync(filePath)) {
console.error(`[convex-deploy] 缺少当前 active Convex 文件:${filePath}`);
console.error("[convex-deploy] 不允许从 recycle/wolai-frontend/convex 回退部署;需要的函数必须迁回 root convex/ 或明确退役。");
process.exit(1);
}
}
const args = [
"deploy",
"--url", "http://127.0.0.1:3210", // Backend port, NOT HTTP actions (3211)
"--admin-key", adminKey,
"--typecheck", "disable",
"--codegen", "disable",
];
const child = spawn("npx", ["convex", ...args], {
cwd: repoRoot,
stdio: "inherit",
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
});
child.on("close", (code) => {
process.exit(code || 0);
});
@@ -1,170 +0,0 @@
> 状态补充(2026-07-03):本稿为 OpenHub 初步融合稿,OpenHub AI 面板 / FastAPI / Redis / opencode client 方向仍保留参考价值;其中 WeKnora 默认知识库底座口径已被当前 OpenHub / native agent + LightRAG + Turso/libSQL 主线覆盖。`7-68-openhub-weknora-mnote-deep-fusion-v1.md` 已标记 stale,不再作为当前默认 provider 依据。
# 7-67 OpenHub Page AI 深度融合设计 v1
状态:process
Owner07-ai / mnote-web / control-plane
日期:2026-06-25
## 1. 背景
`7-65` 的 opencode 官方 WebUI iframe 路线验证了 opencode runtime、同源反代、session binding、文件打开/刷新等接缝,但 UI 深度融合受 iframe 与官方 WebUI 结构限制。用户目标已经调整为:尽量复用成熟社区项目 OpenHub 的前端、消息库、权限、知识库 UI 和 opencode 接入模式,把 MNote Page AI 做成类似 VSCode + Cline 的一体化侧边栏,而不是 MNote 自研一个简陋聊天框。
当前 Page AI 主路径已回正为 OpenHub / native agent + LightRAG + Turso/libSQL
```text
MNote Rust SSR / control-plane / local workspace
-> OpenHub AI 面板(只暴露 AI 能力,裁剪非 AI 入口)
-> OpenHub FastAPI + Redis + OpenHub SQLite session/skill/tool permissionMCP/tool 由 MNote/LightRAG/opencode 工具配置承接
-> OpenHub opencode client / opencode serve runtime
-> LightRAG knowledge provider(默认知识库底座)
```
`7-65` 降级为 opencode runtime / 官方 iframe fallback`7-66` 降级为 native UI fallback;本稿的 Rust adapter / 自建 message store 路线也冻结为参考,不再作为执行主线。
## 2. 硬边界
- 不恢复 Reasonix / ZCode / Hermes / Board / CodexMobile 为 Page AI 默认后端。
- 不新增 Page AI Leptos islandPage AI 仍属于 `mnote-web` host runtime 与普通前端资源。
- 不直接引入 OpenHub 登录、admin、FileManager、KnowledgeManager 等非 AI 入口;用户、权限、workspace 真相归 MNote control-plane。
- 不把 OpenHub 自带 SQLite 文本知识库当作 MNote 长期知识库底座;当前默认长期知识库 provider 是 LightRAG。
- 不使用 `--dangerously-skip-permissions` 作为默认路径。
- 不把 opencode 原生 session 列表裸露给前端;所有 session 必须绑定 MNote 用户、rootUri、page path。
- 不通过高频轮询刷新消息或 changed files;优先使用 opencode event stream、MNote watcher、programmatic refresh。
## 3. OpenHub 可复用资产
### 3.1 前端组件
第一阶段优先复用并裁剪:
- `SmartQueryPage.jsx`:聊天 shell、消息流、状态管理、移动端布局参考。
- `ChatInput.jsx`:输入框、附件、快捷操作、发送体验。
- `AssistantMessage.jsx`Markdown、代码块、tool result、reasoning 展示基础。
- `ToolCall.jsx`:工具调用卡片与权限提示参考。
- `HistoryDrawer.jsx`:历史会话抽屉。
- `DiffViewer.jsx`:变更展示,但文件打开必须接 MNote open-resource。
- `FileManager.jsx`:映射 MNote local workspace / file tree。
- `KnowledgeManager.jsx`:知识库 UI 参考,后端改接 MNote/WeKnora adapter。
暂缓融合:
- OpenHub Login/Admin 整页。
- SmartEntity / Team / Scheduler。
- GitTimeMachine 的 restore 写入能力;可先只显示 diff / changed files。
### 3.2 后端模式(已由 7-68 覆盖)
可复用其 API contract 和 opencode 调用模式:
- `/api/query/stream`
- `/api/sessions`
- `/api/sessions/{sessionId}/messages`
- `/api/knowledge/*`
- `/api/files/*`
- opencode `/session?directory=<workspace>``/session/{id}/prompt_async?directory=<workspace>``/global/event?directory=<workspace>`
本稿原判断是“实现落在 Rust `mnote-web` / control-plane,不新增常驻 FastAPI 后端”。`7-68` 已更正:第一阶段保留 OpenHub FastAPI + Redis + opencode client 作为 AI 运行栈,MNote 只做登录态、scope 注入、入口裁剪、文件打开和 citation 回跳桥。
## 4. MNote 目标架构
### 4.1 UI 层
`sidebar-page-ai-runtime.js` 不再维护自研聊天消息渲染主链,而是承载 `7-68` 的 OpenHub AI 面板嵌入:
- MNote-native host chrome:当前页、selection、workspace、授权状态、runtime 状态、changed file chips。
- OpenHub AI 面板:消息、tool call、diff、history、input、skill/permission、WeKnora CLI/MCP tool 状态、运行日志。
- Bridge:只处理 MNote 专属动作:`open-file``refresh-file``insert-context``session-ready``changed-files`
### 4.2 Rust adapter 层(冻结为旧设想)
以下自建 session/message 表路线已被 `7-68` 覆盖,不作为当前执行主线:
- `page_ai_sessions`:MNote 用户维度的跨浏览器 session binding。
- `page_ai_messages`:用户消息、assistant 消息、tool call、opencode ids、状态。
- `page_ai_context_snapshots`:当前页标题、真实 Markdown 路径、selection、rootUri、allowed roots、知识摘要。
- `page_ai_changed_files`opencode event/diff 得到的 changed files 与 MNote resource mapping。
当前主线是:OpenHub session/message 是 Page AI 会话真相;MNote control-plane 只保存 binding、scope、artifact/open-reference 索引,禁止复制消息全文主存储。
### 4.3 opencode runtime 层
短期:单 opencode server + 当前打开 rootUri + MNote 用户级 binding。
中期:按 MNote user/profile 隔离 XDG profile,按需启动、空闲回收。
多用户强隔离不由 OpenHub 原生保证,必须由 MNote 反代与 profile manager 实现。
### 4.4 知识库层
OpenHub 知识库结论:其自带实现是 `knowledge_bases + knowledge_sources + LIKE/BM25/TF-IDF + prompt stuffing`,不是完整 RAG。
MNote 采用:
- UI:复用 OpenHub `KnowledgeManager` 交互。
- API:提供 OpenHub-compatible `/api/knowledge/*`
- Provider:默认走 LightRAG。
- Fallback:未配置 WeKnora 时,可临时用 OpenHub-like SQLite 文本知识源做短知识。
- CitationWeKnora 结果必须保留 source/resource/open-reference 映射,方便点击回 MNote 文件或资源页。
## 5. 实施阶段
### Phase A:源码裁剪 spike
- [ ] 抽取 OpenHub Chat 组件依赖图,确认最小可运行组件集。
- [ ]`mnote-web` 静态资源中引入 OpenHub-derived bundle 或独立构建产物。
- [ ] 去除 OpenHub 登录/admin 路由依赖,改用 MNote 当前登录态。
- [ ] 用静态 fixture 跑出接近 OpenHub 原始体验的 Page AI sidebar。
### Phase BOpenHub-compatible session/message API
- [ ] 增加 MNote control-plane session/message 表。
- [ ] 实现 `/api/page-ai/openhub/sessions``/messages` adapter。
- [ ] 绑定 `mnote_user_id + rootUri + pageAbsolutePath + opencode_session_id`
- [ ] 支持跨浏览器恢复同一 MNote 用户的会话。
### Phase C:真实 opencode streaming
- [ ] adapter 创建/恢复 opencode sessiondirectory 固定为当前打开 rootUri。
- [ ] `/query/stream` 转发到 opencode prompt_async + global event。
- [ ] 保存 user/assistant/tool/diff 消息。
- [ ] 解析 changed files 并驱动 MNote changed file chips。
### Phase DMNote 文件与刷新融合
- [ ] changed file chip 点击走 `openResourceInActiveTab()`
- [ ] 当前页被修改后调用 `refreshPrimaryDocument()` 或 watcher 刷新链路。
- [ ] DiffViewer 中所有 file path 点击都映射到 MNote resource/file open。
- [ ] 文件路径必须限制在当前 rootUri / allowed roots 内。
### Phase EKnowledge / LightRAG adapter
- [ ] 兼容 OpenHub `knowledgeService` 的 list/create/upload/search/stats API。
- [ ] 后端默认调用 LightRAG ingestion/search。
- [ ] 将 LightRAG 命中结果转成 OpenHub UI 可展示的 source/citation。
- [ ] 未配置 LightRAG 时启用 SQLite fallback,并在 UI 明确标注 fallback。
### Phase F:浏览器真实验证
- [ ] `npm run dev:hot` 一键拉起 MNote + opencode runtime + Page AI UI。
- [ ] 登录测试账号后打开真实 Markdown 页面。
- [ ] Page AI 看到 OpenHub-derived UI,而不是旧简陋聊天框或官方 iframe。
- [ ] 发送真实消息,模型能识别当前 rootUri 内文件。
- [ ] 让 opencode 修改测试 MarkdownMNote changed chip 可打开,当前页可刷新。
- [ ] Knowledge UI 可上传/检索,LightRAG provider 有真实命中与引用。
- [ ] 保存截图与 smoke 输出。
## 6. 验收标准
MVP 完成条件:
- Page AI 主要视觉与交互来自 OpenHub Chat 子系统。
- 会话和消息持久化在 MNote control-plane,支持同用户跨浏览器恢复。
- opencode 真实流式回复可用,工作目录固定为当前打开 rootUri。
- changed files 与 MNote open/refresh 打通。
- Knowledge UI 至少能展示 WeKnora-backed 搜索结果;未接 WeKnora 时必须标注 fallback,不得声称知识库主线已完成。
- `npm run dev:hot` 后可用真实浏览器截图证明。
## 7. 当前结论
OpenHub 仍是目前最适合 MNote Page AI 深度融合的参考实现,但本文已被 `7-68` 覆盖。正确路线不是把 OpenHub Page AI 子系统重写成 MNote 原生 UI,也不是由 MNote Rust adapter 重写 opencode client,而是在 MNote 侧嵌入 OpenHub AI 面板,保留 OpenHub FastAPI/Redis/opencode client,由 MNote 提供登录态、workspace scope、文件打开、citation 回跳和 WeKnora 工具/知识底座边界。
@@ -1,280 +0,0 @@
# [stale] 7-68 OpenHub + WeKnora + MNote 深度融合执行 Checklist v1
状态:stale / reference-only(不再作为当前 process 主线)
Owner07-ai / mnote-web / control-plane / knowledge-provider
日期:2026-06-25
2026-07-03 stale 说明:当前 runtime 口径已改为 OpenHub / native agent + LightRAG + Turso/libSQL。本文 checklist 中将 WeKnora 定义为默认知识库 provider、LightRAG 退为 legacy 的条目不再作为当前主线执行;当前知识库主路径以 LightRAG provider 和 MNote provider-neutral `mnote.knowledge_rag.*` facade 为准。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界。
上位设计:`design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md`
## 0. 执行原则
- 保留各系统已完成能力:OpenHub 保留 AI 面板、FastAPI、SQLite session/message、Redis 临时态、skill/tool permission、opencode clientWeKnora 保留 KB/API/CLI/MCP/search/indexMNote 保留 auth/workspace/resource tree/document pane/source registry。
- 只在产品真相冲突处做胶水:登录入口、workspace/rootUri scope、知识库 source truth、文件打开、citation 回跳、OpenHub 非 AI 入口裁剪、Git snapshot 禁用。
- 不把未确认能力写成已完成能力:当前 OpenHub 源码未确认独立 MCP 管理 UI/API,第一阶段通过 MNote 注册 WeKnora CLI/MCP/API tool 到 OpenHub/opencode。
- 不把 Redis 当持久真相:OpenHub message 真相在 SQLiteMNote auth/source/file truth 在 control-plane/本地文件,WeKnora Redis 属于其内部队列/缓存。
## 1. P0 设计冻结与旧路径降级
- [x]`design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md` 顶部加覆盖说明:官方 opencode WebUI iframe 仅保留 fallback,不是 Page AI 主线。
- [x]`design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md` 顶部加覆盖说明:自研 native UI 仅保留 fallback,不继续扩自研聊天框。
- [x]`design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md` 顶部加覆盖说明:已被 7-68 覆盖,路线改为嵌入 OpenHub AI 面板并保留 FastAPI/opencode client。
- [x] 搜索 `design/07-ai/``scripts/``rust/crates/mnote-web/browser/` 中仍把 LightRAG 描述为默认知识库 provider 的文案,列出需改文件,不在本任务中顺手改代码。
- [x] 标记 `7-50``7-57` 中 LightRAG 相关 done 文档为历史事实:曾经完成,不再代表当前新主线。
P0 搜索记录(2026-06-25):
- 已在 `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md``7-51``7-52``7-55``7-56``7-57` 顶部标记历史口径:LightRAG 曾完成,但不再代表当前新主线;WeKnora 是 7-68 默认 provider。
- 仍需后续单独迁移或归档的 active/process 文档:`design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md``design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md``design/07-ai/process/7-61-yuxi-reference-middleware-extensions-dashboard-v1.md`。这些文档仍含 LightRAG 默认主线语义,应按 7-68 后续批次改为 WeKnora / provider-neutral 或移入 `design/old/`
- 仍需后续代码迁移的 LightRAG 残留:`rust/crates/mnote-web/browser/document-resource-tab-runtime.js``rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js``scripts/reasonix-acp-wrapper.mjs``scripts/task538-knowledge-rag-source-scope-api-smoke.js``scripts/task542-knowledge-rag-search-grouping-and-short-query-smoke.js``scripts/TESTING_REFERENCE.md``scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`。本轮先完成 7-68 主链静态边界与 WeKnora facade,不批量重写所有 legacy smoke。
## 2. P0 OpenHub 源码事实冻结
- [x] 固定 OpenHub 源码路径:`/tmp/mnote-openhub-research/OpenHub`;若后续要深改,先建立可复核源码快照或 CodeGraph 索引。
- [x] 记录 FastAPI 入口:`smart-query-backend/app/main.py`router 包括 `auth/query/session/admin/files/internal/knowledge/admin_knowledge/channels`
- [x] 记录 opencode client`app/services/opencode_client.py` 使用 `OPENCODE_BASE_URL`,并支持 `directory` 参数。
- [x] 记录 opencode launcher`app/services/opencode_launcher.py` 可启动 `opencode serve`;集成时 `workdir` 必须来自 MNote rootUri,不用 OpenHub 默认路径。
- [x] 记录 session/message 真相:`init_db.py``conversation_sessions/conversation_messages``app/database.py`
- [x] 记录 Redis 用途:`app/core/auth.py``app/database.py` 中的 token/rate-limit/workspace cache/图片临时态;不得写成消息队列主链。
- [x] 记录自动 Git snapshot 触发点:`app/services/stream.py``app/services/task_executor.py``app/api/session.py` restore API。
- [x] 记录冲突入口:`App.jsx``/login``/admin``FileManager.jsx``KnowledgeManager.jsx``GitTimeMachine.jsx`
P0 OpenHub 记录(2026-06-25):
- 源码路径已固定为 `/tmp/mnote-openhub-research/OpenHub`
- FastAPI 入口 `/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/main.py` include routers`auth/query/session/admin/files/internal/smart_entity/smart_entity_tasks/knowledge/admin_knowledge/channels`
- `app/config.py``OPENCODE_BASE_URL` 默认 `http://127.0.0.1:4096``app/services/opencode_client.py``get/post/put/patch` 支持把 `directory` 写入 query params。
- `app/services/opencode_launcher.py` 可启动 `opencode serve``main.py``opencode_auto_start` 当前默认 workdir 仍有 OpenHub 自带 fallbackMNote 集成时必须由 rootUri 派生或禁用 auto-start。
- Git snapshot 写链真实存在:`app/services/stream.py``app/services/task_executor.py` 会 init git / has_changes / create_snapshot`app/api/session.py` 暴露 snapshot list/detail/diff/restore/restore-file 路由。
- 冲突入口真实存在:前端 `src/App.jsx``/login``/admin`OpenHub 还包含 FileManager / KnowledgeManager / GitTimeMachine 相关入口,MNote host 第一阶段必须 guard 或转接。
## 3. P0 WeKnora 源码事实冻结
- [x] 固定 WeKnora 源码路径:`/mnt/Data1T/Mnote_data/weknora/WeKnora`
- [x] 记录 KB UI 入口:`frontend/src/views/knowledge/KnowledgeBaseList.vue``KnowledgeBase.vue``KnowledgeBaseEditorModal.vue``frontend/src/components/knowledge-processing-timeline.vue`
- [x] 记录多 KB 检索:`docs/api/knowledge-search.md` 与 router `/api/v1/knowledge-search`,请求必须带 `knowledge_base_id(s)``knowledge_ids`
- [x] 记录单 KB hybrid-search`client/knowledgebase.go``/api/v1/knowledge-bases/:id/hybrid-search`
- [x] 记录 `SearchResult` 全字段:`id/content/knowledge_id/knowledge_base_id/chunk_index/knowledge_title/start_at/end_at/seq/score/match_type/sub_chunk_id/metadata/chunk_type/parent_chunk_id/image_info/knowledge_filename/knowledge_source/knowledge_channel/chunk_metadata/matched_content/knowledge_description`
- [x] 记录 CLI MCP`cli/internal/mcp/tools.go``cli/cmd/mcp/serve.go`;第一阶段默认采用只读 curated surface。
- [x] 记录 Python `mcp-server/` 作为后续写能力参考,第一阶段不默认暴露给 OpenHub/opencode。
- [x] 记录 WeKnora Redis/Asynq/Langfuse 属于 WeKnora stack,不和 OpenHub Redis keyspace 混用。
P0 WeKnora 记录(2026-06-25):
- 源码路径已固定为 `/mnt/Data1T/Mnote_data/weknora/WeKnora`
- KB UI 入口已定位:`KnowledgeBaseList.vue``KnowledgeBase.vue``KnowledgeBaseEditorModal.vue``knowledge-processing-timeline.vue`
- 多 KB 搜索合同来自 `docs/api/knowledge-search.md``POST /api/v1/knowledge-search`,请求至少包含 `knowledge_base_id``knowledge_base_ids`;可用 `knowledge_ids` 限定文件。
- `client/knowledgebase.go` 记录 `HybridSearch` 路径 `/api/v1/knowledge-bases/:id/hybrid-search``SearchResult` 包含 `id/content/knowledge_id/chunk_index/knowledge_title/start_at/end_at/seq/score/match_type/chunk_type/image_info/metadata/knowledge_filename/knowledge_source/knowledge_channel/matched_content`,服务端还有 parent/sub/chunk metadata 等扩展字段,本轮 MNote mapper 以原始 chunk 保留这些字段。
- CLI MCP 已定位:`cli/internal/mcp/tools.go` 注册 curated tools`cli/cmd/mcp/serve.go` 构建 `weknora mcp serve`,当前 stdio-only。第一阶段 MNote 暴露自己的只读 facade,不默认开放 Python `mcp-server` 写能力。
## 4. P1 OpenHub 服务栈接入
- [x] 为 MNote dev-hot 增加 OpenHub 开关:`ENABLE_OPENHUB=1``OPENHUB_BACKEND_CMD``OPENHUB_BACKEND_PORT``OPENHUB_REDIS_URL/DB``OPENHUB_OPENCODE_BASE_URL`
- [~] 启动顺序明确为 Redis -> opencode serve -> OpenHub FastAPI -> MNote host health proxy。
- [x] health 输出区分 `openhub_fastapi``openhub_redis``opencode_serve``weknora_api``weknora_cli_mcp``mnote_binding`
- [x] 禁止 kill-port/destructive launcher 行为;端口被占用时只报告占用 PID 和进程名。
- [~] OpenHub `opencode_auto_start` 若启用,`opencode_workdir` 必须由 MNote 当前 rootUri 派生;否则 MNote 自己启动 opencode serve 并让 OpenHub 复用。
- [x] 加环境开关或最小 fork patch 禁用 OpenHub 自动 `git init/commit/restore`
- [~] smokeOpenHub FastAPI 可 `/openapi.json` 或 health 探针;Redis 可认证连接;opencode serve 可访问;WeKnora CLI profile/doctor 可通过;WeKnora stdio MCP handshake 仍未作为最终通过项。
P1 OpenHub 服务栈记录:
- `scripts/desktop-hot.js` 已增加 `ENABLE_OPENHUB``OPENHUB_CMD``OPENHUB_PORT``OPENHUB_BACKEND_DIR``OPENHUB_REDIS_HEALTH_URL``MNOTE_OPENHUB_BASE_URL``MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE`,并补 `scripts/desktop-hot.test.js`
- 当前实现的变量名是 `OPENHUB_CMD` / `OPENHUB_PORT`,不是 checklist 早期草案里的 `OPENHUB_BACKEND_CMD` / `OPENHUB_BACKEND_PORT`;后续若需要兼容别名可再补。
- `desktop-hot` 不释放或 kill OpenHub 端口;OpenHub Redis 目前只有 health URL 检查,不负责启动 Redis。
- `MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=1` 已作为 MNote 启动环境默认值和 smoke 断言;OpenHub 研究源码 `/tmp/mnote-openhub-research/OpenHub/smart-query-backend` 已最小 patch`git_snapshot.py` 中央拦截 `init/config/add/commit/checkout/restore/reset/revert``stream.py``task_executor.py` 跳过自动 snapshot`session.py` 的 restore / restore-file 在禁用时返回 403。`scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js` 和 Python guard 探针已通过。
- 当前真实探针:OpenHub FastAPI `127.0.0.1:18080`、opencode `127.0.0.1:4096`、MNote `3021` 均可达;WeKnora 在 MNote 进程携带 `MNOTE_WEKNORA_API_KEY``/api/page-ai/openhub/status` 返回 ready。Redis 目前只作为 OpenHub 内部可选依赖/降级链路,MNote health 仅支持外部 URL 探针,不负责启动 Redis;已用 WeKnora `.env``REDIS_PASSWORD` 真实 `AUTH + PING` 返回 `+OK/+PONG`
- 2026-06-26 WeKnora CLI 真实 profile smoke:临时 `XDG_CONFIG_HOME` 下执行 `weknora profile add mnote-local --host http://127.0.0.1:8080 --use`,再把 MNote 进程中的 `MNOTE_WEKNORA_API_KEY` pipe 给 `weknora auth login --with-token``weknora doctor --no-cache --format json` 返回 4/4 checks passedbase URL、auth、server version、credential storage)。`weknora mcp serve --help` 通过;stdio MCP handshake 探针未稳定完成,保留为 dev-hot/orchestration 后续项。
## 5. P1 登录与 OpenHub Scope
- [x] 新增/扩展 MNote OpenHub bootstrap endpoint:输入当前 `mnote_session + workspace/rootUri + page_resource_id`,输出 OpenHub runtime scope。
- [x] 定义 `openhub_user_key = stable_hash(mnote_user_id)`
- [x] 定义 `openhub_workspace_key = stable_hash(workspace_id, root_uri)`
- [x] 定义 `openhub_session_scope = hash(mnote_user_id, workspace_id, root_uri, page_resource_id)`
- [x] 定义 `weknora_tool_scope = allowed_kb_ids + allowed_source_ids + citation_policy`
- [x] 禁用 OpenHub 前端 `/login` 跳转和 localStorage token 作为 MNote 产品入口;由 MNote proxy/header/bootstrap 注入受控身份。
- [x] 保留 OpenHub 后端 user/session owner 校验;映射到 MNote 派生 OpenHub user,不共享单一 OpenHub 用户。
- [~] rootUri/workspace 切换时新建或切换 OpenHub session;旧 binding 标记 `stale/archived`
- [x] 测试:两个 MNote 用户同一页面不能读到同一个 OpenHub session/message/skill/tool permission scope。
P1 scope 记录:
- 新增 `rust/crates/mnote-web/src/routes/page_ai_openhub.rs``/api/page-ai/openhub/bootstrap` 输出 camelCase 与 snake_case scope`openhubUserKey/openhub_user_key``openhubWorkspaceKey/workspace_key``openhubSessionScope/session_scope``skillScope/skill_scope``toolPermissionScope/tool_permission_scope``weknoraToolScope/weknora_tool_scope`
- MNote 侧已拒绝 OpenHub JWT/localStorage 作为产品入口;`/page-ai/openhub/login` 等非 AI 路由由 MNote guard。
- OpenHub 研究源码已消费 `X-MNote-*` headers 派生后端 user/scope,并保留 session owner guard。
- 2026-06-26 `node --check scripts/task775-openhub-mnote-scope-isolation-smoke.js``node scripts/task775-openhub-mnote-scope-isolation-smoke.js`:通过。直连 OpenHub MNote header scope 创建 session `task775-openhub-scope-1782410072554-157c8c`,Owner A 可读取自己写入的 user messageOwner B 读取同一 session messages 返回 403 `无权访问该会话`;stream 阶段模型不可用不影响 owner guard 验收,因为消息持久化和隔离均已验证。
## 6. P1 Page AI 嵌入 OpenHub AI 面板
- [x]`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` 设计嵌入方式:iframe 或 reverse proxy 二选一。
- [x] 只暴露 OpenHub AI 页面;`/login``/admin`、FileManager、KnowledgeManager、GitTimeMachine、Team/Scheduler/SmartEntity 非 MVP 入口隐藏、404 或转 MNote。
- [~] 保留 AI 主界面、history/session 抽屉、skill/agent 设置、tool/model permission、tool card、diff、changed files、运行日志。
- [x] 如果 OpenHub 无 MCP 管理 UI,只显示 WeKnora MCP/CLI tool 连接状态和 MNote scope,不新建完整 MCP 管理器。
- [x] MNote context 注入字段:user display、workspace/rootUri、page path/title/resource id、selection 摘要、allowed roots、session/skill/tool/weknora scope。
- [x] changed file chip 点击走 MNote document pane bridge。
- [x] OpenHub KnowledgeManager 入口若出现,跳转 MNote WeKnora 知识库页;不得打开 OpenHub knowledge tables。
- [x] browser smoke:登录 MNote 后打开 Page AI,不出现 OpenHub/WeKnora 登录页。
- [x] browser/API smokeOpenHub session/message 已持久化并可读;刷新后 UI history 恢复已完成真实浏览器验收。
P1 Page AI host 记录:
- `sidebar-page-ai-runtime.js` 默认打开 OpenHub host draweriframe 指向 `/page-ai/openhub/ai?scope=...``localStorage mnote.page_ai.openhub_host=0` 可回退 `/page-ai/opencode`
- `page_ai_openhub.rs``routes/mod.rs` 已挂 `/page-ai/openhub/ai` 静态 shell 和 `/page-ai/openhub/ai/{*path}` proxy`login/admin/file/files/knowledge/git` 路由已 guard。
- OpenHub React 前端已做 MNote embed 最小补丁:`mnoteEmbed.js` 识别 `/page-ai/openhub/ai` 嵌入态,`BrowserRouter` 使用 `/page-ai/openhub/ai` basenameAPI 默认走 `/page-ai/openhub/ai/api`,请求自动携带 `scope/mnoteScope`,无 token 不再发送 `Bearer null`401 不跳 OpenHub `/login``/login/admin/file/files/knowledge/git` 前缀导航回 AI 根页;嵌入态隐藏 OpenHub FileManager、KnowledgeManager、GitTimeMachine、SmartEntity/Team 入口和对应 drawer。
- MNote `ai_shell` 已从 OpenHub FastAPI 根 `/` 拉取 SPA index.html 并重写 `/assets/...``/page-ai/openhub/ai/assets/...`OpenHub React dist 已复制到 `smart-query-backend/static/`
- 2026-06-26 浏览器 smoke `node scripts/task773-page-ai-openhub-browser-smoke.js`:通过。MNote 3000 可达,测试账号真实登录成功(`authMode=sqliteSession`),Page AI drawer、OpenHub host chrome 与 `/page-ai/openhub/ai?scope=...&mnoteScope=...` iframe 可见,未出现 OpenHub/WeKnora 登录页;iframe 内 OpenHub React UI 可见,文本 marker 包含 `OpenHub 平台/开始对话/历史记录/技能管理``openhubReactConnected=true``staticBoundaryOnly=false`,冲突入口 `文件管理/知识库/时光机/智能体/协作任务/团队状态` 不再出现在嵌入态正文。截图:`tmp/7-68-runtime/page-ai-openhub-browser.png`;结果 JSON`tmp/7-68-runtime/page-ai-openhub-browser-result.json`
- 2026-06-26 send/history smoke `node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过。MNote SQLite 测试账号登录后,经 MNote proxy 调 OpenHub `/api/models` 返回 66 个真实 opencode 模型,选用 `opencodego/deepseek-v4-flash` 发送真实 streamOpenHub SQLite 可读取同一 session 的 user/assistant message。结果 JSON`tmp/7-68-runtime/openhub-send-smoke-result.json`
- 2026-06-26 changed files bridge smoke `node scripts/task776-openhub-changed-files-bridge-smoke.js`:通过。OpenHub MNote host mode `/api/sessions/{session_id}/diff` 从 opencode tool event metadata 提取真实 changed file path,并返回 `diffAvailable=false` 与限制说明,不伪造 diff content;结果命中 `knowledge-rag-fixtures-7-68/task774-openhub-file-edit-1782409309451.md`。OpenHub 前端 `DiffViewer` / `SmartQueryPage` 通过 `postMNoteOpenFile` 发送 `mnote:open-file`MNote host 监听后复用现有 document pane open-file bridge。
- 2026-06-26 `BASE_URL=http://127.0.0.1:3021 node scripts/task786-openhub-history-refresh-browser-smoke.js`:通过。真实浏览器创建 OpenHub session message,刷新后 history drawer 可见 marker,点击 session 后当前 message area 可见同一 markerOpenHub API message/history 同步验证通过。该轮 stream 返回“所有模型均不可用” error event,但 user message/session/history 持久化与 UI 恢复已验收;真实 AI reply 由 `task774` 覆盖。截图:`tmp/task786-openhub-history-refresh-browser-smoke/openhub-history-refresh.png`
## 7. P1 OpenHub 原生 opencode 链路
- [x] 保留 OpenHub FastAPI prompt/event/diff client,不在 MNote 重写 event parser。
- [x] 确认 OpenHub prompt 请求携带 `directory = MNote rootUri`
- [x] 确认 OpenHub 能监听 opencode `/global/event` 并渲染 assistant/tool part。
- [x] 确认 OpenHub 能读取 changed files/diff,并将 path 暴露给 MNote bridge。
- [x] MNote 只存 artifact index`openhub_session_id + kind + provider_id + path/citation payload`,不复制消息全文。
- [x] 当前打开文档被 opencode/OpenHub changed-file bridge 标记后,MNote 走 watcher 或 `refreshPrimaryDocument()` 刷新。
- [~] smoke:让 OpenHub AI 修改 rootUri 内测试 MarkdownMNote document pane 可见变更,且没有 OpenHub Git snapshot commit。当前已覆盖真实 opencode 文件修改 + 模拟 OpenHub changed-file bridge 刷新;单条端到端 AI 修改“当前已打开文档”仍可后续补强。
P1 opencode 链路记录:
- MNote 当前只做 host/status/bootstrap/proxy 边界,没有重写 OpenHub prompt/event parser。
- OpenHub 后端 `query.py` 已在 MNote host mode 下使用 `resolve_user_workspace(current_user)`,创建 session 与 stream prompt 均把 `directory` 指向 MNote rootUri 解析出的 workspace path。
- OpenHub 后端 `query.py` 已补 MNote host mode `/api/models`:不再返回空模型,而是调用真实 opencode `/config/providers`,展开 provider/model 并按 opencode `default` 输出 `default/defaultBuild/defaultPlan`;opencode 不可达时返回明确 empty + error,不伪造 mock model。
- 2026-06-26 主控复核:`curl -H X-MNote-* http://127.0.0.1:18080/api/models` 返回 `success=true``modelCount=66``source=opencode_config_providers`、默认 `openai/gpt-5.3-chat-latest``python -m py_compile` 覆盖 `query.py/auth.py/mnote_scope.py` 通过。
- 2026-06-26 `node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过。经 MNote proxy 发送真实 OpenHub stream,事件包含 `session/step-start/text/reasoning/step-finish/message_complete/session_idle`OpenHub SQLite `/api/sessions/{id}/messages` 可读 user/assistant 两条消息。
- 2026-06-26 `MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过。opencode 在 MNote rootUri 内把 `knowledge-rag-fixtures-7-68/task774-openhub-file-edit-1782409309451.md` 修改为 `status: MNOTE_OPENHUB_FILE_EDIT_OK`stream 事件包含 `tool`workspace `.git` 在前后均不存在,未创建 OpenHub Git snapshot。当前 document pane 刷新由后续 `task779` 覆盖。
- 2026-06-26 `node scripts/task776-openhub-changed-files-bridge-smoke.js`:通过。MNote proxy fresh bootstrap 后访问 OpenHub `/api/sessions/ses_1002052f0ffeOpebUFbGq4EC76/diff` 返回 `source=opencode_tool_events``diffAvailable=false`changed path 命中上面的真实 file-edit fixtureOpenHub Git snapshot 写链仍禁用,因此只暴露真实 changed path,不生成或伪造行级 diff。当前 document pane 刷新由后续 `task779` 覆盖。
- 2026-06-26 `node scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js`:通过。浏览器打开 local-first 测试 Markdown 后,脚本在 rootUri 内模拟 OpenHub/opencode 改写磁盘文件,再触发 MNote OpenHub changed-file bridgeMNote 侧复用 `mnote:open-file` / local-folder event bus synthetic watch batch / `refreshPrimaryDocument()` / `openPrimaryDocument()`primary document pane 原地显示新内容,并写入 `data-mnote-page-ai-openhub-document-pane-refresh` 诊断 marker。该 smoke 不新增应用轮询。
- 2026-06-26 MNote 侧新增 `/api/page-ai/openhub/artifact-index` GET/POST 最小边界:写入 `schema=mnote.page_ai_openhub_artifact_index_record.v1` 的轻量索引,只接受 `openhub_session_id/openhubSessionId + kind + provider_id/providerId + path + citation_payload/citationPayload` 等 locator 字段;默认持久化到 rootUri 下 `.mnote/page-ai-openhub-artifact-index.json`,也可由 `MNOTE_OPENHUB_ARTIFACT_INDEX_PATH` 指定。该实现明确 `messageFulltextCopied=false`,并拒绝 `message/messages/content/text/transcript/conversationMessages/assistantMessage/userMessage` 等 OpenHub user/assistant message 全文字段;不是复制 OpenHub SQLite message 表。
- 2026-06-26 `node --check scripts/task778-openhub-artifact-index-static-smoke.js``node scripts/task778-openhub-artifact-index-static-smoke.js`:通过。静态 smoke 断言 artifact index route、轻量字段、全文字段拒绝、最小文件持久化和本 checklist 记录。
- 2026-06-26 主控 runtime 复核:登录 MNote 测试账号后 POST `/api/page-ai/openhub/artifact-index` 写入 `kind=changed_file``providerId=opencode-tool-event-1``path=knowledge-rag-fixtures-7-68/task774-openhub-file-edit-1782409309451.md`GET 同 session 返回 1 条记录,`storage.messageFulltextCopied=false`;携带 `message` 全文字段的 POST 返回 `page_ai_openhub_artifact_index_forbidden_fulltext_field`
- 2026-06-26 OpenHub 嵌入态已接 MNote artifact index`mnoteEmbed.js``mnoteScope` 解出 `rootUri/workspaceId/pageResourceId``SmartQueryPage.jsx` 在 changed files diff metadata 加载后 POST `kind=changed_file` 轻量记录,并在 WeKnora citation/tool output 出现时 POST `kind=citation` 轻量记录;请求字段限定为 `openhubSessionId/kind/providerId/path/citationPayload/rootUri/workspaceId/pageResourceId`citationPayload 递归去除 `message/messages/content/text/transcript/quote/displayQuote/matched_content` 等全文或证据正文键,不发送 OpenHub message/content/text 全文字段。
- 2026-06-26 `node --check scripts/task780-openhub-artifact-index-runtime-smoke.js``node scripts/task780-openhub-artifact-index-runtime-smoke.js`:通过。smoke 验证 OpenHub 前端静态边界、runtime POST URL/request body 形状、`mnoteScope` 上下文字段、changed_file / citation 轻量 artifact index 记录,以及请求体不含全文字段。
## 8. P1 WeKnora 知识库页面替换
- [x] 建立 MNote `mnote_knowledge_bases` 模型:user/workspace/rootUri/name/provider/provider_kb_id/default_tool_enabled。
- [x] 迁移/扩展 `mnote_knowledge_sources`:兼容旧 `lightRag*` 字段,新增 provider-neutral 字段。
- [x] 知识库列表页复用 WeKnora KB list 体验:名称、描述、文档数、chunk 数、processing 状态、更新时间。
- [x] 知识库详情页复用 WeKnora detail 体验:source 列表、状态、reparse/delete、搜索入口。
- [x] source 添加入口使用 MNote FileTree/resource picker,不使用 WeKnora 独立文件真相。
- [x] 入库前 MNote 校验 allowed roots;文件/文件夹/page resource 转成 WeKnora file/manual/url ingest。
- [x] processing timeline 显示 WeKnora `pending/processing/completed/failed`,并同步到 registry 与 FileTree 灯号。
- [x] 删除 source 只删除 provider index/registry 映射,不删除本地文件。
- [x] browser smoke:创建 KB -> 添加本地文件夹 -> 看到 indexing -> 完成后可搜索。
P1 WeKnora 知识库模型记录:
- 2026-06-26 新增 MNote 后端 `mnote_knowledge_bases` runtime registry`schema=mnote.knowledge_bases.registry.v1`,持久化到 workspace root 下 `.mnote/index/mnote-knowledge-bases.json`,字段包含 `userId/workspaceId/rootUri/name/provider/providerKbId/defaultToolEnabled/sourceCount/chunkCount/status``status``ingest` 会在有 `rootUri` 时返回 `knowledgeBases`
- `mnote_knowledge_sources` 边界通过现有 `.mnote/index/lightrag-source-registry.json` 兼容落地:新增 provider-neutral 字段 `provider/providerStatus/providerSourceId/providerKnowledgeId/providerKnowledgeBaseId`,同时保留旧 `lightRagDocId/lightRagStatus/lightRagFilePath` 兼容字段;读写 registry 时会为旧记录补齐 provider-neutral 字段,不删除旧 registry。
- WeKnora ingest 现在写入 source registry 的 provider-neutral 映射,并同步 `mnote_knowledge_bases.sourceCount`search/open-reference 继续复用 provider ids 与旧 LightRAG/source registry 兼容映射,不破坏已完成 ingest/search/open-reference。
- 2026-06-26 `node --check scripts/task781-knowledge-bases-registry-smoke.js`:通过。
- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3021 node scripts/task781-knowledge-bases-registry-smoke.js`:通过。验证 status 可读 `mnote_knowledge_bases`、ingest 写 provider-neutral source 字段、search 复用同一 WeKnora KB/source 映射;结果文件 `tmp/task781-knowledge-bases-registry-smoke/result.json`。同一脚本曾因 WeKnora 异步索引窗口在 240s 内未返回新 source 而失败一次,随后重跑通过,说明 search readiness 仍有 provider 异步延迟风险。
- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3021 node scripts/task785-weknora-create-kb-folder-ingest-search-smoke.js`:通过。真实 API 创建 WeKnora KB `df7bd813-fef2-40f4-b08d-b47e03a58cea`,ingest 本地文件夹下 2 个 Markdown sourcescoped search 命中同一 KB/source 映射;结果文件 `tmp/task785-weknora-create-kb-folder-ingest-search-smoke/result.json`
- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3022 node scripts/task788-weknora-kb-folder-browser-flow-smoke.js`:通过。真实浏览器 UI 创建 WeKnora KB `dc5e35f6-4d0f-4289-ae79-de8f4dac8441`,UI 添加本地文件夹 sourcesource registry 映射 2 个 sourceUI 指定新 KB 检索并展示 source resultAPI search 复核命中同一 KB/source`uiCreatesKb=true``uiAddsFolder=true``browserVerifiesSearch=true``apiVerifiesSearch=true`。截图:`tmp/task788-weknora-kb-folder-browser-flow-smoke/weknora-kb-folder-browser-flow.png`
- 2026-06-26 修复 WeKnora KB 创建 payload:新增 `embedding_model_id`,默认 `builtin-mnote-embedding`,可由 `MNOTE_WEKNORA_EMBEDDING_MODEL_ID` / `WEKNORA_EMBEDDING_MODEL_ID` 覆盖;否则新 KB 会长期 processing 且 search 无结果。
- 2026-06-26 修复 scoped search`sourcePaths` 从 registry 推导 `knowledge_ids`,并在 WeKnora 返回后按 source scope 二次过滤,避免同 KB 下其他旧文件污染指定 source 检索。
## 9. P1 WeKnora 检索替换 LightRAG
- [~] 在 `rust/crates/mnote-web/src/routes/knowledge_rag.rs` 或拆分模块中抽出 `KnowledgeProvider` boundary。
- [x] 新增 `weknora` provider,旧 LightRAG 标为 `lightrag_legacy` 并默认隐藏。
- [x] `/api/knowledge-rag/status` 改为 WeKnora health + KB/source registry 状态。
- [x] `/api/knowledge-rag/ingest` 改为 WeKnora ingest,写 provider KB / knowledge / source hash。
- [x] `/api/knowledge-rag/search``/api/v1/knowledge-search`;单 KB 调试可走 hybrid-search。
- [x] `/api/knowledge-rag/query` 第一阶段用 search results + citations 生成 answer envelope,不启用 WeKnora chat session。
- [x] `/api/knowledge-rag/section-context` 改为 WeKnora chunk/list/by-id + MNote locator,不读 LightRAG sidecar。
- [~] `/api/knowledge-rag/open-reference` 仅接受 provider ids / open-reference token,回查 registry 后生成 MNote locator。
- [x] 映射保留 `content/matched_content/match_type/metadata/chunk_metadata/image_info/parent_chunk_id/sub_chunk_id`
- [x] UI 不把 score 显示为百分比相似度;只显示排序或弱化分值。
- [x] 单测:SearchResult -> MNote result/citation/open-reference;缺 registry 映射时降级且不伪造路径。
- [x] smokeWeKnora ingest 本地 md -> MNote search 返回 provider=`weknora` -> open-reference 打开 MNote 文档。
P1 WeKnora 检索记录:
- `knowledge_rag.rs` 默认 provider 为 `weknora``MNOTE_KNOWLEDGE_PROVIDER=lightrag|lightrag_legacy` 才进入 legacy。
- `/api/knowledge-rag/status` 返回 `providerConfig.active/default/legacyFallback/weknora/lightrag`、WeKnora health、legacyHealth、registry。
- `/api/knowledge-rag/ingest` 默认 WeKnora provider 时通过 `POST /api/v1/knowledge-bases/:id/knowledge/file` multipart 上传本地授权文件,写回 `providerKnowledgeBaseId/providerKnowledgeId/sourceHash/providerStatus`;缺 KB id 时只登记 `pending_provider_ingest_missing_kb`,不碰 LightRAG staging。
- `/api/knowledge-rag/search``/api/knowledge-rag/query` 调 WeKnora `/knowledge-search`,请求包含 `query + knowledge_base_ids`,按需 `knowledge_ids/match_count`API key 只在后端注入。
- WeKnora chunk mapper 保留 provider ids、raw chunk、content/matched_content、metadata/chunk_metadata/image_info/parent/sub chunks;未命中 registry 时 `filePath=null``locatorDegraded=true`,不把 `knowledge_filename` 伪装成本地路径。
- `sidebar-page-settings-runtime.js` 已收口 WeKnora 默认 provider、LightRAG legacy fallback、source registry、processing/citation/open-reference 文案;score 只显示 provider 排序分,不再表达为相似度百分比;删除 source 明确不删除本地文件。
- `node scripts/task772-weknora-ingest-search-open-reference-e2e.js` 已真实通过:在 allowed root 下创建 `knowledge-rag-fixtures-7-68/task772-weknora-e2e-1782405331456.md`MNote `/api/knowledge-rag/ingest` 返回 provider=`weknora``mappingStatus=provider_mapped``providerKnowledgeBaseId=90cc060c-3a16-4146-9d2b-fef8eb2c2d90``providerKnowledgeId=78b2c703-6d6a-4fa6-a881-f5ff6cfb769d``/api/knowledge-rag/search` 返回 4 条 weknora reference 并保留 provider ids`/api/knowledge-rag/open-reference` 返回 `filePath=null``sourceRootRelativePath` 为 registry 映射路径、`openAction.params.path` 为本地 source path,未把 provider filename 伪造成本地路径。
- `/api/knowledge-rag/section-context` 已改为 WeKnora provider chunk 优先:有 `providerChunkId` 时调 `/chunks/by-id/:id`,否则按 `providerKnowledgeId``/chunks/:knowledge_id`;返回 `providerChunkFetch.attempted/ok/source/fetchedChunks``providerChunk` metadata 与 MNote locator,且 `sidecarRead=false`。为适配 WeKnora 异步 parse 状态,section-context 对 WeKnora provider 已允许 `pending/submitted/processing/processed/completed/provider_mapped` 的 registry 映射进入 provider chunk fetchLightRAG legacy sidecar 仍要求 `processed`
- 2026-06-26 `node scripts/task777-weknora-section-context-smoke.js`:通过。基于 task772 真实 ingest 结果,by-id 命中 `providerChunkId=78f026a5-7bb1-4b82-92f7-ee4f560f5640`list 命中同一 `providerKnowledgeId=ec461ca5-37f7-471c-b4f7-4ced7be09a01`,两路均返回 `providerChunkFetch.ok=true``sidecarRead=false`、文本含 task772 marker。
- 2026-06-26 `cargo test -p mnote-web knowledge_rag --lib -- --test-threads=1`59 passed`node scripts/task544-weknora-provider-bridge-static-smoke.js`:通过。
## 10. P1 WeKnora MCP/CLI Tool Bridge
- [x] 定义 MNote 对 OpenHub/opencode 暴露的工具名:`mnote.weknora.search``mnote.weknora.open_reference``mnote.weknora.list_sources``mnote.weknora.get_source_status`
- [x] 底层第一阶段优先用 Go CLI `weknora mcp serve` 或 MNote HTTP facade 包装同等只读能力。
- [x] tool 调用前注入 KB/source allowlist;禁止直接传未授权 `knowledge_base_id`
- [x] tool result 返回 provider ids、quote/matched_content、chunk metadata、MNote open-reference token。
- [x] 不把 WeKnora API key 暴露给浏览器或 OpenHub 前端 localStorage。
- [x] Python `mcp-server` 的 create/delete/chunk mutation 不默认暴露;如未来开启写能力,必须经过 MNote 权限审批。
- [~] smokeOpenHub AI 通过 WeKnora tool 查询 KB,回答中出现可点击 citation,点击后由 MNote 打开本地页面。当前完成 OpenHub host mode 自动 WeKnora search/tool event + clickable citation bridge;仍不是 opencode 原生 MCP 自动选 tool 的完整闭环。
P1 WeKnora tool 记录:
- Hermes tool manifest 与 dispatch 已新增 `mnote.weknora.search/open_reference/list_sources/get_source_status`
- Tool wrapper 要求 `rootUri` 以及 `scope/allowlist/allowedRoots/aiAccessScope/sourcePaths` 之一,否则返回 `mnote_weknora_scope_required`
- 当前通过 MNote HTTP facade 调用 WeKnora;没有把 WeKnora API key 暴露到浏览器/localStorage,也没有默认开放 Python mcp-server 写能力。
- 2026-06-28 主控收口:MNote 新增中性 tool executor alias `/api/mnote/tools/{manifest,call,audit}`OpenHub backend 已改为调用 `/api/mnote/tools/call`;旧 `/api/hermes/tools/mnote/*` 只保留 legacy alias。OpenHub host mode 只读 facade 通过 MNote 反代受控 headers 与 server-side `X-MNote-Session-Cookie` 调 MNote tool executor,支持 `mnote.weknora.search/open_reference/list_sources/get_source_status`OpenHub 不接触 WeKnora API key,也不把 MNote cookie 暴露给前端 localStorage。`stream.py` 在 MNote host mode 下对知识库/资料库/WeKnora/citation/source 类问题先调用 MNote WeKnora search,把结构化 tool result 注入 OpenHub/opencode prompt,并向 OpenHub 前端推送 `mnote.weknora.search` tool event。
- 2026-06-26 task782 可写 workerOpenHub 前端 `AssistantMessage` 从 WeKnora tool output 的 `citations/uiCitations/references` 渲染 `data-mnote-openhub-citation` 可点击 citation,点击经 `postMNoteOpenReference``mnote:open-reference`MNote Page AI host 已接受 `openhub-citation` 并复用 document pane open-file bridge 打开本地页面。新增 `scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js` 覆盖后端 facade、scope/cookie/API key 边界、stream auto tool bridge、citation button 与 postMessage runtime。主控复核:OpenHub `/openapi.json` 已暴露 `/api/mnote/tools/call``node scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js` 通过;边界仍标 partial,不宣称 opencode 原生 MCP 自动选 tool 完成。
## 11. P1 验收与证据
- [~] `npm run dev:hot` 或目标启动命令能启动/检查 MNote、OpenHub FastAPI、OpenHub Redis、opencode、WeKnora API、WeKnora CLI/MCP。OpenHub/MNote/opencode/WeKnora API、Redis auth、WeKnora CLI profile/doctor 与 MCP 命令面已探通;stdio MCP handshake 和单命令 dev-hot orchestration 仍作为后续收口。
- [x]`mnote.e2e@example.com` 登录 MNotePage AI 不出现 OpenHub/WeKnora 登录。
- [x] Page AI 可发送消息,OpenHub SQLite session/message/history 持久化,刷新恢复。
- [x] AI 修改当前 rootUri 内 MarkdownMNote 当前页面刷新后可见。当前证据由 `task774` 真实 opencode 文件修改 + `task779` 浏览器模拟 OpenHub changed-file bridge 刷新组成。
- [x] OpenHub Login/Admin/FileManager/KnowledgeManager/GitTimeMachine 非 AI 入口不接管 MNote。
- [x] WeKnora 知识库页完成 KB 创建、source 添加、状态显示、检索、citation 回跳。
- [x] 旧 LightRAG 默认入口不再出现在主 UI/smokelegacy fallback 显式标识。
- [x] 保存 smoke 输出、关键截图、失败分层日志。
P1 当前验证证据:
- `cargo check -p mnote-web`:通过。
- `cargo test -p mnote-web knowledge_rag --lib -- --test-threads=1`59 passed。
- `node scripts/task544-weknora-provider-bridge-static-smoke.js`:通过。
- `node scripts/task768-page-ai-openhub-host-static-smoke.js`:通过。
- `node scripts/task769-weknora-knowledge-settings-ui-static-smoke.js`:通过。
- `node scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js`:通过。
- `node --check scripts/task773-page-ai-openhub-browser-smoke.js`:通过。
- 早期 `node scripts/task773-page-ai-openhub-browser-smoke.js` 只验证到静态 shell,已被后续 React bundle 接入结果覆盖;保留为阶段证据,不作为当前最终状态。
- `npm run build``/tmp/mnote-openhub-research/OpenHub/smart-query-frontend`):通过;Vite 仍提示主 chunk 超过 500 kB,这是既有体积 warning。
- OpenHub React dist 已复制到 `/tmp/mnote-openhub-research/OpenHub/smart-query-backend/static/`MNote `/page-ai/openhub/ai` 可加载 OpenHub SPA index/assets。
- `node scripts/task773-page-ai-openhub-browser-smoke.js`:通过;测试账号真实登录,Page AI OpenHub host drawer/iframe 可见,OpenHub React UI marker 出现,未出现 OpenHub/WeKnora 登录页;`openhubReactConnected=true``staticBoundaryOnly=false`,嵌入态未显示 FileManager/KnowledgeManager/GitTimeMachine 等冲突入口。
- `curl -H X-MNote-* http://127.0.0.1:18080/api/models`:通过;MNote host mode 返回 66 个真实 opencode 模型,`source=opencode_config_providers`,未使用 mock。
- `node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过;MNote proxy -> OpenHub `/api/models` -> `/api/query/stream` -> OpenHub SQLite `/api/sessions/{id}/messages` 全链路通过,选用 `opencodego/deepseek-v4-flash`
- `MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过;opencode 修改 MNote rootUri 内测试 Markdown,最终内容含 `status: MNOTE_OPENHUB_FILE_EDIT_OK``.git` 未被 OpenHub 创建。
- `node --check scripts/task772-weknora-ingest-search-open-reference-e2e.js`:通过。
- `node scripts/task772-weknora-ingest-search-open-reference-e2e.js`:通过;当前 shell 未直接暴露 WeKnora env,脚本先输出 `local_weknora_env_not_visible` warning,但继续以 MNote 3000 的 status 和真实 API 调用为准,最终 ingest/search/open-reference 全链路通过。最新主控重跑创建 `knowledge-rag-fixtures-7-68/task772-weknora-e2e-1782408870763.md`,映射到 WeKnora KB `90cc060c-3a16-4146-9d2b-fef8eb2c2d90` 与 knowledge `c6789541-7ad5-47a4-b621-5ef1e2762688`
- `python -m py_compile /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/api/query.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/api/session.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/core/auth.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/core/mnote_scope.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/git_snapshot.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/stream.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/task_executor.py`:通过。
- `MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=1 PYTHONPATH=/tmp/mnote-openhub-research/OpenHub/smart-query-backend python ...`:通过,确认底层 git 写命令 guard 生效。
- `node --test scripts/desktop-hot.test.js`6 passed。
- `git diff --check`:通过。
- `codegraph sync .`:完成,报告 synced 38 changed files。
- `node scripts/task776-openhub-changed-files-bridge-smoke.js`:通过;MNote proxy -> OpenHub changed files endpoint 返回真实 opencode tool-event path,且 OpenHub 前端到 MNote document pane open-file bridge 静态边界通过。
- `node scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js`:通过;打开测试 Markdown 后改写 rootUri 内文件并触发 OpenHub changed-file bridgedocument pane 可见刷新到新正文,诊断显示 `eventBusSource=openhub_changed_file_bridge`
- `node --check scripts/task780-openhub-artifact-index-runtime-smoke.js`:通过。
- `node scripts/task780-openhub-artifact-index-runtime-smoke.js`:通过;验证 OpenHub embed changed_file / citation 轻量 artifact index POST 形状,且不含 message/content/text 等全文字段。
- 外部服务真实探针:`/api/page-ai/openhub/status` 在配置 WeKnora API key 后返回 `status=ready`OpenHub FastAPI `127.0.0.1:18080` readyopencode `127.0.0.1:4096` readyWeKnora public `/health` readyOpenHub `/openapi.json` 暴露 `/api/mnote/tools/call`Redis `AUTH + PING` 返回 `+OK/+PONG`WeKnora CLI `profile add + auth login --with-token + doctor --no-cache` 通过,`go run . mcp serve --help` 通过。stdio MCP handshake 未作为最终通过项,因此 dev-hot/CLI/MCP 全链路仍 partial。
- 2026-06-26 task783/task784 UI 增量:`sidebar-page-settings-runtime.js` 在 WeKnora 设置面板增加 KB summaryKB/source/provider docs/processing)、本地文件/文件夹 source picker 边界标记、source row 与 search result 的 `open-source-reference` 控件;`sidebar-tree-runtime.js` 接线该控件到 MNote document pane `openPrimaryDocument`,仍复用 `/api/knowledge-rag/status/ingest/search/delete-source`,未改后端 `knowledge_rag.rs``node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js``node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js``node --check scripts/task783-weknora-kb-settings-ui-static-smoke.js``node --check scripts/task784-weknora-kb-settings-browser-smoke.js``node scripts/task783-weknora-kb-settings-ui-static-smoke.js``node scripts/task769-weknora-knowledge-settings-ui-static-smoke.js` 均通过。真实 browser smoke 使用临时 3018 MNote 实例与已有 task772 WeKnora 入库结果:`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task784-weknora-kb-settings-browser-smoke.js` 通过,截图 `tmp/task784-weknora-kb-settings-browser-smoke/weknora-kb-settings.png`;验证 KB summary 包含 KB `90cc060c-3a16-4146-9d2b-fef8eb2c2d90`source row/search result/open-reference/delete-preserve-local-file 控件可见。仍 partial:未创建新的 KB;未真实点击 open-reference 后断言文档 pane 跳转;未完成 checklist 第 8 节“创建 KB -> 添加本地文件夹 -> indexing -> 完成后搜索”的完整链路。
- 2026-06-26 task788 UI 完整链路增量:`sidebar-page-settings-runtime.js` 新增 WeKnora KB select、创建 KB 控件,ingest/search 带当前选中 `providerKnowledgeBaseId``sidebar-tree-runtime.js` 接线 create-kb 与 KB select change。主控复核 `node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js``node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js``node --check scripts/task788-weknora-kb-folder-browser-flow-smoke.js` 通过;临时当前构建 MNote `3022``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3022 node scripts/task788-weknora-kb-folder-browser-flow-smoke.js` 通过,UI 创建 KB `dc5e35f6-4d0f-4289-ae79-de8f4dac8441`、UI 添加文件夹 source、UI 指定新 KB 检索、API search 复核均通过。
- 2026-06-26 用户纠正后知识库页面替换增量:原“资料库问答/资料源管理”小面板不符合 7-68,已改为 WeKnora 风格“知识库列表 + 知识库详情/文档管理”页面。`sidebar-page-settings-runtime.js` 补入 KB 面包屑、Documents/Wiki/Graph/Search 页签、source/tag 侧栏、文档搜索/筛选、添加文档/文件夹、列表/网格切换、processing/status 与 open-reference 控件;`main.css` 补齐对应布局;新增 `scripts/task790-weknora-kb-page-experience-static-smoke.js``scripts/task79x-weknora-kb-page-browser-smoke.js`
- 2026-06-26 纠正后复核:`node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js``node --check scripts/task790-weknora-kb-page-experience-static-smoke.js``node --check scripts/task79x-weknora-kb-page-browser-smoke.js``node scripts/task783-weknora-kb-settings-ui-static-smoke.js``node scripts/task789-weknora-kb-page-ui-reference-static-smoke.js``node scripts/task790-weknora-kb-page-experience-static-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3022 node scripts/task79x-weknora-kb-page-browser-smoke.js` 均通过。浏览器结果确认 `openedReplacementPanel=true``notOldSimpleSettingsPanel=true``hasKnowledgeBaseListCards=true``hasDetailStructure=true``hasDocumentTabs=true``hasSearchAndFilter=true`;截图 `tmp/task79x-weknora-kb-page-browser-smoke/weknora-kb-page-browser-smoke.png`
- 2026-06-26 Page AI 截图复核:`MNOTE_UI_BASE_URL=http://127.0.0.1:3022 node scripts/task773-page-ai-openhub-browser-smoke.js` 通过,截图 `tmp/7-68-runtime/page-ai-openhub-browser.png`OpenHub React UI marker 可见,`openhubReactConnected=true``staticBoundaryOnly=false`,未出现 OpenHub/WeKnora 登录页或 FileManager/KnowledgeManager/GitTimeMachine 冲突入口。
- 2026-06-26 `BASE_URL=http://127.0.0.1:3021 node scripts/task786-openhub-history-refresh-browser-smoke.js`:通过;OpenHub session/message/history 刷新恢复已由浏览器和 API 双重验证,结果文件 `tmp/task786-openhub-history-refresh-browser-smoke/result.json`
- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3021 node scripts/task787-weknora-default-lightrag-legacy-smoke.js`:通过;静态扫描 33 个主 UI/smoke 文件,`violations=[]`status API 的默认/active provider 为 WeKnoraLightRAG 仅作为显式 legacy fallback。
## 12. P2 后续优化
- [ ] 若需要开放 OpenHub Admin/tool/model 管理,先做 MNote role -> OpenHub admin scope 映射设计。
- [ ] 若需要完整 MCP 管理 UI,再单独设计 OpenHub/MNote MCP 管理面,不混入首版嵌入。
- [ ] 若需要 WeKnora chat/agent-chat,作为 OpenHub tool 内部 provider call,不变更 Page AI session 真相。
- [ ] 若需要版本恢复,走 MNote DocumentBuffer/watcher/显式用户确认策略,不恢复 OpenHub GitTimeMachine 写链。
@@ -1,809 +0,0 @@
# [stale] 7-68 OpenHub + WeKnora + MNote Page AI 嵌入式集成设计 v1
状态:stale / reference-only(不再作为当前 process 主线)
Owner07-ai / mnote-web / control-plane / knowledge-provider
日期:2026-06-25
2026-07-03 stale 说明:本文仍保留 Page AI / OpenHub / WeKnora 融合的历史设计上下文,但当前 runtime 口径已改为 OpenHub / native agent + LightRAG + Turso/libSQL。本文中将 WeKnora 定义为默认知识库 provider、LightRAG 退为 legacy 的内容不再作为当前主线执行;当前知识库主路径以 LightRAG provider 和 MNote provider-neutral `mnote.knowledge_rag.*` facade 为准。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界。
执行说明:本文不再是当前主设计,仅作为 WeKnora 方向历史方案和 OpenHub 集成参考。当前实现与后续新增能力不得从本文推导出“WeKnora 是默认 provider”或“LightRAG 已退出主线”的口径。
## 0. 结论先行
MNote Page AI 不应再沿“官方 opencode WebUI iframe”或“自研简陋聊天框”继续堆功能。新的主路线是:
```text
MNote 当前文档页 / workspace / auth / resource truth
├─ Page AI UI:嵌入 OpenHub AI 界面(只暴露 AI 面板能力)
├─ OpenHub backend:运行 OpenHub FastAPI + Redis + OpenHub SQLite session/skill/tool permission
├─ Agent runtimeOpenHub FastAPI 调用 opencode serve / opencode provider
├─ Knowledge providerWeKnora,通过 MNote 注册给 OpenHub/opencode 的 MCP/CLI/skill 工具调用
└─ MNote boundary:统一登录授权、workspace/rootUri scope、文件打开、citation 回跳
```
核心取舍:
总原则:**各部分尽量保持原有已完成能力,只有发生产品真相冲突时才做最小胶水/嫁接/裁剪**。OpenHub 保持 AI 面板、FastAPI、Redis、SQLite session/message、skill/tool permission、opencode clientWeKnora 保持知识库底座、MCP/CLI/APIMNote 保持 workspace/auth/resource tree/document pane/source registry。MNote 不重写 OpenHub/WeKnora 已有能力,只在登录态、workspace scope、文件打开、citation 回跳、禁用无关入口这些冲突点上做最小移植。
- **OpenHub 负责 Page AI 界面与多用户 AI 运行栈**:嵌入 OpenHub AI 面板,运行 FastAPI/Redis,使用 OpenHub 的用户隔离、session、skill、agent/tool permission、opencode 调用链;MCP 由 MNote/WeKnora/opencode 工具配置承接,不把“OpenHub 已有完整 MCP 管理面”当成已确认事实;FileManager/KnowledgeManager/Admin/Login 等非 AI 入口掐断或转接。
- **WeKnora 负责知识库底座、知识库页面参考实现和 MCP/CLI/API 工具能力**:解析、chunk、混合检索、RAG 引用、Wiki/图谱、知识库管理 API;知识库页面优先复用 WeKnora 的 KB list/detail/upload/status 体验,但不让 WeKnora 接管 MNote 文件真相。
- **MNote 负责宿主真相与冲突胶水**:登录、用户、workspace/rootUri、resource tree、页面打开、allowed roots、session binding、source registry、citation/open-reference;不重写 OpenHub/WeKnora 已有主功能。
- **opencode 负责 agent 执行**:文件编辑、diff、工具审批、模型调用。
不得把 OpenHub 登录入口、OpenHub FileManager、OpenHub KnowledgeManager、WeKnora RBAC/前端变成 MNote 的用户/文件/知识库真相。OpenHub AI session/message/skill/tool permission/FastAPI/Redis 可以作为 Page AI 运行真相,但必须受 MNote 派生的用户与 workspace scope 隔离;WeKnora 保持知识库底座真相,MNote 只做展示、授权和回跳映射。
## 1. 背景与当前状态
### 1.1 已知事实
- OpenHub 是一个基于 opencode 的多用户 AI 平台参考实现,前端 React/AntD 组件较完整,包含 Chat、History、ToolCall、Diff、FileManager、KnowledgeManager 等。
- OpenHub 多用户主要是应用层隔离:后端带 `directory=<user workspace>` 调 opencode,自己用 SQLite 管用户、session、message、权限、技能/工具权限,并可能对 workspace 执行 Git snapshot / restore。
- OpenHub 自带知识库是轻量 `knowledge_bases + knowledge_sources + 本地全文 content + SQLite LIKE 候选 + Python BM25/TF-IDF rerank + prompt stuffing`,不是完整 RAG 底座;默认注入上下文约 1200 字符。
- WeKnora 已部署在本机 `/mnt/Data1T/Mnote_data/weknora/WeKnora`,并提供知识库、知识文件、chunk、`/api/v1/knowledge-search`、knowledge-chat、agent-chat、tenant/RBAC、共享空间、CLI/MCP 等能力。
- MNote 当前 active 代码仍大量使用 `knowledge_rag` / `LightRAG` 命名;用户已明确当前放弃 LightRAG,因此需要 provider-neutral 化并切到 WeKnora。
- MNote 当前已有 `/api/page-ai/opencode/*` 反代/绑定雏形和 `ai_external_conversation_bindings`;在本路线下应转为 OpenHub host/proxy/session binding 与 artifact/open-reference index,而不是重写 OpenHub FastAPI 的 opencode client。
### 1.2 本设计覆盖范围
本设计替代 `7-65` 的官方 opencode iframe 产品主线,并收敛 `7-67` 的 OpenHub 初步融合设想。核心不是重写三套系统,而是保留各自已有能力,只对冲突点做最小胶水:
1. 登录/用户/权限冲突。
2. WeKnora 知识库底座、MNote 展示层、MNote 注册给 OpenHub/opencode 的 CLI/MCP/skill/tool 调用边界。
3. 页面/文件打开、changed files、citation 回跳边界。
## 2. 三方职责边界
| 能力 | MNote | OpenHub | WeKnora | 取舍 |
|---|---|---|---|---|
| 登录/用户 | MNote SQLite control-plane、`mnote_session` | OpenHub JWT/localStorage 禁用或后端注入;使用 MNote 派生 user/workspace | WeKnora tenant/RBAC/API key | MNote 是唯一登录入口;OpenHub 运行态按 MNote 用户/工作区派生 |
| workspace/rootUri | MNote local workspace、rootUri 授权 | OpenHub user workspace path / session scope / skill scope / tool scope | WeKnora tenant/KB | rootUri 归 MNoteOpenHub 的 workspace directory 由 MNote 授权 rootUri 派生 |
| Page AI UI | MNote sidebar host / iframe/proxy shell | OpenHub AI 界面 | WeKnora 不进入 Page AI 对话壳 | 嵌入 OpenHub AI 面板;隐藏或掐断非 AI 页面入口 |
| session/message | MNote 只做 host binding / ownership index | OpenHub SQLite conversation/session/message tablesRedis 只作缓存/临时态 | WeKnora chat sessions 暂弃用 | OpenHub session/message 是 Page AI 真相;MNote 不复制消息全文 |
| agent runtime | MNote 启停/健康检查/反代边界 | OpenHub FastAPI + Redis + opencode client | WeKnora CLI/MCP/APIagent-chat 暂弃用 | 运行 OpenHub 后端;由 OpenHub 调 opencodeMNote 不重写 opencode client |
| 知识库 UI | MNote shell / FileTree 灯号 / citation 回跳 | OpenHub KnowledgeManager 掐断或跳转 MNote | 复用 WeKnora KB list/detail/upload/status 页面体验 | 用 MNote shell + WeKnora KB adapter 替换 MNote 简陋页,但本地文件和授权仍归 MNote |
| 检索/RAG | MNote source registry / citation 回跳 / scope 集合定义 | OpenHub AI 通过 MNote 注册的 CLI/MCP/skill/tool 调 WeKnora | WeKnora hybrid/vector/graph/chunk | 知识库是 MNote 授权文件/文件夹集合的索引视图;WeKnora 是唯一索引与查询 provider |
| 文件打开 | MNote document pane/resource tab/FileTree,相当于 FileManager | OpenHub FileManager 掐断或跳转 MNote | WeKnora 不负责页面打开 | OpenHub AI 回复中的 path/citation 点击回 MNote 打开页面 |
| 权限审批 | MNote allowed roots + workspace grants + scope 注入 | OpenHub tool/model/skill permissionsMCP 走 opencode/WeKnora 工具配置 | WeKnora RBAC | MNote 提供授权边界;OpenHub 按原生权限规范执行;WeKnora RBAC 作 provider 防线 |
| Git / snapshot / restore | MNote watcher、buffer、用户显式保存/版本策略 | OpenHub Git snapshot 默认关闭 | 无关 | 第一阶段禁用 Git snapshot/restore,避免污染 local-first workspace |
| Redis/cache | MNote 不存 Page AI 消息真相 | OpenHub Redis 主要用于 token/rate-limit/cache/临时态;消息真相在 SQLite | WeKnora Redis/Asynq/Langfuse 由 WeKnora stack 管理 | Redis 归各自服务栈;不作为 MNote 权限/文件/知识库真相 |
## 3. 登录、OpenHub 隔离与 WeKnora 授权融合
### 3.1 核心更正
这里不能简单写成“只使用 MNote 单一用户认证,然后 OpenHub/WeKnora 都完全无用户”。更准确的模型是保留各自用户/权限机制中有价值的部分,并由 MNote 在边界上做最小嫁接:
```text
MNote 登录态 / user_id / workspace grants
├─ OpenHub 派生隔离上下文:openhub_user_key + workspace/runtime/session/skill/tool scope
└─ WeKnora 派生知识库上下文:已授权 workspace/rootUri -> provider tenant/profile/KB/source registry
```
- **MNote 负责入口认证与授权判断**:当前用户是谁、能访问哪些 workspace/rootUri、能读写哪些 source。
- **OpenHub 需要 per-user / per-workspace 隔离**session、message、skill、tool permission、opencode directory、changed files 都必须绑定 MNote 用户与 workspaceMCP/tool scope 由 MNote 注册的 WeKnora/opencode 工具配置体现;不能所有 MNote 用户共用一个 OpenHub runtime identity。
- **WeKnora 是唯一知识库底座,并通过 MCP / CLI / API 暴露给 OpenHub/opencode**:它只接收 MNote 已授权 workspace 的文件/文件夹集合 ingest/search/query 或工具调用;知识库可见性本身依赖 MNote 的 source registry 和 allowed rootsWeKnora 用户认证可以保持简单。
- **WeKnora RBAC/API key 是 provider 防线**:不承担 MNote 产品层用户隔离,不把 WeKnora tenant/user 反向暴露成 MNote 登录体系。
### 3.2 冲突
OpenHub 和 WeKnora 的用户模型对 MNote 的影响不同:
- OpenHub 前端会使用 `auth_token` / JWT / localStorage,并在 401 后跳转到 `/login`
- OpenHub 后端还有会话、消息、skill、tool/model permission、workspace path 和 Git snapshot 等用户相关状态;MCP 工具面由 MNote/WeKnora/opencode 配置承接。
- WeKnora 有 tenant RBAC、Owner/Admin/Contributor/Viewer、共享空间与 API Key,但 MNote 的知识库使用场景主要来自“用户已授权 workspace/rootUri”。
- MNote 已有 SQLite control-plane auth、`mnote_session` cookie、测试账号与 local workspace 授权。
如果直接嵌入 OpenHub 或 WeKnora Web UI,会出现三套登录入口、三套用户 id、三套权限判断;但如果把 OpenHub 也降成“无用户共享 runtime”,又会让 session、skill、工具审批、WeKnora tool scope 和文件变更串用户。
### 3.3 决策
- MNote 是唯一**前端登录入口**和产品层授权入口。
- OpenHub 不保留自己的 Login 页面、JWT/localStorage 登录跳转,但 MNote boundary 必须为每个 MNote 用户派生 OpenHub runtime identity。
- OpenHub 派生 identity 至少包含:`mnote_user_id``workspace_id``root_uri``openhub_user_key``opencode_session_scope``skill_scope``tool_permission_scope``weknora_tool_scope`
- OpenHub session/message/skill/tool permission 不得跨 `mnote_user_id + workspace_id/root_uri` 共享;MCP/tool 配置不得绕过 MNote 注入的 KB/source allowlist。
- WeKnora 使用 MNote 后端服务 API key 或受控 profile 调用;前端不直接持有 WeKnora API key。
- WeKnora KB/source 由 MNote 的 workspace grants、source registry、allowed roots 决定;WeKnora tenant/RBAC 只作为 provider 内部防线。
### 3.4 映射建议
短期本机/local-first
```text
MNote user_id + workspace_id/rootUri
-> OpenHub runtime identity: mnote:{user_id}:{workspace_id}:{root_hash}
-> OpenHub scopes:
session_scope = user_id + workspace_id + rootUri + page_resource_id
skill_scope = user_id + workspace_id + rootUri
tool_permission_scope = user_id + workspace_id + rootUri
weknora_tool_scope = user_id + workspace_id + allowed_kb_ids/source_ids
-> WeKnora provider profile: mnote-local 服务 API key
-> WeKnora KB: mnote-{workspace_id}-{purpose}
-> MNote source registry 记录 provider KB / knowledge / chunk 映射
```
中期多用户/局域网:
```text
MNote user_id + workspace membership
-> OpenHub runtime identity 按 user/workspace 派生或映射到受控 OpenHub user
-> MNote credential vault 选择 WeKnora service profile/API key
-> WeKnora 默认按 workspace/profile/KB 隔离,不强制每个 MNote 用户对应 WeKnora 用户
-> 所有 OpenHub 可见性仍由 MNote 登录态 + OpenHub scope 校验
-> 所有 WeKnora 结果仍由 MNote source registry / allowed roots 二次过滤
```
不要在第一阶段为每个 MNote 用户强行同步 WeKnora RBAC;这会放大生命周期与权限同步复杂度。相反,第一阶段应优先保证 OpenHub 派生上下文隔离,因为 Page AI 的 session、skill、tool permission、WeKnora tool scope 和文件变更都直接依赖 MNote 登录态。
## 4. OpenHub Session / Message 真相
### 4.1 核心更正
这里不应设计“三方 session 融合”,也不应新增一套 MNote `page_ai_messages` 作为 AI 面板消息真相。应保留 OpenHub 已做好的 session/message 能力,只做 MNote ownership binding
```text
MNote Page AI 面板
-> MNote 自有 Page AI 映射 OpenHub session/conversation
-> OpenHub session / conversation 是唯一 AI 面板会话真相
-> MNote control-plane 只保存绑定、索引和打开/权限映射
-> WeKnora 不参与 Page AI 会话真相
```
- **OpenHub session 是 Page AI session 真相**:消息、turn、tool call、skill/tool 状态、WeKnora tool call 状态、history、retry、visible/hidden 等语义以 OpenHub conversation/session 模型为准。
- **MNote 不复制消息主存储**:MNote 只需要保存 `mnote_user/workspace/rootUri/page_resource_id -> openhub_session_id/opencode_session_id` 的绑定,以及 changed file / citation 回跳所需的轻量索引。
- **WeKnora 不产生会话冲突**:当前 WeKnora 主要作为 MCP/CLI/知识库工具 provider`knowledge-chat` / `agent-chat` 暂时弃用,不纳入 Page AI 主链,因此不设计 `knowledge_session_id`
### 4.2 冲突
真正的冲突不是“三套消息历史融合”,而是:
- OpenHub session/message 本来就是 AI 面板的产品模型,MNote 自建 `page_ai_messages` 会变成第二份聊天真相。
- MNote 仍需要知道某个 OpenHub session 属于哪个 `mnote_user_id + workspace_id/rootUri + page_resource_id`,否则无法做跨浏览器恢复、权限过滤和打开文件回跳。
- WeKnora 的 agent-chat / knowledge-chat 若混入主链,会引入第二套 provider chat session;当前应明确弃用。
### 4.3 决策
MNote control-plane 只新增或复用**绑定/索引层**,不新增消息全文主表。表名可复用现有 `ai_external_conversation_bindings` 并扩展 metadata,不要求一定新建下列物理表:
```text
page_ai_openhub_bindings
id
mnote_user_id
workspace_id
root_uri
page_resource_id
page_absolute_path
openhub_user_key
openhub_session_id
opencode_session_id nullable
status = active | archived | stale
metadata_json
created_at / updated_at / archived_at
page_ai_artifact_index
id
binding_id
openhub_session_id
kind = changed_file | diff | citation | tool_call_ref | attachment_ref
provider = openhub | opencode | weknora
provider_id
payload_json
mnote_resource_id nullable
mnote_open_reference_json nullable
created_at
```
`page_ai_artifact_index` 不是消息真相,只是为了 MNote sidebar/document pane 能打开 changed files、diff、citation、attachment。消息正文、历史列表、tool card 展示、retry/隐藏状态仍从 OpenHub session/conversation 读取。
### 4.4 OpenHub session scope
OpenHub session 必须绑定 MNote 登录态派生的 scope
```text
openhub_session_scope = hash(mnote_user_id, workspace_id, root_uri, page_resource_id)
openhub_user_key = stable_hash(mnote_user_id)
openhub_workspace_key = stable_hash(workspace_id, root_uri)
```
- 同一用户同一页面可恢复最近 active OpenHub session。
- 切换 rootUri 或 workspace 必须新开 OpenHub session;旧 session 标记 stale 或 archived。
- 不同 MNote 用户不得共享同一个 OpenHub session、skill scope、tool permission scope 或 WeKnora tool scope。
- MNote boundary 对 OpenHub session 的读写必须先校验 `mnote_session` 与 binding ownership。
### 4.5 WeKnora session policy
第一阶段不使用 WeKnora `knowledge-chat` / `agent-chat` 作为 Page AI 会话层:
- WeKnora 通过 MCP/CLI/API 暴露知识库能力给 OpenHub/opencode 工具链。
- WeKnora 检索结果返回 chunk/referenceMNote 负责 source registry 映射和 citation 回跳。
- 如果未来启用 WeKnora agent-chat,只能作为 OpenHub tool call 的内部 provider call,不能成为 Page AI 历史会话真相。
## 5. Knowledge 融合
### 5.1 OpenHub 知识库定位
OpenHub `KnowledgeManager` 第一阶段不复用;其后端知识库也不应成为主线:
- 数据模型只有 base/source,没有持久 chunk/citation/embedding。
- 检索是 SQLite `LIKE` 候选 + BM25/TF-IDF 重排。
- 注入是 prompt stuffing,总上下文默认约 1200 字符。
适合:作为 OpenHub 源码理解和对照材料。
不适合:MNote 知识库主线、fallback 知识库、KnowledgeManager 页面复用、长期资料库、复杂 PDF/图片/OCR、可点击 citation、跨文档图谱、长期 RAG。
### 5.2 WeKnora 能力定位
WeKnora 应承担 MNote 知识库 provider
- 知识库类型:以 WeKnora `KnowledgeBase.Type` 和 FAQ 配置为准;Wiki/图谱属于 WeKnora Wiki mode / graph 能力,不能未经接口枚举直接当作 KB type 写死。
- 导入:文件、URL、Markdown/手工知识、外部数据源。
- 文档处理:chunk、OCR/VLM/ASR、图谱抽取、问题生成、reparse。
- 检索:多 KB / 指定 knowledge 检索优先对接 `POST /api/v1/knowledge-search`;单 KB 调试或 CLI/MCP 可走 `POST /api/v1/knowledge-bases/:id/hybrid-search`;返回分数按排序分处理,不能按百分比或原始相似度解释。
- 问答:`POST /api/v1/knowledge-chat/:session_id``POST /api/v1/agent-chat/:session_id` SSE 作为后续可选 provider 能力;第一阶段 Page AI 主链暂弃用,不产生主会话真相。
- 权限:tenant RBAC / shared organization 作内部防线。
MNote 侧不要把 WeKnora RBAC 当唯一隔离边界:WeKnora RBAC 可能受配置开关影响,关闭时 guard 可能记录但放行;MNote 必须始终按 `mnote_session + workspace/rootUri + source registry + allowed roots` 二次过滤。
### 5.3 MNote Knowledge Adapter / Search Replacement
保留 MNote 对外 canonical API
```text
/api/knowledge-rag/status
/api/knowledge-rag/ingest
/api/knowledge-rag/search
/api/knowledge-rag/query
/api/knowledge-rag/section-context
/api/knowledge-rag/open-reference
/api/knowledge-rag/delete-source
/api/knowledge-rag/prune-registry
```
但内部主链从 LightRAG 聚合检索切到 WeKnora。当前 `knowledge_rag.rs``/api/knowledge-rag/search``/api/knowledge-rag/query``/api/knowledge-rag/section-context` 仍围绕 LightRAG `/query/search``/query/data`、sidecar block、reference mapper、rank/dedupe 展开;替换时不能只改 endpoint,需要把 provider 调用、结果模型、locator 映射和排序语义一起替换。
目标 adapter
```rust
trait KnowledgeProvider {
fn status(root_uri, workspace_id) -> ProviderStatus;
fn ensure_kb(scope) -> ProviderKbRef;
fn ingest(source) -> ProviderKnowledgeRef;
fn search(query, scope, filters) -> Vec<ProviderReference>;
fn query(question, scope, filters) -> ProviderQueryResult;
fn section_context(provider_ref, query, scope) -> ProviderSectionContext;
fn open_reference(provider_ref) -> MnoteOpenReference;
fn delete_source(provider_ref) -> DeleteResult;
}
```
新增 `weknora` provider 实现;旧 LightRAG provider 标记 legacy,不再作为默认。第一阶段 `query` 可以由 WeKnora search results + citations 组成 answer envelope,不启用 WeKnora `knowledge-chat` / `agent-chat` 会话。
检索替换原则:
- `/api/knowledge-rag/search`:调用 WeKnora `/api/v1/knowledge-search` 或 KB 级 hybrid-search,返回 MNote `search_results.v1` 兼容结构。
- `/api/knowledge-rag/query`:不再调用 LightRAG `/query/data`;第一阶段用 WeKnora search result 生成带 citations/references 的 query result。
- `/api/knowledge-rag/section-context`:不再读 LightRAG sidecar blocks;改为基于 WeKnora chunk / source registry / 本地文件 locator 构造上下文。
- 排序与阈值:WeKnora score 是 provider 排序/融合分,不能沿用 LightRAG 相似度阈值、百分比相似度或旧 rank 解释。
- 引用映射:WeKnora `knowledge_id` / `chunk_id` / `knowledge_base_id` 只用于回查 registry,不能直接作为 MNote 文件路径。
WeKnora search result 到 MNote registry 的最低字段映射:
| WeKnora 字段 | MNote 派生字段 | 说明 |
|---|---|---|
| `id` | `providerChunkId` | WeKnora chunk id |
| `knowledge_id` | `providerKnowledgeId` | 回查 registry 的主键之一 |
| `knowledge_base_id` | `providerKnowledgeBaseId` | provider KB 映射;`knowledge-search` 与部分 CLI 输出字段覆盖不完全时从请求 scope/registry 补齐 |
| `content` / `matched_content` | `quote` / `matchedText` | citation 文本来源;FAQ/相似问命中优先保留 matched_content |
| `chunk_index` | `chunkIndex` | 可用于同一 knowledge 内排序/定位 |
| `start_at` / `end_at` | `providerOffsets` | 只能作为 provider 内偏移,不能直接当 Markdown 行号 |
| `knowledge_filename` | `providerDisplayName` | 只用于显示,不能当本地路径真相 |
| `knowledge_source` / `knowledge_channel` | `providerSourceMeta` | 用于辅助映射和审计 |
| `match_type` | `providerMatchType` | 区分 vector/keyword/hybrid/enrichment/direct 等命中渠道 |
| `parent_chunk_id` / `sub_chunk_id` | `providerChunkHierarchy` | 父子 chunk 与上下文扩展 |
| `metadata` / `chunk_metadata` / `image_info` | `providerMetadata` | 图片/VLM、FAQ、自定义元数据与定位诊断 |
`sourcePath``lineStart``lineEnd``mnoteResourceId``openReference` 必须由 MNote registry / locator 派生;WeKnora 未返回时不能伪造。
### 5.4 知识库定义、页面选择与 WeKnora Tool Bridge
这里不应设计 `OpenHub-compatible /knowledge/*`,也不应把 OpenHub `KnowledgeManager.jsx` 作为第一阶段知识库页。知识库页面建议复用 WeKnora 的 `KnowledgeBaseList.vue` / `KnowledgeBase.vue` / `KnowledgeBaseEditorModal.vue` / 上传与 processing timeline 组件体验,作为 MNote 知识库展示层的实现。当前边界是:
```text
MNote Knowledge UI(复用 WeKnora KB list/detail/upload/status 体验)
-> MNote source set / source registry / allowed roots
-> WeKnora ingest / search / status / open-reference
MNote Page AI / OpenHub-style session
-> opencode 按 skill/tool/MCP 规范自行决定 tool callMNote 只提供受限工具配置和 open-reference bridge
-> 已注册的 `mnote.weknora.*` MCP/CLI/API tool
-> WeKnora search/query
-> tool result 回 OpenHub session
```
决策:
- **知识库不是新的文件真相**:真相永远是 MNote 授权 rootUri 下的本地文件/文件夹/page resource;知识库是这些 source 的命名集合、索引状态和检索配置。
- **唯一知识库底座是 WeKnora**OpenHub 自带 knowledge tables、KnowledgeManager 页面和 prompt stuffing 知识库第一阶段全部不使用。
- **知识库页面选择 WeKnora 体验,但不直接接管 MNote shell**:第一阶段采用 `MNote shell + WeKnora KB adapter`。优先抽取或复刻 WeKnora `KnowledgeBaseList.vue``KnowledgeBase.vue``KnowledgeBaseEditorModal.vue``knowledge-processing-timeline.vue` 的交互和数据模型;不 iframe WeKnora 全量产品前端,不暴露 WeKnora 登录/租户切换/独立文件真相。
- **OpenHub 不管理知识库页面**OpenHub 只在对话过程中通过 MCP/CLI/API tool 调用 WeKnoratool result 进入 OpenHub session。
- **MNote tool facade 是注册与权限边界**OpenHub/opencode 不能直接持有 WeKnora API key,也不能绕过 MNote allowed roots / source registry;是否调用工具、如何组织 tool call 由 OpenHub/opencode 自己判断。
- **WeKnora MCP surface 第一阶段选择 Go CLI `weknora mcp serve` 或 MNote facade 包装后的等价只读工具面**:该 surface 当前是手工维护的只读工具集,更适合先挂给 OpenHub/opencode。Python `mcp-server` 暴露 create/delete/chunk mutation 等更宽能力,第一阶段只作为参考,不默认接入。
第一阶段需要的接口不是 OpenHub-compatible knowledge API,而是三类接口:
```text
MNote UI canonical API:
/api/knowledge-rag/status
/api/knowledge-rag/ingest
/api/knowledge-rag/search
/api/knowledge-rag/open-reference
/api/knowledge-rag/delete-source
/api/knowledge-rag/prune-registry
WeKnora-page-in-MNote adapter:
list_kbs / create_kb / update_kb / delete_kb
list_sources / add_source_set / upload_or_link_source / reparse_source / delete_source
get_processing_status / get_citation_open_reference
OpenHub/opencode tool facade:
mnote.weknora.search
mnote.weknora.open_reference
mnote.weknora.list_sources
mnote.weknora.get_source_status
```
WeKnora 页面 adapter 可以复用 WeKnora 前端组件/交互,但数据入口必须先经过 MNote source registry 与 allowed roots`mnote.weknora.*` 可以底层走 WeKnora MCP、CLI 或 HTTP API,但对 OpenHub 暴露的合同必须是 MNote 权限过滤后的 tool contract。
推荐页面复用方式:
1. **首选:MNote adapter 页面复刻 WeKnora 知识库体验**。在 MNote 前端保留统一路由、auth、workspace 和 FileTree/resource picker;后端通过 MNote canonical API 转接 WeKnora KB/doc/status/search。成本高于 iframe,但冲突最少。
2. **可选:抽取 WeKnora Vue 组件进入独立 adapter bundle**。仅抽知识库列表、详情、上传、状态时间线组件;替换其 auth/client/router/store,数据仍走 MNote 后端。
3. **不选:iframe WeKnora 全量前端**。会带来 WeKnora 登录、租户切换、独立上传文件真相和打开页面冲突,只能作为调试入口,不作为产品主线。
知识库最小数据模型应分成 `mnote_knowledge_bases``mnote_knowledge_sources` 两层:
```text
mnote_knowledge_bases
id
user_id
workspace_id
root_uri
name
description
provider = weknora
provider_kb_id
default_tool_enabled
metadata_json
created_at / updated_at / archived_at
```
`mnote_knowledge_bases` 是用户可见的知识库集合;`mnote_knowledge_sources` 是 source 到 provider knowledge/chunk 的映射。删除 source 只删除索引和映射,不删除本地原文件;删除 KB 默认只归档 MNote KB 与删除/禁用对应 WeKnora provider KB,不能批量删除 MNote 本地文件。
### 5.5 Source Registry provider-neutral 化
旧 LightRAG 字段应迁移为 provider-neutral registry;当前 JSON 文件 `lightrag-source-registry.json` 中的 `lightRagDocId/lightRagStatus/lightRagFilePath/symlinkPath` 等字段必须兼容读取并迁移或双写一段时间。目标结构为:
```text
mnote_knowledge_sources
id
user_id
workspace_id
root_uri
resource_id nullable
source_uri
source_path
source_hash
provider = weknora | lightrag_legacy | local_fallback
provider_kb_id
provider_knowledge_id
provider_doc_id nullable
provider_status
index_status = queued | parsing | indexed | failed | stale | deleted
title
source_type
tags_json
citation_locator_json
metadata_json
created_at / updated_at / deleted_at
```
关键原则:provider 返回的 `knowledge_filename` / `chunk_id` 不能直接当 MNote 文件真相,必须回查 registry 映射到 `resource_id/source_uri/open_reference`
## 6. 页面打开 / 文件打开 / 引用回跳融合
### 6.1 冲突
- MNote 自己就是 FileManagerresource tree / file tree / document pane / resource tab 是唯一页面/文件打开入口。
- OpenHub 第一阶段只借用多用户 AI 能力和 AI 页面产品参考,不使用 OpenHub FileManager 或完整前端。
- WeKnora 只暴露 MCP/CLI/API 知识工具,返回 knowledge/chunk/reference,不参与页面打开。
因此这里不需要做 OpenHub FileManager、WeKnora 全量 Web UI 或 provider path 的打开融合;只需要保证 MNote Page AI 回复中的 changed file、diff、citation、tool result 能映射回 MNote 页面。注意:这里的“不使用 WeKnora Web UI”只针对 Page AI 对话壳、引用打开链路和文件打开链路;知识库管理页面仍按 5.4 复用 WeKnora KB 页面体验,但必须运行在 MNote shell 与 MNote 权限/source registry 后面。
### 6.2 决策
所有打开动作只发生在 MNote Page AI / document pane 内:
- changed file chip 点击:`window.__mnoteDocumentPaneRuntime.openResourceInActiveTab()`
- 当前页被修改:`window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument()` 或 watcher 链路。
- WeKnora citation / MCP/CLI tool result 点击:`/api/knowledge-rag/open-reference` 返回 MNote locator,再由 MNote 前端打开。
- OpenHub session 中出现的 path/diff/tool reference 只作为数据来源,渲染和点击由 MNote Page AI 处理。
- 不接 OpenHub FileManager,不使用 OpenHub `/api/files` 作为页面读写入口。
- 不在 Page AI / 引用打开链路嵌入 WeKnora 全量 Web UI,不让 WeKnora 决定打开哪个 MNote 页面。
### 6.3 MNote Page AI open reference payload
```json
{
"provider": "weknora",
"providerKbId": "kb-...",
"providerKnowledgeId": "...",
"providerChunkId": "...",
"sourceId": "mnote-source-...",
"rootUri": "file:///...",
"sourcePath": "docs/a.md",
"locator": {
"kind": "markdown-range",
"heading": "...",
"lineStart": 12,
"lineEnd": 20,
"quote": "..."
}
}
```
后端必须二次校验:当前用户、rootUri、workspace、source ownership、allowed read scope。这个 payload 只服务 MNote Page AI 中的点击打开,不是 OpenHub FileManager 或 WeKnora 前端合同。
## 7. OpenHub FastAPI / Redis / opencode Runtime 安排
### 7.1 核心边界
这里不应让 MNote 重写 OpenHub FastAPI 的 opencode client。最小干扰路径是运行 OpenHub AI 后端栈,让 MNote 只做宿主、scope 注入、入口裁剪和回跳桥:
```text
MNote Page AI host
-> MNote 校验登录态、workspace、rootUri、allowed roots
-> 注入/映射 OpenHub user workspace + session/skill/tool scope
-> OpenHub AI UI
-> OpenHub FastAPI + SQLite session/message + Redis cache
-> OpenHub opencode client
-> opencode serve /global/event /session/{id}/prompt_async /diff
```
- **OpenHub FastAPI 继续运行**:负责 AI session/message、skill、tool permission、opencode client、event stream、diff/changed files 等 OpenHub 原生能力。
- **不要把待补能力写成已存在能力**:OpenHub 源码已确认有 session/message、skill、tool/model permission、SmartEntity、Git snapshot、FileManager、KnowledgeManager 和 opencode event/diff 链路;未确认独立 MCP 管理 API/UI。MCP 第一阶段应由 MNote 注册 WeKnora CLI/MCP/API tool 到 OpenHub/opencode,而不是假设 OpenHub 已有完整 MCP 管理面。
- **Redis 跟随 OpenHub stack**OpenHub Redis 主要用于 token/rate-limit/cache/临时态;OpenHub session/message 真相仍在 SQLite。MNote 不把 Redis 当自己的消息、权限、文件或知识库真相。
- **MNote 不重写 planner/client**MNote 不替 OpenHub 判断何时调用 WeKnora,也不重写 opencode prompt/event/diff 流程。
- **MNote 只裁剪不用入口**OpenHub Login、FileManager、KnowledgeManager、Admin 等页面不暴露;必要时在 proxy 层 404、隐藏菜单或跳转到 MNote 对应页面。
### 7.2 FastAPI 的具体作用
OpenHub FastAPI 在本设计中保留以下作用:
- 管理 OpenHub conversation/session/message/history。
- 管理 skill、agent/SmartEntity、tool/model permission。
- 调用 `opencode serve`,包括创建 session、发送 prompt、监听 `/global/event`、读取 diff。
- 向 OpenHub AI 前端提供消息流、tool card、diff、changed files、history 等 API。
- 读取由 MNote 注入的 workspace directory、user scope、allowed roots、WeKnora CLI/MCP/API tool 配置。
OpenHub FastAPI 不保留以下作用:
- 不作为 MNote 登录入口。
- 不接管 MNote resource tree / FileTree / document pane。
- 不启用 OpenHub 自带 KnowledgeManager 作为知识库 UI。
- 不启用 OpenHub 自带轻量 knowledge tables 作为 RAG 底座。
- 不执行 Git snapshot / restore / revert,除非未来另开设计并经 MNote 显式确认。
### 7.3 Redis 安排
- OpenHub 需要的 Redis/cache/临时态由 OpenHub deployment 管理,随 OpenHub FastAPI 启停和 health checkOpenHub `REDIS_HOST/REDIS_PORT/REDIS_DB` 应独立配置,避免误连 WeKnora Redis DB。
- WeKnora 有自己的 Redis/Asynq/Langfuse 相关依赖,由 WeKnora stack 管理;MNote 只检查 WeKnora health、必要 API 与 `weknora mcp serve`/CLI profile 是否可用。
- MNote control-plane 不依赖 Redis 保存登录授权、workspace grants、source registry、open-reference 或文件真相。
- Redis 中只允许放可重建状态:缓存、队列、临时流状态;不能成为用户权限、文件内容、知识库 source registry 或 Page AI 消息的唯一持久真相。
- 本机开发阶段允许共用一个 Redis 进程,但必须使用独立 DB/前缀:OpenHub、WeKnora、Langfuse/worker queue 不得混用 keyspace`dev-hot` health 输出应显示各自 Redis 连接目标。
### 7.4 Page AI context
MNote 注入给 OpenHub / opencode 的 context
```text
- 当前 MNote 用户/匿名显示名,不含敏感 cookie/token
- 当前 workspace/rootUri
- 当前页面标题、真实 Markdown path、resource id
- selection 摘要
- allowed roots / write constraints
- OpenHub session scope / skill scope / tool permission scope
- WeKnora tool scope:允许查询的 kb ids/source ids/citation policy
- MNote 文件打开/刷新 bridge usage
```
不要注入整篇正文;路径与 allowed roots 足够让 opencode 在本地读取文件。正文只在 selection 或用户明确需要时作为有限上下文传入。
## 8. UI 边界:嵌入 OpenHub AI 面板,保留 AI 原能力
### 8.1 Page AI Shell
MNote sidebar host 的职责应尽量薄:承载 OpenHub AI 面板、注入 MNote context、处理 MNote 回跳,不重新设计一套顶部/侧边栏产品结构。
- 顶部:第一阶段可以不要 MNote 自定义顶部;如需状态,只做极简 host 状态条或错误提示,避免覆盖 OpenHub AI 面板原有布局。
- 主体:OpenHub AI 面板,由 OpenHub 前端/后端处理消息、tool card、diff、history、agent/skill/tool 状态。
- 侧边:优先保留 OpenHub AI 相关侧边能力,包括历史 session、skill、agent/SmartEntity、tool/model permission 等设置;MCP 若当前 OpenHub 前端没有原生管理页,第一阶段显示 WeKnora MCP/CLI tool 连接状态与 MNote scope,而不是重写完整 MCP 管理器。
- MNote context:以 context pills / hidden bootstrap / postMessage / proxy header 方式注入当前 page path、rootUri、selection、allowed roots,不强行改 OpenHub UI 主结构。
- 回跳:changed file、citation、reference 点击时走 MNote bridge 打开页面。
### 8.2 OpenHub 前端裁剪策略
第一阶段不是“大面积禁用 OpenHub 前端”,而是**保留 AI 面板相关能力,只掐断与 MNote 真相冲突的入口**:
保留:
- AI chat 主界面。
- history / session 抽屉。
- skill / agent 设置。
- WeKnora CLI/MCP tool 的连接状态;若 OpenHub 无原生 MCP 设置 UI,则通过 MNote host 或 OpenHub 最小扩展显示,不阻塞 AI 主界面。
- tool/model permission UI。
- tool card、diff、changed files、运行日志等 AI 运行态 UI。
裁剪或转接:
- OpenHub Login:禁用,改由 MNote 登录态注入 OpenHub 派生用户。
- OpenHub workspace selector:禁用或固定为 MNote 授权 rootUri 派生 workspace。
- OpenHub FileManager:不作为文件真相;若 AI 面板内出现文件入口,转接到 MNote document pane / FileTree。
- OpenHub KnowledgeManager:不作为知识库 UI;如入口存在,转接到 MNote 知识库展示层或隐藏。
- OpenHub Admin / Team / Scheduler / SmartEntity:默认隐藏或不可达,除非后续明确纳入 Page AI 管理面。
- OpenHub Git snapshot / restore:默认关闭,避免改写 MNote local-first workspace 版本语义。
### 8.3 MNote 暴露给 OpenHub 的边界能力
MNote 不替 OpenHub 判断何时调用知识库、何时用 skill/tool/MCP 工具、如何组织 tool call;这些交给 OpenHub/opencode 已有 skill/tool/agent 规范处理,并由 MNote 注册的 WeKnora CLI/MCP/API tool 提供知识能力。MNote 只提供最小边界能力:
```text
- 当前页面 contextpage path / title / selection / rootUri / allowed roots
- OpenHub scopeuser/workspace/rootUri 派生的 session/skill/tool permission scope
- WeKnora CLI/MCP/API 配置:以 skill/tool/MCP 工具形式注册给 OpenHub/opencode
- open-reference bridge:把 provider citation/chunk/source 映射成 MNote 页面打开动作
- changed-file bridge:把 OpenHub/opencode 返回的 path 映射成 MNote document pane 打开/刷新
```
也就是说,MNote 是宿主、授权边界和回跳桥,不是 OpenHub agent 的 planner,也不是 OpenHub AI 面板的重写者。
## 9. 数据流:OpenHub 原生执行,MNote 注入边界
### 9.1 发送 Page AI 消息
```text
用户输入
-> MNote Page AI host 中的 OpenHub AI 面板
-> OpenHub 前端调用 OpenHub FastAPI
-> OpenHub FastAPI 使用 OpenHub session/message/skill/tool permission 与 MNote 注册的 WeKnora tool scope
-> OpenHub FastAPI 调 opencode serve
-> opencode 读写 MNote 授权 rootUri 内文件
-> OpenHub session/message/tool history 持久化
-> OpenHub AI UI 渲染回复、tool card、diff、changed files
-> MNote bridge 只处理文件打开、刷新、citation 回跳
```
MNote 不在消息主链里重写 OpenHub planner,也不解析知识需求后替 OpenHub 决定调用 WeKnora。MNote 只负责登录态、workspace 授权、scope 注入和结果回跳。
### 9.2 知识检索
```text
OpenHub/opencode 判断需要知识
-> 按 skill/tool/MCP 规范调用已注册的 WeKnora 工具
-> WeKnora MCP/CLI/API 返回 chunks/references
-> OpenHub/opencode 把结果纳入当前 session/tool result
-> 用户点击 citation/reference 时
-> MNote open-reference bridge 按 source registry / allowed roots 映射并打开对应页面
```
MNote 不负责替 OpenHub 判断 knowledge scopescope 在注册 WeKnora CLI/MCP/skill/tool 时由 MNote 根据当前用户、workspace、allowed roots 预先约束。
### 9.3 知识库生成与展示
```text
MNote 知识库展示层上传/添加资料
-> MNote canonical knowledge API
-> MNote 校验 user/workspace/rootUri/write permission
-> WeKnora file/manual/url ingest
-> 写 mnote_knowledge_sources registry
-> FileTree/Knowledge UI 显示 indexing 状态
-> 生成/更新可供 OpenHub/opencode 使用的 WeKnora CLI/MCP/skill/tool 配置
```
OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边界配置好的 WeKnora 工具。知识库生成页面复用 WeKnora KB 页面体验,但 source 选择应以 MNote 本地文件/文件夹集合为入口。
## 10. 可执行 Checklist
本节保留主线阶段清单;逐文件、逐接口、逐 smoke 的细化执行项见 `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md`。实施时先完成配套清单 P0/P1,再回填本文状态;不要把 P2/P3 优化提前混入 MVP。
### 10.1 设计与旧路径冻结
- [x]`7-65` 标注官方 opencode iframe 只保留为 fallback,不再作为 Page AI 产品主线。
- [x]`7-66` 标注自研 native UI 只保留为 fallback,不再继续扩自研聊天框。
- [x]`7-67` 标注已被本文覆盖:OpenHub 路线从“参考/重写”改为“嵌入 AI 面板 + 保留 FastAPI/Redis/opencode client”。
- [x] 将本文保留在 `design/07-ai/process/`,作为当前 Page AI 嵌入式集成主设计。
- [x] 在本轮授权写入范围内统一术语:WeKnora 是唯一知识库底座,MNote 是知识库展示和文件真相层,OpenHub KnowledgeManager 不作为知识库页;bugs/testing 文档未在本任务授权写入范围内修改。
- [x] 在本轮授权写入范围内搜索并标记仍把 LightRAG 描述为默认知识库 provider 的文案,改成 legacy/fallback。
#### 10.1 证据
- `design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md`:已冻结为官方 opencode WebUI iframe fallback;上下文口径从 LightRAG 引用改为 WeKnora 引用,旧 LightRAG 仅 legacy/fallback。
- `design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md`:已冻结为 native UI fallback / debug receipt,不再扩写自研聊天框。
- `design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md`:已标注被本文覆盖,路线改为嵌入 OpenHub AI 面板并保留 OpenHub FastAPI/Redis/opencode client。
- `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md`:保留在 `process/`,作为当前 Page AI 嵌入式集成主设计;10.2-10.9 后续执行细化到 `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md`
### 10.2 OpenHub 服务栈接入
- [ ] 确认 OpenHub 本机源码路径、启动命令、依赖文件和默认端口。
- [ ] 确认 OpenHub FastAPI Redis 的真实用途:token/rate-limit/cache/临时态;记录 Redis host/port/db/env 和启动顺序,避免写成消息真相或队列主链。
- [ ] 确认 OpenHub FastAPI 调 opencode 的配置项:opencode base URL、directory 参数、BasicAuth、模型/provider env。
- [ ] 确认 OpenHub 自动 Git snapshot 的触发点,并通过配置/补丁关闭 `stream.py``task_executor.py` 中的自动 snapshot 写链。
- [ ]`scripts/desktop-hot.js` 或等价 dev-hot 链路中增加 OpenHub FastAPI、Redis、opencode serve 的启动/跳过/health check。
- [ ] 增加 OpenHub health endpoint 探针;失败时错误信息区分 FastAPI、Redis、opencode。
- [ ] 禁用 OpenHub launcher 的 kill-port 或 destructive workspace 行为,避免影响 MNote dev 进程。
- [ ] 明确 OpenHub Git snapshot / restore / revert 默认关闭,并在启动环境或配置中落实。
- [ ] 写 smokeOpenHub FastAPI 可列 sessionRedis 可达,opencode serve 可达。
### 10.3 MNote 登录态到 OpenHub Scope
- [ ] 定义 `openhub_user_key = stable_hash(mnote_user_id)`
- [ ] 定义 `openhub_workspace_key = stable_hash(workspace_id, root_uri)`
- [ ] 定义 session scope`mnote_user_id + workspace_id + root_uri + page_resource_id`
- [ ] 在 MNote 后端实现或扩展 OpenHub host/proxy bootstrap endpoint,输出 user/workspace/session/tool scope。
- [ ] 禁止前端持有 OpenHub JWT/localStorage 登录真相;OpenHub 用户态由 MNote 后端注入或代理。
- [ ] rootUri / workspace 切换时新建或切换 OpenHub session,旧 session 标记 stale/archived。
- [ ] 不同 MNote 用户访问同一页面时不得复用同一 OpenHub session、skill scope、tool permission scope 或 WeKnora tool scope。
- [ ] 写 control-plane 测试:binding 受 user/workspace/rootUri 隔离,跨用户查询失败。
### 10.4 OpenHub AI 面板嵌入
- [ ] 在 MNote Page AI sidebar host 中选择 iframe 或 reverse proxy 嵌入方式。
- [ ] 只暴露 OpenHub AI 页面路由;Login/Admin/Team/Scheduler/SmartEntity 默认不可达。
- [ ] OpenHub workspace selector 固定到 MNote 授权 rootUri 派生 workspace。
- [ ] 保留 OpenHub AI 主界面、history/session、skill/agent 设置、tool/model permission、tool card、diff、changed files、运行日志;MCP 管理面如源码不存在,不把它作为已完成 OpenHub UI 依赖。
- [ ] FileManager 入口若出现在 AI 面板内,跳转 MNote document pane / FileTree 或禁用。
- [ ] KnowledgeManager 入口若出现在 AI 面板内,跳转 MNote WeKnora 知识库页或隐藏。
- [ ] MNote context 通过 postMessage、proxy header 或 bootstrap JSON 注入 page path、title、selection、rootUri、allowed roots。
- [ ] 写浏览器 smokePage AI 显示 OpenHub AI 面板,刷新后 session/history 仍可恢复。
- [ ] 写浏览器 smoke:访问 OpenHub Login/Admin/FileManager/KnowledgeManager 非 AI 入口不会接管 MNote。
### 10.5 OpenHub 原生 opencode 链路
- [ ] 保持 OpenHub FastAPI 调用 `opencode serve`MNote 不重写 prompt/event/diff client。
- [ ] 确认 OpenHub 创建 session 时 directory 固定为 MNote 授权 rootUri。
- [ ] 确认 OpenHub 发送 prompt 后可监听 `/global/event` 并渲染 tool card / assistant message。
- [ ] 确认 OpenHub 可读取 `/session/{id}/diff` 或等价 changed files。
- [ ] MNote 只读取 changed file / diff artifact 的 path 和 session id,用于 open/refresh。
- [ ] changed file 点击调用 `window.__mnoteDocumentPaneRuntime.openResourceInActiveTab()`
- [ ] 当前打开页面被修改后走 watcher 或 `refreshPrimaryDocument()` 刷新。
- [ ] 写真实 smoke:让 OpenHub AI 修改 rootUri 内 MarkdownMNote 当前页面可看到变更。
### 10.6 WeKnora 知识库页面替换
- [ ] 盘点 WeKnora `KnowledgeBaseList.vue``KnowledgeBase.vue``KnowledgeBaseEditorModal.vue``knowledge-processing-timeline.vue` 的依赖。
- [ ] 决定复用方式:优先 MNote adapter 页面复刻 WeKnora 交互;可选抽组件;不采用 iframe WeKnora 全量 frontend route 作为产品主线。
- [ ] 知识库定义为 MNote 授权文件/文件夹/page resource 集合,不创建新的文件内容真相。
- [ ] 建立 source set 模型:kb id、workspace id、rootUri、source path/resource id、provider kb id、provider knowledge id、source hash。
- [ ] source 选择 UI 接 MNote FileTree / resource picker,而不是 WeKnora 自己的独立文件真相。
- [ ] 入库时 MNote 先校验 allowed roots,再调用 WeKnora file/manual/url ingest。
- [ ] WeKnora processing / reparse / failed / indexed 状态同步到 MNote registry 与 FileTree 灯号。
- [ ] 删除 source 时只删除知识库索引和 registry 映射,不删除本地原文件。
- [ ] 写浏览器 smoke:创建 KB、添加本地文件夹、看到 indexing 状态、完成后可检索。
### 10.7 WeKnora 检索替换 LightRAG
- [ ] 抽出 `KnowledgeProvider` 或等价 provider boundary,新增 `weknora` 实现。
- [ ] `/api/knowledge-rag/status` 改为检查 WeKnora health、KB 映射、source registry 状态。
- [ ] `/api/knowledge-rag/ingest` 改为 WeKnora ingest,并写入 provider KB / knowledge / source hash。
- [ ] `/api/knowledge-rag/search` 改为 WeKnora `/api/v1/knowledge-search`;单 KB 调试/CLI/MCP 可走 `/api/v1/knowledge-bases/:id/hybrid-search`
- [ ] `/api/knowledge-rag/query` 第一阶段由 WeKnora search results + citations 组成 query result,不启用 WeKnora chat session。
- [ ] `/api/knowledge-rag/section-context` 改为基于 WeKnora chunk + MNote 本地 locator,不读 LightRAG sidecar。
- [ ] `open-reference` 从 WeKnora `knowledge_id/chunk_id/knowledge_base_id` 回查 MNote registry,再生成 MNote locator;保留 `content/matched_content/match_type/metadata/chunk_metadata/image_info/parent_chunk_id/sub_chunk_id` 供 citation 与诊断。
- [ ] WeKnora RRF score 不沿用 LightRAG 阈值;UI 只显示排序分或弱化分值解释。
- [ ] 保留旧 LightRAG provider 为 legacy fallback,但默认隐藏且不作为 smoke 基线。
- [ ] 更新现有 knowledge-rag smoke,把 provider 断言从 `lightrag` 改为 `weknora`
- [ ] 写单元测试:WeKnora SearchResult 映射为 MNote search result / citation / open-reference。
### 10.8 WeKnora MCP/CLI Tool Bridge
- [ ] 第一阶段 MCP surface 选定 Go CLI `weknora mcp serve` 或 MNote facade 包装后的等价只读工具面;Python `mcp-server` 只作为后续写能力参考,不默认暴露给 OpenHub/opencode。
- [ ] 默认暴露只读工具:`mnote.weknora.search``mnote.weknora.list_sources``mnote.weknora.get_source_status``mnote.weknora.open_reference`
- [ ] 写工具 manifest / skill,使 OpenHub/opencode 能在当前 session scope 内调用 WeKnora。
- [ ] tool 调用前注入 KB/source allowlist,不让 OpenHub/opencode 查询未授权 source。
- [ ] tool result 返回 provider ids、quote、chunk metadata、MNote open-reference token。
- [ ] citation 点击由 MNote bridge 打开,不由 OpenHub 或 WeKnora 决定本地路径。
- [ ] 写真实 smokeOpenHub AI 通过 MCP/CLI tool 查询 WeKnora,回答中出现可回跳 citation。
### 10.9 dev-hot 与真实验收
- [ ] `npm run dev:hot` 启动或检查 MNote、OpenHub FastAPI、Redis、opencode、WeKnora。
- [ ] 登录 `mnote.e2e@example.com`,确认没有 OpenHub/WeKnora 登录跳转。
- [ ] 打开真实 Markdown 页面并打开 Page AI。
- [ ] Page AI 嵌入 OpenHub AI 面板,history/session/skill/agent/tool permission 入口仍可用;WeKnora MCP/CLI tool 状态可见。
- [ ] OpenHub Login/Admin/FileManager/KnowledgeManager 非 AI 入口被隐藏、404 或跳转 MNote。
- [ ] 发送消息后 OpenHub session/message/history 持久化,刷新浏览器可恢复。
- [ ] 让 AI 修改当前 rootUri 内 MarkdownMNote document pane 可刷新并显示变更。
- [ ] 在 WeKnora 知识库页用本地文件/文件夹集合建库,完成 ingest/index。
- [ ] OpenHub AI 通过 WeKnora MCP/CLI/API tool 检索该 KB,并返回 citation。
- [ ] 点击 citation 打开 MNote 对应页面或资源 tab。
- [ ] 保存 smoke 输出和关键截图;失败时标明 OpenHub FastAPI、Redis、opencode、WeKnora、MNote bridge 中哪一层失败。
## 11. 取舍矩阵
| 冲突点 | 直接用 OpenHub | 直接用 WeKnora | MNote 边界方案 | 决策 |
|---|---|---|---|---|
| 登录 | 第二套 JWT/localStorage | 第二套 tenant login/API key | MNote cookie -> scope/binding | 选 MNote 登录入口 + OpenHub 派生 scope |
| 消息历史 | OpenHub conversation/session | WeKnora chat session 暂弃用 | MNote binding + artifact index | 选 OpenHub session 为真相,MNote 只做绑定/索引 |
| 知识库 UI | OpenHub KnowledgeManager 暂不使用 | WeKnora KB list/detail/upload/status 体验可复用 | MNote shell + WeKnora KB UI adapter | 选 WeKnora 知识库页面体验,嵌入 MNote 外壳 |
| 知识库底座 | OpenHub 自带知识库暂不用 | 强 RAG/Wiki/Graph | MNote registry + WeKnora | 选 WeKnora |
| 文件打开 | workspace path | knowledge filename | MNote resource/open-reference | 选 MNote |
| 权限 | 模型/工具/skillMCP 由工具配置承接 | tenant RBAC | MNote scope + OpenHub/opencode 权限规范 + provider 防线 | 组合,以 MNote scope 为边界 |
| Agent 编辑 | opencode | WeKnora agent-chat 暂弃用 | opencode 编辑 + WeKnora CLI/MCP/API 知识工具 | 组合 |
| UI 成本 | 嵌入 AI 面板,裁剪非 AI 入口 | 不适合 Page AI 编辑侧栏 | MNote host + OpenHub AI 面板 | 选 OpenHub AI 面板嵌入 |
| 运维 | OpenHub FastAPI + Redis + opencode 运行 | WeKnora 服务 | MNote 启动/检查 OpenHub/opencode/Redis/WeKnora,提供 binding/tool 配置 | 选 OpenHub 栈运行 + MNote 边界控制 |
## 12. 风险与防线
### 12.1 风险:三套权限漂移
防线:MNote 是浏览器入口和授权边界;OpenHub/opencode 只能拿到 MNote 派生 scope、allowed roots、tool/CLI/MCP 配置,不直接获得未过滤 workspace/rootUri。
补充:WeKnora RBAC 只能作为 provider 防线,不作为 MNote 授权来源;RBAC 关闭、API key 复用或共享空间变化时,MNote 过滤结果仍必须保持一致。
### 12.2 风险:WeKnora citation 无法定位到本地文件
防线:ingest 时必须写 `source_uri/source_hash/provider_knowledge_id`;检索返回后以 provider id 回查 registry,失败时 UI 标记“定位降级”,不能伪造路径。
### 12.3 风险:OpenHub UI 迁移成本变成 fork
防线:第一阶段嵌入 OpenHub AI 面板,尽量保持 OpenHub 已有 AI 功能不动;只对非 AI 入口通过隐藏、404、反代拦截或跳转 MNote 做最小裁剪。后期如需精简或替换组件,再单独做 UI 迁移设计。
### 12.4 风险:opencode 工作目录过大导致找不到文件
防线:工作目录固定为当前打开 rootUricontext 中传相对 path、allowed roots、当前页面 path;切换 rootUri 新开 session;不把大仓根或 recycle 目录作为默认工作目录。
### 12.5 风险:旧 LightRAG 残留误导
防线:UI 文案和配置改 provider-neutral;默认 provider 改 WeKnora;旧 LightRAG tools/manifest 标记 legacy 或隐藏。
### 12.6 风险:OpenHub 自动 Git snapshot 污染 workspace
防线:只参考 OpenHub AI 页面与 event/diff 解析思路;不迁移 Git snapshot / restore 后端路径。若以后需要版本恢复,必须走 MNote DocumentBuffer / watcher / 显式用户确认的版本策略。
### 12.7 风险:MCP/CLI 写权限边界不清
防线:WeKnora 有 Go CLI `weknora mcp serve` 与 Python `mcp-server` 两套 surface,读写能力不同。MNote 接入前必须指定采用哪一套、默认只读还是允许 create/delete,并把写操作纳入 MNote 权限审批。
## 13. 验收标准
MVP 通过必须同时满足:
- 浏览器中 Page AI 嵌入 OpenHub AI 面板,并能显示 OpenHub 原生消息/tool/diff/history。
- MNote 登录态是唯一登录入口;无 OpenHub/WeKnora 登录跳转。
- 同一 MNote 用户跨浏览器可恢复绑定到当前页面/rootUri 的 OpenHub session。
- opencode 在当前 rootUri 下能真实读取/修改 Markdown。
- changed file chips 与 DiffViewer path 能用 MNote 打开。
- MNote 知识库页面已由 `MNote shell + WeKnora KB adapter` 替换简陋页:可创建/列出/查看 KB,可从 MNote FileTree/resource picker 添加本地文件或文件夹,可显示 WeKnora processing/indexed/failed 状态。
- `/api/knowledge-rag/status/search/query/section-context/open-reference/delete-source` 默认 provider 为 `weknora`;旧 LightRAG 只作为 `lightrag_legacy` 隐藏 fallback,不再作为默认 smoke 基线。
- WeKnora search/MCP/CLI 工具返回真实引用,citation 经 registry 映射后可回跳 MNote 文件;失败时明确定位降级,且不能把 `knowledge_filename` 伪装成本地路径。
- OpenHub/opencode 通过 MNote 注册的 WeKnora tool scope 调用知识库;MNote 不替 OpenHub 做 planner 判断,只限制 KB/source allowlist 与 open-reference。
- `npm run dev:hot` 能拉起并检查必要 runtime;失败时错误页给出 OpenHub FastAPI、Redis、opencode、WeKnora 或 OpenHub session binding 哪个不可达。
- MNote Page AI 路径不触发 OpenHub 登录跳转;OpenHub FileManager/KnowledgeManager/Admin 等非 AI 入口被隐藏、404 或跳转 MNote;消息真相保留在受 MNote scope 隔离的 OpenHub session 中,不触发 Git snapshot/restore 自动写链。
## 14. 本轮源码依据
- OpenHub 源码:`/tmp/mnote-openhub-research/OpenHub`(当前无 `.codegraph/`,本轮以 targeted `find/rg/sed` 复核)
- WeKnora 本机部署:`/mnt/Data1T/Mnote_data/weknora/WeKnora`
- MNote Page AI runtime`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- MNote document pane bridge`rust/crates/mnote-web/browser/document-editor-adapter-runtime.js`
- MNote opencode route`rust/crates/mnote-web/src/routes/page_ai_opencode.rs`
- MNote knowledge route`rust/crates/mnote-web/src/routes/knowledge_rag.rs`
- MNote auth/session route`rust/crates/mnote-web/src/routes/session.rs``rust/crates/mnote-web/src/routes/gateway.rs`
- OpenHub FastAPI 入口:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/main.py`
- OpenHub opencode client / launcher`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/opencode_client.py``/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/opencode_launcher.py`
- OpenHub streaming / Git snapshot 触发:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/stream.py``/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/task_executor.py`
- OpenHub session/auth/files/knowledge/admin`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/api/session.py``auth.py``files.py``knowledge.py``admin.py`
- WeKnora SearchResult / hybrid-search client`/mnt/Data1T/Mnote_data/weknora/WeKnora/client/knowledgebase.go`
- WeKnora MCP CLI`/mnt/Data1T/Mnote_data/weknora/WeKnora/cli/internal/mcp/tools.go``/mnt/Data1T/Mnote_data/weknora/WeKnora/cli/cmd/mcp/serve.go`
- WeKnora KB UI`/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/`
@@ -1,4 +0,0 @@
#!/usr/bin/env node
"use strict";
require("./task779-openhub-file-edit-document-pane-refresh-smoke.js");
@@ -1,46 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const runtimePath = path.join(repoRoot, 'rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js');
const routePath = path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_openhub.rs');
const routesModPath = path.join(repoRoot, 'rust/crates/mnote-web/src/routes/mod.rs');
const runtime = fs.readFileSync(runtimePath, 'utf8');
const route = fs.readFileSync(routePath, 'utf8');
const routesMod = fs.readFileSync(routesModPath, 'utf8');
const checks = [
['openhub host enabled by default', runtime.includes('function pageAiOpenHubHostEnabled()') && runtime.includes('return true;') && runtime.includes('data-page-ai-openhub-host')],
['openhub bootstrap api', runtime.includes('/api/page-ai/openhub/bootstrap') && routesMod.includes('/api/page-ai/openhub/bootstrap')],
['mnote auth truth copy', route.includes('"authTruth": "mnote_session"') && runtime.includes('拒绝 OpenHub JWT/localStorage')],
['openhub user key derived', route.includes('"openhubUserKey"') && route.includes('stable_hash("openhub_user"')],
['workspace session tool scope', route.includes('"openhubSessionScope"') && route.includes('"skillScope"') && route.includes('"mcpScope"') && route.includes('"toolPermissionScope"')],
['proxy injects mnote scope headers', route.includes('add_mnote_scope_headers') && route.includes('x-mnote-user-key') && route.includes('x-mnote-user-id') && route.includes('x-mnote-display-name') && route.includes('x-mnote-workspace-key') && route.includes('x-mnote-session-scope') && route.includes('x-mnote-tool-permission-scope') && route.includes('x-mnote-weknora-tool-scope')],
['proxy carries full mnote scope internally', route.includes('mnoteScope') && route.includes('mnote_scope_from_query') && route.includes('proxy_query_without_internal_scope')],
['snake case bootstrap scope aliases', route.includes('"openhub_user_key"') && route.includes('"workspace_key"') && route.includes('"session_scope"') && route.includes('"tool_permission_scope"')],
['weknora tool scope', route.includes('"weknoraToolScope"') && route.includes('"weknora_tool_scope"') && runtime.includes('weknora_tool_scope')],
['layered status endpoint', route.includes('"openhub_fastapi"') && route.includes('"opencode"') && route.includes('"weknora"') && route.includes('"mnote_binding"')],
['degraded external services are explicit', route.includes('"degraded"') && route.includes('reachable') && route.includes('upstream_http_')],
['ai proxy boundary', route.includes('pub async fn ai_proxy') && routesMod.includes('/page-ai/openhub/ai/{*path}')],
['no visible opencode fallback action', !runtime.includes('openhub-use-opencode-fallback')],
['non ai route guard', route.includes('page_ai_openhub_non_ai_route_guarded') && routesMod.includes('/page-ai/openhub/knowledge/{*path}') && routesMod.includes('/page-ai/openhub/file/{*path}') && routesMod.includes('/page-ai/openhub/git/{*path}')],
['static ai shell', route.includes('data-mnote-openhub-ai-shell="static-boundary"') && routesMod.includes('/page-ai/openhub/ai')],
['openhub quick address actions are native openhub source not proxy overlay', !route.includes('data-mnote-openhub-ai-quick-actions') && !route.includes('data-mnote-openhub-send-current-tab') && !route.includes('data-mnote-openhub-send-current-folder')],
['openhub quick address bridge uses active tab only', runtime.includes("message.source === 'openhub-ai'") && runtime.includes("message.type === 'mnote:get-active-tab-address'") && runtime.includes('pageAiCurrentActiveTabEditorTarget') && runtime.includes("type: 'mnote:active-tab-address'")],
['openhub folder action sends folder address only', runtime.includes('pageAiCurrentActiveTabAddressPayload') && runtime.includes('folderUrl') && runtime.includes("kind === 'folder' ? addressPayload.folderUrl : addressPayload.tabUrl")],
['openhub diagnostics are hidden from user chrome', runtime.includes('wolai-page-ai-openhub-diagnostics') && runtime.includes('data-page-ai-openhub-bootstrap-copy hidden aria-hidden="true"')],
['no duplicate mnote openhub shell header', !runtime.includes('<h2 class="wolai-page-ai-title">OpenHub AI</h2>') && !runtime.includes('data-page-ai-openhub-runtime-status>OpenHub host boundary 静态占位')],
['no visible openhub legacy fallback switch', !runtime.includes("localStorage.setItem('mnote.page_ai.openhub_host', '0')")],
['openhub fallback payload has no legacy route', route.includes('"enabled": false') && !route.includes('"legacyRoute": "/page-ai/opencode"')],
];
const failed = checks.filter(([, ok]) => !ok);
if (failed.length) {
console.error('Page AI OpenHub host static smoke failed:');
for (const [name] of failed) console.error(`- ${name}`);
process.exit(1);
}
console.log('Page AI OpenHub host static smoke passed.');
@@ -1,98 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub';
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
const disableEnv = 'MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
const legacyDisableEnv = 'OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
function read(relativePath) {
return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8');
}
function assertCheck(name, passed) {
if (!passed) failures.push(name);
}
function includesAll(source, needles) {
return needles.every((needle) => source.includes(needle));
}
const failures = [];
const gitSnapshot = read('app/services/git_snapshot.py');
const stream = read('app/services/stream.py');
const taskExecutor = read('app/services/task_executor.py');
const session = read('app/api/session.py');
assertCheck(
'git_snapshot exposes MNote disable env and legacy equivalent',
includesAll(gitSnapshot, [disableEnv, legacyDisableEnv, 'def is_snapshot_restore_disabled'])
);
assertCheck(
'git_snapshot low-level git write command guard covers destructive/write commands',
includesAll(gitSnapshot, [
'_GIT_WRITE_COMMANDS',
'"init"',
'"config"',
'"add"',
'"commit"',
'"checkout"',
'"restore"',
'"reset"',
'"revert"',
'is_snapshot_restore_disabled() and _is_git_write(args)',
])
);
assertCheck(
'git_snapshot high-level write APIs short-circuit when disabled',
includesAll(gitSnapshot, [
'def init_git_repo',
'def create_snapshot',
'def create_restore_snapshot',
'def restore_all',
'def restore_single_file',
'disabled by {GIT_SNAPSHOT_RESTORE_DISABLE_ENV}',
])
);
assertCheck(
'stream automatic snapshot path is guarded before init/create_snapshot',
stream.includes('not git_snap.is_snapshot_restore_disabled()') &&
stream.includes('git_snap.init_git_repo') &&
stream.includes('git_snap.create_snapshot') &&
stream.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE')
);
assertCheck(
'task_executor automatic snapshot path is guarded before init/create_snapshot',
taskExecutor.includes('not git_snapshot.is_snapshot_restore_disabled()') &&
taskExecutor.includes('git_snapshot.init_git_repo') &&
taskExecutor.includes('git_snapshot.create_snapshot') &&
taskExecutor.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE')
);
const restoreRouteGuardCount = (
session.match(/git_snapshot\.is_snapshot_restore_disabled\(\)/g) || []
).length;
assertCheck(
'session restore routes reject when disabled',
restoreRouteGuardCount >= 2 &&
session.includes('Git snapshot/restore 写链已由 MNote 禁用') &&
session.includes('git_snapshot.restore_all') &&
session.includes('git_snapshot.restore_single_file') &&
session.includes('git_snapshot.create_restore_snapshot')
);
if (failures.length) {
console.error('OpenHub git snapshot/restore guard static smoke failed:');
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log('OpenHub git snapshot/restore guard static smoke passed.');
@@ -1,135 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub';
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
function read(relativePath) {
return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8');
}
function assertCheck(name, passed) {
if (!passed) failures.push(name);
}
function includesAll(source, needles) {
return needles.every((needle) => source.includes(needle));
}
const failures = [];
const mnoteScope = read('app/core/mnote_scope.py');
const auth = read('app/core/auth.py');
const query = read('app/api/query.py');
const session = read('app/api/session.py');
const stream = read('app/services/stream.py');
const requiredHeaders = [
'X-MNote-User-Key',
'X-MNote-Workspace-Key',
'X-MNote-Session-Scope',
'X-MNote-Root-Uri',
'X-MNote-Page-Resource-Id',
'X-MNote-Tool-Permission-Scope',
'X-MNote-WeKnora-Tool-Scope',
];
assertCheck(
'mnote scope helper exists with all controlled headers',
includesAll(mnoteScope, [
'MNOTE_HOST_TRUTH = "mnote_controlled_headers"',
'def derive_mnote_user',
'def resolve_user_workspace',
'def resolve_user_asset_workspace',
'def get_mnote_scope_metadata',
'X-MNote-Display-Name',
...requiredHeaders,
])
);
assertCheck(
'derived user is stable per MNote user and not tied to workspace scope',
includesAll(mnoteScope, [
'def _stable_openhub_user_id(user_key: str)',
'mnote_openhub_user_id',
'user_key',
'workspace_key',
'display_name',
'get_user_by_username',
'"openhub_user_id"',
'"openhub_username"',
]) &&
!mnoteScope.includes('def _stable_openhub_user_id(user_key: str, workspace_key: str)') &&
!mnoteScope.includes('_stable_openhub_user_id(user_key, workspace_key)') &&
!mnoteScope.includes('mnote_shared_user')
);
assertCheck(
'session and workspace scope are derived from MNote scope',
includesAll(mnoteScope, [
'def _stable_openhub_session_id',
'mnote_openhub_session',
'openhub_workspace_scope',
'session_scope',
'root_uri',
'workspace_path',
'_root_uri_to_workspace_path',
])
);
assertCheck(
'tool and weknora scopes are retained as MNote scope metadata',
includesAll(mnoteScope, [
'tool_permission_scope',
'weknora_tool_scope',
'provisioned_workspace_path',
'_parse_scope_header',
'"mnote_scope"',
'"source_headers"',
])
);
assertCheck(
'MNote host mode rejects frontend JWT/localStorage truth',
includesAll(mnoteScope, [
'REJECTED_MNOTE_HOST_TRUTHS',
'localStorage',
'OpenHub JWT',
'frontend JWT',
]) &&
auth.includes('derive_mnote_user(request)') &&
auth.includes('HTTPBearer(auto_error=False)') &&
auth.indexOf('derive_mnote_user(request)') < auth.indexOf('validate_token(token)')
);
assertCheck(
'query/session entries consume derived workspace and scope',
query.includes('resolve_user_workspace(current_user)') &&
query.includes('not current_user.get("mnote_host_mode")') &&
query.includes('mnote_scope = get_mnote_scope_metadata(current_user)') &&
session.includes('resolve_user_workspace(current_user)') &&
session.includes('mnote_scope=get_mnote_scope_metadata(current_user)')
);
assertCheck(
'stream persists MNote scope metadata with user message',
includesAll(stream, [
'mnote_scope: Optional[dict] = None',
'metadata["mnote_scope"] = mnote_scope',
'database.save_session',
'user_id',
'workspace_path',
'not mnote_scope',
])
);
if (failures.length) {
console.error('OpenHub MNote scope bridge static smoke failed:');
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log('OpenHub MNote scope bridge static smoke passed.');
@@ -1,553 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
getViewerIdentity,
} = require("./tree-shell-smoke-helpers");
const TASK = "task773-page-ai-openhub-browser-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser.png");
const RESULT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser-result.json");
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"]
.find((candidate) => fs.existsSync(candidate));
class SmokeFailure extends Error {
constructor(kind, message, details = {}) {
super(message);
this.name = "SmokeFailure";
this.kind = kind;
this.details = details;
}
}
function visibleSelectorScript(selectors) {
return selectors.some((selector) => {
const nodes = Array.from(document.querySelectorAll(selector));
return nodes.some((node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
});
});
}
async function assertServiceReachable(baseUrl) {
let response;
try {
response = await fetch(`${baseUrl}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) });
} catch (error) {
throw new SmokeFailure("service_unreachable", `MNote 3000 服务不可达:${error.message}`, { baseUrl });
}
if (!response.ok && response.status !== 303) {
throw new SmokeFailure("service_unreachable", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl });
}
}
async function loginWithUiFirst(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
if (!page.url().includes("/auth")) {
return getViewerIdentity(requestContext);
}
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLogin.isVisible({ timeout: 8_000 }).catch(() => false)) {
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
}
if (page.url().includes("/auth")) {
const account = page.locator('input[name="account"], input[type="email"], input[data-auth-field="account"]').first();
const password = page.locator('input[name="password"], input[type="password"]').first();
const submit = page.getByRole("button", { name: /^登录$|账号登录|登录$/ }).first();
if (!(await account.isVisible({ timeout: 2_000 }).catch(() => false)) || !(await password.isVisible({ timeout: 2_000 }).catch(() => false))) {
throw new SmokeFailure("auth_failed", "认证页未出现快速登录,也找不到账号密码输入框", { url: page.url() });
}
await account.fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
await password.fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS });
await submit.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
}
if (page.url().includes("/auth")) {
throw new SmokeFailure("auth_failed", "测试账号登录后仍停留在 /auth", { url: page.url() });
}
try {
return await getViewerIdentity(requestContext);
} catch (error) {
throw new SmokeFailure("auth_failed", `登录后 whoami 仍不可用:${error.message}`, { url: page.url() });
}
}
async function waitForVisibleAny(page, selectors, label) {
try {
await page.waitForFunction(visibleSelectorScript, selectors, { timeout: UI_TIMEOUT_MS });
} catch (error) {
throw new SmokeFailure("selector_missing", `${label} 不可见。候选 selector: ${selectors.join(", ")}`, {
selectors,
cause: error.message,
});
}
}
async function openPageAiDrawer(page) {
await page.goto(BASE_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
throw new SmokeFailure("auth_failed", "打开主页后被重定向到 /auth,登录态未生效", { url: page.url() });
}
await page.evaluate(() => {
try {
localStorage.removeItem("mnote.page_ai.openhub_host");
localStorage.setItem("mnote.page_ai.openhub_host", "1");
} catch {}
});
await waitForVisibleAny(page, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "Page AI 入口");
const drawerVisible = await page.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false);
if (!drawerVisible) {
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
}
await waitForVisibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI drawer");
}
async function validateOpenHubQuickActions(page) {
const frameHandle = await page.locator("iframe[data-page-ai-openhub-iframe]").elementHandle({ timeout: UI_TIMEOUT_MS });
const frame = frameHandle ? await frameHandle.contentFrame() : null;
if (!frame) {
throw new SmokeFailure("quick_action_failed", "OpenHub iframe frame 不可用", { reason: "iframe_frame_missing" });
}
const tabButton = frame.locator("[data-mnote-openhub-current-tab-toggle]").first();
const folderButton = frame.locator("[data-mnote-openhub-current-folder-toggle]").first();
await tabButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await folderButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const fillVisibleInput = async (value) => frame.evaluate((nextValue) => {
const selectors = [
"textarea[placeholder*='输入']",
"textarea",
"[contenteditable='true']",
"input[type='text'][placeholder*='输入']",
"input[type='text']",
".ant-input",
];
const visible = (node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
for (const selector of selectors) {
const nodes = Array.from(document.querySelectorAll(selector)).filter(visible);
if (!nodes.length) continue;
const node = nodes[nodes.length - 1];
node.focus();
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), "value");
if (descriptor && typeof descriptor.set === "function") {
descriptor.set.call(node, nextValue);
} else {
node.value = nextValue;
}
} else {
node.textContent = nextValue;
}
node.dispatchEvent(new InputEvent("input", { bubbles: true, data: nextValue, inputType: "insertText" }));
node.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
return false;
}, value);
const readVisibleInput = async () => frame.evaluate(() => {
const selectors = [
"textarea[placeholder*='输入']",
"textarea",
"[contenteditable='true']",
"input[type='text'][placeholder*='输入']",
"input[type='text']",
".ant-input",
];
const visible = (node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
for (const selector of selectors) {
const nodes = Array.from(document.querySelectorAll(selector)).filter(visible);
if (!nodes.length) continue;
const node = nodes[nodes.length - 1];
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) return node.value || "";
return node.textContent || "";
}
return "";
});
const hasInput = await fillVisibleInput("");
if (!hasInput) {
throw new SmokeFailure("quick_action_failed", "OpenHub iframe 内未找到可见聊天输入框", {});
}
const prompt = `MNOTE_NATIVE_CONTEXT_SMOKE_${Date.now()}`;
let capturedBody = null;
await page.route("**/page-ai/openhub/ai/api/query/stream**", async (route) => {
capturedBody = JSON.parse(route.request().postData() || "{}");
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: [
`data: ${JSON.stringify({ type: "session", conversation_id: "task773-native-context", done: false })}`,
"",
`data: ${JSON.stringify({ type: "message_complete", done: true })}`,
"",
].join("\n"),
});
});
await fillVisibleInput(prompt);
await tabButton.click({ timeout: UI_TIMEOUT_MS });
const inputTextAfterToggle = (await readVisibleInput()).trim();
const tabSelected = await tabButton.evaluate((node) => node.classList.contains("ant-btn-primary") || node.getAttribute("type") === "button" && node.matches(".ant-btn-primary"));
const folderSelectedAfterTab = await folderButton.evaluate((node) => node.classList.contains("ant-btn-primary"));
await page.keyboard.press("Enter");
await page.waitForFunction(() => window.__mnoteOpenHubTask773RequestCaptured === true, undefined, { timeout: 100 }).catch(() => undefined);
const started = Date.now();
while (!capturedBody && Date.now() - started < UI_TIMEOUT_MS) {
await page.waitForTimeout(100);
}
const activeEditor = await page.evaluate(() => window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === "function"
? window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot()?.activeEditor || null
: null);
const result = {
ok: Boolean(capturedBody && capturedBody.mnote_context && capturedBody.mnote_context.value),
tabButtonVisible: await tabButton.isVisible().catch(() => false),
folderButtonVisible: await folderButton.isVisible().catch(() => false),
tabSelected,
folderSelectedAfterTab,
inputTextAfterToggle,
requestQuestion: capturedBody && capturedBody.question,
mnoteContext: capturedBody && capturedBody.mnote_context,
activeEditor,
};
if (!result.ok) {
throw new SmokeFailure("quick_action_failed", "OpenHub 当前 Tab/文件夹上下文未随发送请求进入后台", result);
}
if (!result.tabSelected || result.folderSelectedAfterTab) {
throw new SmokeFailure("quick_action_selection_invalid", "当前 Tab 按钮没有呈现单选选中态", result);
}
if (result.inputTextAfterToggle !== prompt) {
throw new SmokeFailure("quick_action_leaked_to_input", "当前 Tab/文件夹地址不应直接写入用户可见输入框", result);
}
if (result.mnoteContext.kind !== "tab" || !/^https?:\/\/.+\/documents\//.test(result.mnoteContext.value)) {
throw new SmokeFailure("quick_action_tab_context_invalid", "当前 Tab 发送上下文不是 MNote 文档地址", result);
}
return result;
}
async function collectState(page) {
return page.evaluate(() => {
const visible = (node) => {
if (!node || node.nodeType !== 1) return false;
const ownerWindow = node.ownerDocument && node.ownerDocument.defaultView ? node.ownerDocument.defaultView : window;
const style = ownerWindow.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
const rectOf = (node) => {
if (!node || node.nodeType !== 1 || typeof node.getBoundingClientRect !== "function") return null;
const rect = node.getBoundingClientRect();
return {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
top: Math.round(rect.top),
bottom: Math.round(rect.bottom),
};
};
const visibleElements = (selector) => Array.from(document.querySelectorAll(selector)).filter(visible);
const visibleAny = (selector) => visibleElements(selector).length > 0;
const text = (selector) => (document.querySelector(selector)?.textContent || "").trim();
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
const iframeDoc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null;
const iframeBodyText = (iframeDoc && iframeDoc.body ? iframeDoc.body.textContent || "" : "").trim();
const iframeShellKind = iframeDoc && iframeDoc.body ? iframeDoc.body.getAttribute("data-mnote-openhub-ai-shell") || "" : "";
const drawer = Array.from(document.querySelectorAll("[data-testid='wolai-page-ai-drawer']")).find(visible) || null;
const openhubHost = document.querySelector("[data-page-ai-openhub-host='true']");
const outerOpenHubHeaderVisible = visibleAny(".wolai-page-ai-opencode-header")
|| visibleAny(".wolai-page-ai-header-copy")
|| visibleAny("[data-page-ai-openhub-runtime-status]");
const diagnostics = document.querySelector("[data-page-ai-openhub-bootstrap-copy]");
const diagnosticsVisible = Boolean(diagnostics && visible(diagnostics));
const diagnosticsRect = diagnosticsVisible ? rectOf(diagnostics) : null;
const diagnosticsOpen = diagnostics instanceof HTMLDetailsElement ? diagnostics.open : false;
const debugChromeRects = diagnosticsVisible ? [{
selector: "[data-page-ai-openhub-bootstrap-copy]",
text: (diagnostics.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160),
rect: diagnosticsRect,
open: diagnosticsOpen,
}] : [];
const debugChromeTotalHeight = diagnosticsRect?.height || 0;
const fallbackActionSelector = "[data-page-ai-action='openhub-use-opencode-fallback']";
const fallbackActionVisibleInIframe = iframeDoc
? Array.from(iframeDoc.querySelectorAll(fallbackActionSelector)).some(visible)
: false;
const pageText = (document.body.textContent || "").replace(/\s+/g, " ").trim();
const loginTextPattern = /(登录\s*OpenHub|OpenHub\s*Login|WeKnora\s*登录|登录\s*WeKnora|Sign in to OpenHub|OpenHub account|WeKnora account)/i;
const reactSelectorMarkers = [
"[data-openhub-ai-panel]",
"[data-testid='openhub-ai-panel']",
"[data-testid='openhub-chat']",
"[data-openhub-conversation]",
"[data-openhub-session-history]",
".openhub-ai-panel",
".chat-message-list",
".ant-layout",
".ant-menu",
".ant-input",
].filter((selector) => Array.from(document.querySelectorAll(selector)).some(visible)
|| (iframeDoc && Array.from(iframeDoc.querySelectorAll(selector)).some(visible)));
const reactTextMarkers = [
"OpenHub 平台",
"开始对话",
"历史记录",
"技能管理",
"选择模型",
].filter((marker) => iframeBodyText.includes(marker));
const conflictEntryPattern = /(文件管理|知识库|时光机|智能体|协作任务|团队状态)/;
const quickActionTab = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-tab-toggle]") : null;
const quickActionFolder = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-folder-toggle]") : null;
const smokeInput = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-smoke-input]") : null;
return {
url: location.href,
title: document.title,
bodySnippet: pageText.slice(0, 800),
drawerVisible: Boolean(drawer),
drawerRect: rectOf(drawer),
openhubHost: Boolean(openhubHost),
openhubHostRect: rectOf(openhubHost),
outerOpenHubHeaderVisible,
debugChromeVisible: debugChromeRects.length > 0,
debugChromeRects,
debugChromeTotalHeight,
debugChromeSqueezesContent: diagnosticsOpen || debugChromeTotalHeight > 80,
hostChromeVisible: Boolean(Array.from(document.querySelectorAll("[data-page-ai-openhub-bootstrap-copy]")).find(visible)),
iframeVisible: iframe instanceof HTMLIFrameElement && visible(iframe),
iframeRect: rectOf(iframe),
iframeSrc: iframe instanceof HTMLIFrameElement ? iframe.getAttribute("src") || "" : "",
iframeBodySnippet: iframeBodyText.slice(0, 800),
iframeShellKind,
runtimeStatus: text("[data-page-ai-openhub-runtime-status]"),
authTruth: text("[data-page-ai-openhub-auth-truth]"),
workspaceScope: text("[data-page-ai-openhub-workspace-scope]"),
routeGuard: text("[data-page-ai-openhub-route-guard]"),
fallback: text("[data-page-ai-openhub-fallback]"),
loginPageVisible: loginTextPattern.test(pageText) || loginTextPattern.test(iframeBodyText),
staticBoundaryVisible: iframeShellKind === "static-boundary" || /静态占位|static-boundary|最小 host\/bootstrap 占位/.test(iframeBodyText),
fallbackActionVisible: visibleAny(fallbackActionSelector) || fallbackActionVisibleInIframe,
fallbackActionCount: document.querySelectorAll(fallbackActionSelector).length
+ (iframeDoc ? iframeDoc.querySelectorAll(fallbackActionSelector).length : 0),
reactAiMarkers: [...reactSelectorMarkers, ...reactTextMarkers.map((marker) => `text:${marker}`)],
reactTextMarkers,
conflictEntryVisible: conflictEntryPattern.test(iframeBodyText),
quickActionTabVisible: Boolean(quickActionTab && visible(quickActionTab)),
quickActionFolderVisible: Boolean(quickActionFolder && visible(quickActionFolder)),
quickActionTabText: quickActionTab ? (quickActionTab.textContent || "").trim() : "",
quickActionFolderText: quickActionFolder ? (quickActionFolder.textContent || "").trim() : "",
quickActionTabLabel: quickActionTab ? quickActionTab.getAttribute("aria-label") || "" : "",
quickActionFolderLabel: quickActionFolder ? quickActionFolder.getAttribute("aria-label") || "" : "",
quickActionTabSelected: quickActionTab ? quickActionTab.classList.contains("ant-btn-primary") : false,
quickActionFolderSelected: quickActionFolder ? quickActionFolder.classList.contains("ant-btn-primary") : false,
quickActionSmokeInputValue: smokeInput instanceof HTMLTextAreaElement ? smokeInput.value : "",
};
});
}
function assertOpenHubState(state) {
if (!state.drawerVisible) {
throw new SmokeFailure("selector_missing", "Page AI drawer 未保持可见", state);
}
if (!state.openhubHost) {
throw new SmokeFailure("selector_missing", "Page AI drawer 未切到 OpenHub host", state);
}
if (!state.iframeVisible || !state.iframeSrc.includes("/page-ai/openhub/ai")) {
throw new SmokeFailure("selector_missing", "OpenHub iframe 不可见或 src 未指向 /page-ai/openhub/ai", state);
}
if (state.outerOpenHubHeaderVisible) {
throw new SmokeFailure("outer_header_visible", "OpenHub drawer 仍显示 MNote 外层 OpenHub AI 标题栏", state);
}
if (!state.iframeRect || state.iframeRect.height < 420) {
throw new SmokeFailure("iframe_too_short", "OpenHub iframe 高度不足,可能被顶部 debug/status 区挤压", state);
}
if (state.debugChromeSqueezesContent) {
throw new SmokeFailure("debug_chrome_visible", "OpenHub 顶部 debug/status chrome 仍可见且挤占空间", state);
}
if (state.fallbackActionVisible || state.fallbackActionCount > 0) {
throw new SmokeFailure("legacy_fallback_visible", "OpenHub drawer 仍存在用户可见 opencode fallback 入口", state);
}
if (state.loginPageVisible) {
throw new SmokeFailure("unexpected_upstream_login", "页面出现 OpenHub/WeKnora 登录入口", state);
}
if (!state.iframeBodySnippet && !state.reactAiMarkers.length) {
throw new SmokeFailure("selector_missing", "OpenHub iframe 已出现,但 iframe 内 shell 内容不可见", state);
}
if (state.conflictEntryVisible) {
throw new SmokeFailure("unexpected_conflict_entry", "OpenHub iframe 嵌入态仍显示 MNote 真相冲突入口", state);
}
if (!state.quickActionTabVisible || !state.quickActionFolderVisible) {
throw new SmokeFailure("quick_action_missing", "OpenHub iframe 内缺少当前 Tab/文件夹快捷按钮", state);
}
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const consoleMessages = [];
let browser;
let context;
let page;
let result;
let directHostRoute = null;
let legacyOpencodeRoute = null;
const networkEvents = [];
try {
await assertServiceReachable(BASE_URL);
browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
page = await context.newPage();
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
consoleMessages.push({ type: message.type(), text: message.text() });
}
});
page.on("response", (response) => {
const url = response.url();
if (url.includes("/page-ai/openhub")) {
networkEvents.push({ url, status: response.status(), contentType: response.headers()["content-type"] || "" });
}
});
const viewer = await loginWithUiFirst(page, context.request).catch(async (error) => {
if (error instanceof SmokeFailure) throw error;
await ensureAuthenticated(page, context.request);
return getViewerIdentity(context.request);
});
legacyOpencodeRoute = await context.request.get(`${BASE_URL}/page-ai/opencode`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({
url: `${BASE_URL}/page-ai/opencode`,
status: response.status(),
ok: response.ok(),
contentType: response.headers()["content-type"] || "",
bodySnippet: (await response.text()).slice(0, 240),
})).catch((error) => ({
url: `${BASE_URL}/page-ai/opencode`,
status: 0,
ok: false,
error: error.message,
}));
if (legacyOpencodeRoute.status !== 410) {
throw new SmokeFailure("legacy_fallback_route_enabled", "登录后 /page-ai/opencode legacy fallback 页面仍可访问", legacyOpencodeRoute);
}
directHostRoute = await context.request.get(`${BASE_URL}/page-ai/openhub/ai`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({
url: `${BASE_URL}/page-ai/openhub/ai`,
status: response.status(),
ok: response.ok(),
contentType: response.headers()["content-type"] || "",
bodySnippet: (await response.text()).slice(0, 240),
})).catch((error) => ({
url: `${BASE_URL}/page-ai/openhub/ai`,
status: 0,
ok: false,
error: error.message,
}));
await openPageAiDrawer(page);
await waitForVisibleAny(
page,
[
"[data-page-ai-openhub-host='true']",
"[data-page-ai-openhub-bootstrap-copy]",
"iframe[data-page-ai-openhub-iframe]",
],
"OpenHub host",
);
await page.waitForFunction(() => {
const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]");
return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("/page-ai/openhub/ai");
}, undefined, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]");
return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("mnoteScope=");
}, undefined, { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.waitForTimeout(800);
await page.waitForFunction(() => {
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
const doc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null;
return Boolean(doc && doc.querySelector("[data-mnote-openhub-current-tab-toggle]") && doc.querySelector("[data-mnote-openhub-current-folder-toggle]"));
}, undefined, { timeout: UI_TIMEOUT_MS });
const quickActions = await validateOpenHubQuickActions(page);
const state = await collectState(page);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
assertOpenHubState(state);
const openhubReactConnected = state.reactAiMarkers.length > 0;
result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
viewer,
screenshot: SCREENSHOT_PATH,
directHostRoute,
legacyOpencodeRoute,
quickActions,
state,
openhubReactConnected,
staticBoundaryOnly: state.staticBoundaryVisible && !openhubReactConnected,
reactAiNote: openhubReactConnected
? "OpenHub React UI marker 已出现"
: "未发现 OpenHub React AI marker;本次只验证 MNote OpenHub host drawer/iframe/shell 边界,不把静态 shell 记为 React AI 已接入",
networkEvents,
consoleMessages,
};
} catch (error) {
const state = page ? await collectState(page).catch(() => null) : null;
if (page) {
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
}
result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
screenshot: fs.existsSync(SCREENSHOT_PATH) ? SCREENSHOT_PATH : null,
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
error: error instanceof Error ? error.stack || error.message : String(error),
errorDetails: error instanceof SmokeFailure ? error.details : null,
directHostRoute,
legacyOpencodeRoute,
state,
networkEvents,
consoleMessages,
};
} finally {
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
if (context) await context.close().catch(() => undefined);
if (browser) await browser.close().catch(() => undefined);
}
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,294 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
getViewerIdentity,
} = require("./tree-shell-smoke-helpers");
const TASK = "task774-openhub-mnote-send-and-file-edit-e2e";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
const RESULT_PATH = path.join(OUTPUT_DIR, "openhub-send-smoke-result.json");
const WORKSPACE_ROOT = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = `file://${WORKSPACE_ROOT}`;
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
const ENABLE_FILE_EDIT = process.env.MNOTE_OPENHUB_FILE_EDIT === "1";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"]
.find((candidate) => fs.existsSync(candidate));
class SmokeFailure extends Error {
constructor(kind, message, details = {}) {
super(message);
this.name = "SmokeFailure";
this.kind = kind;
this.details = details;
}
}
async function assertServiceReachable(baseUrl) {
let response;
try {
response = await fetch(`${baseUrl}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) });
} catch (error) {
throw new SmokeFailure("service_unreachable", `MNote 3000 服务不可达:${error.message}`, { baseUrl });
}
if (!response.ok && response.status !== 303) {
throw new SmokeFailure("service_unreachable", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl });
}
}
async function loginWithUiFirst(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
if (!page.url().includes("/auth")) {
return getViewerIdentity(requestContext);
}
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLogin.isVisible({ timeout: 8_000 }).catch(() => false)) {
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
}
if (page.url().includes("/auth")) {
const account = page.locator('input[name="account"], input[type="email"], input[data-auth-field="account"]').first();
const password = page.locator('input[name="password"], input[type="password"]').first();
const submit = page.getByRole("button", { name: /^登录$|账号登录|登录$/ }).first();
if (!(await account.isVisible({ timeout: 2_000 }).catch(() => false)) || !(await password.isVisible({ timeout: 2_000 }).catch(() => false))) {
throw new SmokeFailure("auth_failed", "认证页未出现快速登录,也找不到账号密码输入框", { url: page.url() });
}
await account.fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
await password.fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS });
await submit.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
}
if (page.url().includes("/auth")) {
throw new SmokeFailure("auth_failed", "测试账号登录后仍停留在 /auth", { url: page.url() });
}
return getViewerIdentity(requestContext);
}
function mnoteScopeQuery(openhubIframeUrl) {
const queryStart = openhubIframeUrl.indexOf("?");
if (queryStart < 0) return "";
return openhubIframeUrl.slice(queryStart);
}
function parseStream(streamText) {
let sessionId = "";
let assistantText = "";
const eventTypes = [];
for (const line of streamText.split(/\r?\n/)) {
if (!line.startsWith("data: ")) continue;
try {
const data = JSON.parse(line.slice(6));
const payload = data.payload && data.payload.type
? { type: data.payload.type, ...data.payload.properties }
: data;
if (payload.type) eventTypes.push(payload.type);
if (payload.conversation_id) sessionId = payload.conversation_id;
if (["text", "content", "assistant_message", "message"].includes(payload.type) && payload.content) {
assistantText += payload.content;
}
} catch {}
}
return {
sessionId,
eventTypes: [...new Set(eventTypes)],
assistantTextSnippet: assistantText.slice(0, 700),
};
}
async function bootstrapOpenHub(requestContext) {
const response = await requestContext.post(`${BASE_URL}/api/page-ai/openhub/bootstrap`, {
data: {
pageId: "task774-openhub-send-smoke",
workspaceId: "local-ws:mnote-e2e:my-space",
pageTitle: "OpenHub send smoke",
rootUri: ROOT_URI,
allowedRoots: [],
},
timeout: UI_TIMEOUT_MS,
});
const payload = await response.json();
if (!response.ok() || !payload.openhubIframeUrl) {
throw new SmokeFailure("bootstrap_failed", `OpenHub bootstrap 失败:HTTP ${response.status()}`, payload);
}
return payload;
}
async function fetchOpenHubModels(requestContext, scopeQuery) {
const response = await requestContext.get(`${BASE_URL}/page-ai/openhub/ai/api/models${scopeQuery}`, { timeout: UI_TIMEOUT_MS });
const payload = await response.json();
const models = payload?.data?.models || [];
const selected = models.find((model) => model.providerID === "opencodego" && model.modelID === "deepseek-v4-flash")
|| models.find((model) => model.providerID === "opencode" && model.modelID === "deepseek-v4-flash-free")
|| models.find((model) => model.providerID === "opencodego")
|| models.find((model) => model.providerID === "opencode")
|| payload?.data?.default
|| models[0];
if (!response.ok() || !models.length || !selected) {
throw new SmokeFailure("models_empty", `OpenHub /api/models 未返回可用真实模型:HTTP ${response.status()}`, payload);
}
return {
modelCount: models.length,
default: payload.data.default,
source: payload.data.source,
selected,
};
}
async function sendPrompt(requestContext, scopeQuery, model, prompt) {
const response = await requestContext.post(`${BASE_URL}/page-ai/openhub/ai/api/query/stream${scopeQuery}`, {
data: {
question: prompt,
conversation_id: "",
agent: "build",
model: {
providerID: model.providerID,
modelID: model.modelID,
currentUsage: model.currentUsage || 0,
monthlyLimit: model.monthlyLimit || 0,
},
},
headers: { "content-type": "application/json" },
timeout: 180_000,
});
const streamText = await response.text();
if (!response.ok()) {
throw new SmokeFailure("query_stream_failed", `OpenHub query stream 失败:HTTP ${response.status()}`, {
snippet: streamText.slice(0, 800),
});
}
return {
status: response.status(),
length: streamText.length,
snippet: streamText.slice(0, 1_200),
...parseStream(streamText),
};
}
async function fetchMessages(requestContext, scopeQuery, sessionId) {
const response = await requestContext.get(`${BASE_URL}/page-ai/openhub/ai/api/sessions/${encodeURIComponent(sessionId)}/messages${scopeQuery}`, {
timeout: UI_TIMEOUT_MS,
});
const payload = await response.json();
const messages = payload?.data || [];
if (!response.ok() || !messages.some((message) => message.role === "user")) {
throw new SmokeFailure("messages_not_persisted", `OpenHub SQLite messages 未持久化或不可读:HTTP ${response.status()}`, payload);
}
return {
status: response.status(),
count: messages.length,
roles: messages.map((message) => message.role),
last: messages.slice(-2).map((message) => ({
role: message.role,
content: String(message.content || "").slice(0, 300),
model: message.model,
})),
};
}
async function runFileEditProbe(requestContext, scopeQuery, model) {
const fixtureDir = path.join(WORKSPACE_ROOT, "knowledge-rag-fixtures-7-68");
fs.mkdirSync(fixtureDir, { recursive: true });
const fixturePath = path.join(fixtureDir, `task774-openhub-file-edit-${Date.now()}.md`);
fs.writeFileSync(fixturePath, "# OpenHub File Edit Smoke\n\nstatus: pending\n", "utf8");
const gitDir = path.join(WORKSPACE_ROOT, ".git");
const gitExistedBefore = fs.existsSync(gitDir);
const prompt = [
`请直接修改这个文件:${fixturePath}`,
"只把 `status: pending` 改成 `status: MNOTE_OPENHUB_FILE_EDIT_OK`。",
"不要改其它文件。完成后只简短说明已修改。",
].join("\n");
const stream = await sendPrompt(requestContext, scopeQuery, model, prompt);
const finalContent = fs.readFileSync(fixturePath, "utf8");
const ok = finalContent.includes("status: MNOTE_OPENHUB_FILE_EDIT_OK");
if (!ok) {
throw new SmokeFailure("file_edit_not_applied", "OpenHub/opencode 未把 fixture 文件改到期望内容", {
fixturePath,
finalContent,
stream,
});
}
return {
fixturePath,
ok,
finalContent,
gitExistedBefore,
gitExistsAfter: fs.existsSync(gitDir),
stream,
};
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
let browser;
let context;
let result = { ok: false, task: TASK, baseUrl: BASE_URL };
try {
await assertServiceReachable(BASE_URL);
browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
const page = await context.newPage();
const viewer = await loginWithUiFirst(page, context.request);
const bootstrap = await bootstrapOpenHub(context.request);
const scopeQuery = mnoteScopeQuery(bootstrap.openhubIframeUrl);
const models = await fetchOpenHubModels(context.request, scopeQuery);
const smokePrompt = `请只回复 MNOTE_OPENHUB_SMOKE_OK,不要解释。时间戳 ${Date.now()}`;
const stream = await sendPrompt(context.request, scopeQuery, models.selected, smokePrompt);
if (!stream.sessionId) {
throw new SmokeFailure("query_stream_missing_session", "OpenHub query stream 未返回 conversation_id", stream);
}
const messages = await fetchMessages(context.request, scopeQuery, stream.sessionId);
const fileEdit = ENABLE_FILE_EDIT
? await runFileEditProbe(context.request, scopeQuery, models.selected)
: { skipped: true, reason: "设置 MNOTE_OPENHUB_FILE_EDIT=1 后执行真实文件编辑验收" };
result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
viewer,
bootstrap: {
ok: bootstrap.ok,
authTruth: bootstrap.authTruth,
iframeUrlPrefix: bootstrap.openhubIframeUrl.slice(0, 160),
rootUri: bootstrap.scope?.workspaceScope?.rootUri,
},
models,
stream,
messages,
fileEdit,
};
} catch (error) {
result = {
...result,
ok: false,
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
error: error instanceof Error ? error.stack || error.message : String(error),
details: error instanceof SmokeFailure ? error.details : undefined,
};
} finally {
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
if (context) await context.close().catch(() => undefined);
if (browser) await browser.close().catch(() => undefined);
}
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,255 +0,0 @@
#!/usr/bin/env node
"use strict";
const TASK = "task775-openhub-mnote-scope-isolation-smoke";
const OPENHUB_BASE_URL = (process.env.MNOTE_OPENHUB_BASE_URL || "http://127.0.0.1:18080").replace(/\/+$/, "");
const WORKSPACE_ROOT = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_OPENHUB_SCOPE_ROOT_URI || `file://${WORKSPACE_ROOT}`;
const WORKSPACE_KEY = process.env.MNOTE_OPENHUB_SCOPE_WORKSPACE_KEY || "local-ws:mnote-e2e:my-space";
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_OPENHUB_SCOPE_ISOLATION_TIMEOUT_MS || 15_000);
const STREAM_PRIME_MS = Number(process.env.MNOTE_OPENHUB_SCOPE_ISOLATION_STREAM_PRIME_MS || 3_000);
class SmokeFailure extends Error {
constructor(kind, message, details = {}) {
super(message);
this.name = "SmokeFailure";
this.kind = kind;
this.details = details;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildScopeHeaders(ownerLabel, sessionScope) {
return {
"content-type": "application/json",
"X-MNote-User-Key": `task775:${ownerLabel}`,
"X-MNote-Workspace-Key": WORKSPACE_KEY,
"X-MNote-Session-Scope": sessionScope,
"X-MNote-Root-Uri": ROOT_URI,
"X-MNote-Page-Resource-Id": "task775-openhub-scope-isolation",
"X-MNote-Tool-Permission-Scope": JSON.stringify({
source: TASK,
allowedRoots: [ROOT_URI],
}),
"X-MNote-WeKnora-Tool-Scope": JSON.stringify({
source: TASK,
enabled: false,
}),
};
}
async function fetchWithTimeout(url, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
async function readJsonResponse(response) {
const text = await response.text();
try {
return text ? JSON.parse(text) : null;
} catch {
return text;
}
}
async function assertOpenHubReachable(headers) {
const response = await fetchWithTimeout(`${OPENHUB_BASE_URL}/api/sessions?page=1&page_size=1`, {
method: "GET",
headers,
});
const payload = await readJsonResponse(response);
if (!response.ok) {
throw new SmokeFailure("openhub_unreachable", `OpenHub MNote scope API 不可用:HTTP ${response.status}`, {
baseUrl: OPENHUB_BASE_URL,
payload,
});
}
return {
status: response.status,
success: payload?.success === true,
};
}
async function primeSessionWithOwnerA(headers, sessionId, marker) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), STREAM_PRIME_MS);
let response;
let streamSnippet = "";
try {
response = await fetch(`${OPENHUB_BASE_URL}/api/query/stream`, {
method: "POST",
headers,
body: JSON.stringify({
question: `请只回复 ${marker},不要解释。`,
conversation_id: sessionId,
agent: "build",
model: {
providerID: process.env.MNOTE_OPENHUB_SCOPE_MODEL_PROVIDER || "opencodego",
modelID: process.env.MNOTE_OPENHUB_SCOPE_MODEL_ID || "deepseek-v4-flash",
currentUsage: 0,
monthlyLimit: 0,
},
}),
signal: controller.signal,
});
if (!response.ok) {
const payload = await readJsonResponse(response);
throw new SmokeFailure("query_stream_failed", `Owner A 创建 session/message 失败:HTTP ${response.status}`, {
payload,
});
}
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (streamSnippet.length < 2_000) {
const readResult = await Promise.race([
reader.read(),
delay(250).then(() => ({ timedOut: true })),
]);
if (readResult.timedOut) break;
if (readResult.done) break;
streamSnippet += decoder.decode(readResult.value, { stream: true });
if (streamSnippet.includes(marker) || streamSnippet.includes("\"type\"")) break;
}
} finally {
await reader.cancel().catch(() => undefined);
}
}
} catch (error) {
if (error && error.name !== "AbortError") {
throw error;
}
} finally {
clearTimeout(timer);
}
return {
status: response?.status || 0,
streamSnippet: streamSnippet.slice(0, 500),
};
}
async function fetchMessages(headers, sessionId) {
const response = await fetchWithTimeout(
`${OPENHUB_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/messages`,
{
method: "GET",
headers,
},
);
const payload = await readJsonResponse(response);
return {
status: response.status,
ok: response.ok,
payload,
};
}
async function waitForOwnerAMessages(headers, sessionId, marker) {
let last = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
last = await fetchMessages(headers, sessionId);
const messages = Array.isArray(last.payload?.data) ? last.payload.data : [];
const hasMarker = messages.some((message) => String(message.content || "").includes(marker));
if (last.ok && hasMarker) {
return {
status: last.status,
readable: true,
count: messages.length,
roles: messages.map((message) => message.role),
markerFound: true,
sample: messages.slice(-3).map((message) => ({
role: message.role,
content: String(message.content || "").slice(0, 240),
})),
};
}
await delay(500);
}
throw new SmokeFailure("owner_a_messages_not_readable", "Owner A 未能读取到自己创建的 session/messages", {
last,
});
}
function assertOwnerBBlocked(ownerBResult) {
if (ownerBResult.status === 403 || ownerBResult.status === 404) {
return {
blocked: true,
status: ownerBResult.status,
detail: ownerBResult.payload?.detail || ownerBResult.payload,
};
}
throw new SmokeFailure("owner_b_not_blocked", "Owner B 读取到了或可访问 Owner A 的 session/messages", {
status: ownerBResult.status,
payload: ownerBResult.payload,
});
}
async function main() {
const runId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const sessionId = `task775-openhub-scope-${runId}`;
const sessionScope = `task775-openhub-scope-isolation:${runId}`;
const marker = `MNOTE_OPENHUB_SCOPE_ISOLATION_${runId}`;
const headersA = buildScopeHeaders("owner-a", sessionScope);
const headersB = buildScopeHeaders("owner-b", sessionScope);
let result = {
ok: false,
task: TASK,
mode: "direct-openhub-mnote-headers-backend-scope-isolation",
openhubBaseUrl: OPENHUB_BASE_URL,
sessionId,
};
try {
const health = await assertOpenHubReachable(headersA);
const stream = await primeSessionWithOwnerA(headersA, sessionId, marker);
const ownerA = await waitForOwnerAMessages(headersA, sessionId, marker);
const ownerB = assertOwnerBBlocked(await fetchMessages(headersB, sessionId));
result = {
...result,
ok: true,
health,
stream,
ownerA,
ownerB,
summary: {
sessionId,
ownerAReadable: ownerA.readable,
ownerBBlocked: ownerB.blocked,
ownerBStatus: ownerB.status,
},
};
} catch (error) {
result = {
...result,
ok: false,
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
error: error instanceof Error ? error.stack || error.message : String(error),
details: error instanceof SmokeFailure ? error.details : undefined,
};
}
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,234 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const backendSessionPath = path.join(openHubRoot, "smart-query-backend/app/api/session.py");
const frontendApiPath = path.join(openHubRoot, "smart-query-frontend/src/services/api.js");
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js");
const task774ResultPath = path.join(repoRoot, "tmp/7-68-runtime/openhub-send-smoke-result.json");
const outputDir = path.join(repoRoot, "tmp/7-68-runtime");
const resultPath = path.join(outputDir, "openhub-changed-files-bridge-smoke-result.json");
const baseUrl = process.env.MNOTE_BASE_URL || "http://127.0.0.1:3000";
const testAccount = process.env.MNOTE_E2E_ACCOUNT || "mnote-e2e";
const testPassword = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
const workspaceRoot = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
function read(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function assertCheck(failures, name, passed, details = undefined) {
if (!passed) failures.push({ name, details });
}
function loadTask774Probe() {
if (!fs.existsSync(task774ResultPath)) return null;
const payload = JSON.parse(fs.readFileSync(task774ResultPath, "utf8"));
const sessionId = payload?.fileEdit?.stream?.sessionId;
const fixturePath = payload?.fileEdit?.fixturePath;
if (!payload?.ok || !sessionId || !fixturePath) return null;
return { sessionId, fixturePath };
}
async function signInCookie() {
const response = await fetch(`${baseUrl}/api/auth`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
action: "auth:signIn",
args: {
provider: "password",
params: {
account: testAccount,
password: testPassword,
flow: "signIn",
},
},
}),
signal: AbortSignal.timeout(12_000),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`/api/auth 登录失败: HTTP ${response.status} ${text.slice(0, 400)}`);
}
const setCookie = response.headers.get("set-cookie") || "";
const cookies = setCookie
.split(/,(?=\s*[^;,\s]+=)/)
.map((part) => part.split(";")[0].trim())
.filter(Boolean);
if (!cookies.some((cookie) => cookie.startsWith("mnote_session="))) {
throw new Error(`/api/auth 未返回 mnote_session cookie: ${setCookie.slice(0, 400)}`);
}
return cookies.join("; ");
}
async function bootstrapScopeQuery(cookieHeader) {
const response = await fetch(`${baseUrl}/api/page-ai/openhub/bootstrap`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
cookie: cookieHeader,
},
body: JSON.stringify({
workspaceId: "local-ws:mnote-e2e:my-space",
rootUri: `file://${workspaceRoot}`,
pageResourceId: "task776-openhub-changed-files-bridge",
pageTitle: "OpenHub changed files bridge smoke",
allowedRoots: [`file://${workspaceRoot}`],
}),
signal: AbortSignal.timeout(12_000),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(`OpenHub bootstrap 失败: HTTP ${response.status} ${JSON.stringify(payload).slice(0, 500)}`);
}
const iframeUrl = String(payload?.openhubIframeUrl || "");
const query = iframeUrl.includes("?") ? iframeUrl.slice(iframeUrl.indexOf("?")) : "";
if (!query.includes("mnoteScope=")) {
throw new Error(`OpenHub bootstrap 未返回完整 mnoteScope query: ${iframeUrl.slice(0, 200)}`);
}
return query;
}
async function probeLiveEndpoint(probe) {
if (!probe) {
return { skipped: true, reason: "缺少 task774 真实文件编辑结果,先运行 MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js" };
}
const cookieHeader = await signInCookie();
const query = await bootstrapScopeQuery(cookieHeader);
const url = `${baseUrl}/page-ai/openhub/ai/api/sessions/${encodeURIComponent(probe.sessionId)}/diff${query}`;
const response = await fetch(url, {
headers: { accept: "application/json", cookie: cookieHeader },
signal: AbortSignal.timeout(12_000),
});
const text = await response.text();
let payload = null;
try {
payload = JSON.parse(text);
} catch {}
const changed = Array.isArray(payload?.diffs) ? payload.diffs : [];
const matched = changed.some((item) => item.path === probe.fixturePath);
return {
skipped: false,
ok: response.ok && matched,
status: response.status,
matched,
expectedPath: probe.fixturePath,
queryFromFreshBootstrap: true,
changedPaths: changed.map((item) => item.path),
diffAvailable: payload?.diffAvailable,
source: payload?.source,
limitation: payload?.limitation,
snippet: text.slice(0, 800),
};
}
async function main() {
fs.mkdirSync(outputDir, { recursive: true });
const failures = [];
const backendSession = read(backendSessionPath);
const frontendApi = read(frontendApiPath);
const diffViewer = read(diffViewerPath);
const embed = read(embedPath);
const smartQuery = read(smartQueryPath);
const mnoteRuntime = read(mnoteRuntimePath);
assertCheck(
failures,
"OpenHub backend exposes session diff endpoint",
backendSession.includes('@router.get("/api/sessions/{session_id}/diff")') &&
backendSession.includes("_changed_files_from_messages") &&
backendSession.includes("opencode_tool_events")
);
assertCheck(
failures,
"backend extracts path only from write-like opencode tool metadata",
backendSession.includes("_is_write_tool") &&
backendSession.includes("_iter_tool_path_values") &&
backendSession.includes('"filePath"') &&
backendSession.includes('"source": "opencode_tool_event"')
);
assertCheck(
failures,
"backend guards workspace path and does not synthesize diff content",
backendSession.includes("os.path.commonpath") &&
backendSession.includes('"diffAvailable": False') &&
backendSession.includes('"content": ""') &&
backendSession.includes("不生成或伪造 diff")
);
assertCheck(
failures,
"frontend service still calls session diff endpoint",
frontendApi.includes("getSessionDiff") && frontendApi.includes("/sessions/${sessionId}/diff")
);
assertCheck(
failures,
"frontend postMessage bridge emits mnote open-file payload",
embed.includes("postMNoteOpenFile") &&
embed.includes("type: 'mnote:open-file'") &&
embed.includes("source: payload.source || 'openhub-diff'")
);
assertCheck(
failures,
"DiffViewer exposes changed path open action",
diffViewer.includes("postMNoteOpenFile") &&
diffViewer.includes("rootRelativePath") &&
diffViewer.includes("diffAvailable")
);
assertCheck(
failures,
"SmartQueryPage loads changed files after stream and exposes hidden bridge payload",
smartQuery.includes("loadChangedFiles(finalConversationId)") &&
smartQuery.includes("data-mnote-openhub-changed-file") &&
smartQuery.includes("handleOpenChangedFile")
);
assertCheck(
failures,
"MNote host listens for OpenHub open-file bridge",
mnoteRuntime.includes("pageAiInstallMNoteOpenFileBridge") &&
mnoteRuntime.includes("message.type !== 'mnote:open-file'") &&
mnoteRuntime.includes("openhub-diff") &&
mnoteRuntime.includes("pageAiOpenOpencodeChangedFile")
);
const liveProbeInput = loadTask774Probe();
let liveProbe;
try {
liveProbe = await probeLiveEndpoint(liveProbeInput);
if (!liveProbe.skipped) {
assertCheck(failures, "live MNote proxy returns real changed path from task774 session", liveProbe.ok, liveProbe);
}
} catch (error) {
liveProbe = {
skipped: false,
ok: false,
error: error instanceof Error ? error.message : String(error),
reason: "MNote/OpenHub 服务不可达或 task774 session 已不可读",
};
assertCheck(failures, "live MNote proxy returns real changed path from task774 session", false, liveProbe);
}
const result = {
ok: failures.length === 0,
task: "task776-openhub-changed-files-bridge-smoke",
liveProbe,
failures,
};
fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,92 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
const routePath = path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs");
const modPath = path.join(repoRoot, "rust/crates/mnote-web/src/routes/mod.rs");
const designPath = path.join(
repoRoot,
"design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md"
);
function read(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function assertCheck(failures, name, passed, details = undefined) {
if (!passed) failures.push({ name, details });
}
const route = read(routePath);
const routesMod = read(modPath);
const design = read(designPath);
const failures = [];
assertCheck(
failures,
"MNote exposes artifact index API under Page AI OpenHub boundary",
routesMod.includes('"/api/page-ai/openhub/artifact-index"') &&
routesMod.includes("get(page_ai_openhub::artifact_index_get).post(page_ai_openhub::artifact_index_upsert)")
);
assertCheck(
failures,
"artifact index schema stores only lightweight locator fields",
route.includes("mnote.page_ai_openhub_artifact_index_record.v1") &&
route.includes('"openhubSessionId"') &&
route.includes('"kind"') &&
route.includes('"providerId"') &&
route.includes('"path"') &&
route.includes('"citationPayload"')
);
assertCheck(
failures,
"artifact index explicitly refuses OpenHub message fulltext fields",
route.includes("reject_fulltext_message_fields") &&
route.includes("page_ai_openhub_artifact_index_forbidden_fulltext_field") &&
route.includes('"message"') &&
route.includes('"conversationMessages"') &&
route.includes('"assistantMessage"') &&
route.includes('"userMessage"') &&
route.includes('"content"') &&
route.includes('"transcript"')
);
assertCheck(
failures,
"artifact index response documents no message fulltext copy",
route.includes('"messageFulltextCopied": false') &&
route.includes('"openhub_message_fulltext"') &&
route.includes('"openhub_conversation_message_rows"') &&
route.includes('"assistant_text"') &&
route.includes('"user_prompt"')
);
assertCheck(
failures,
"minimal persistence stays inside MNote/root metadata or explicit env path",
route.includes("MNOTE_OPENHUB_ARTIFACT_INDEX_PATH") &&
route.includes('join(".mnote")') &&
route.includes('join("page-ai-openhub-artifact-index.json")') &&
route.includes("write_artifact_index_records")
);
assertCheck(
failures,
"design checklist records completed artifact index boundary",
design.includes("[x] MNote 只存 artifact index") &&
design.includes("task778-openhub-artifact-index-static-smoke.js") &&
design.includes("不是复制 OpenHub SQLite message 表")
);
const result = {
ok: failures.length === 0,
task: "task778-openhub-artifact-index-static-smoke",
failures,
};
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
@@ -1,230 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
const RESULT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh-result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh.png");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath, workspaceId) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
if (workspaceId) url.searchParams.set("workspaceId", workspaceId);
return url.toString();
}
function markdown(title, lines) {
return ["---", `title: ${title}`, "---", "", ...lines, ""].join("\n");
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function waitForEditorText(page, text) {
await page.waitForFunction(
(expected) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return (editor?.textContent || "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readDocumentPaneState(page) {
return page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-pane-role="primary"]');
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const aggregateNode = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
let aggregate = null;
try {
aggregate = JSON.parse(aggregateNode?.textContent || "null");
} catch {}
return {
paneDocumentId: pane?.getAttribute("data-pane-document-id") || "",
runtimeStatus: root?.getAttribute("data-runtime-editor-status") || "",
runtimeError: root?.getAttribute("data-runtime-editor-error") || "",
editorText: editor?.textContent || "",
aggregateText: JSON.stringify(aggregate?.body || aggregate || {}),
syncedAt: aggregateNode?.getAttribute("data-mnote-page-aggregate-synced-at") || "",
openhubRefreshMarker: document.documentElement.getAttribute("data-mnote-page-ai-openhub-document-pane-refresh") || "",
eventBusSource: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-source") || "",
eventBusReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "",
fileChangeSources: window.__mnoteTask779FileChangeSources || [],
documentSessionDebug: window.__mnoteDebugDocumentSessions?.snapshot?.() || null,
};
});
}
async function openDocument(page, root, relativePath, workspaceId) {
await page.goto(documentUrl(root, relativePath, workspaceId), { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForPageAiRuntime(page) {
await page.waitForFunction(
() => typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function"
&& typeof window.__mnoteDocumentPaneRuntime?.refreshPrimaryDocument === "function"
&& typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument === "function",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function installOpenHubChangedFileBridge(page) {
await page.evaluate(() => {
try {
localStorage.setItem("mnote.page_ai.openhub_host", "1");
} catch {}
window.__mnoteSidebarPageAiRuntime.openPageAiDrawer();
});
await page.locator('[data-testid="wolai-page-ai-drawer"][data-page-ai-openhub-host="true"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function simulateOpenHubChangedFile(page, root, relativePath, workspaceId) {
return page.evaluate(({ rootUri, path, workspaceId }) => {
window.postMessage({
type: "mnote:open-file",
source: "openhub-changed-files",
path,
rootUri,
workspaceId,
documentId: `local-md:${path.replaceAll("/", "~2F")}`,
}, window.location.origin);
return true;
}, { rootUri: fileUrl(root), path: relativePath, workspaceId });
}
async function run() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task779-openhub-pane-refresh-"));
const workspaceId = `local-ws:user_real:task779:${Date.now()}`;
const relativePath = "task779-openhub-refresh.md";
const initialText = "task779 initial document pane text";
const changedText = `task779 openhub changed file bridge ${Date.now()}`;
writeWorkspaceManifest(root, "user_real", workspaceId);
fs.writeFileSync(path.join(root, relativePath), markdown("Task 779 OpenHub Refresh", [initialText]), "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1360, height: 900 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const debug = { root, workspaceId, relativePath, initialText, changedText };
try {
await openDocument(page, root, relativePath, workspaceId);
await waitForEditorText(page, initialText);
await waitForPageAiRuntime(page);
await installOpenHubChangedFileBridge(page);
await page.evaluate(() => {
window.__mnoteTask779FileChangeSources = [];
window.addEventListener("mnote:file-change-batch", (event) => {
window.__mnoteTask779FileChangeSources.push(String(event?.detail?.source || ""));
});
});
debug.before = await readDocumentPaneState(page);
fs.writeFileSync(
path.join(root, relativePath),
markdown("Task 779 OpenHub Refresh", [changedText]),
"utf8",
);
const opened = await simulateOpenHubChangedFile(page, root, relativePath, workspaceId);
assert.equal(opened, true, "OpenHub changed-file bridge 应接受当前 Markdown path");
await waitForEditorText(page, changedText);
debug.after = await readDocumentPaneState(page);
assert.equal(debug.after.paneDocumentId, localMdDocumentId(relativePath), "primary document pane 应仍打开测试 Markdown");
assert(debug.after.editorText.includes(changedText), `document pane 应显示磁盘新内容: ${debug.after.editorText}`);
assert(!debug.after.editorText.includes(initialText), `document pane 不应保留旧正文: ${debug.after.editorText}`);
assert.equal(debug.after.openhubRefreshMarker, relativePath, "应记录 OpenHub document pane refresh marker");
assert(
debug.after.fileChangeSources.some((source) => source.includes("openhub_changed_file_bridge")),
`应通过 FileChangeService 消费 OpenHub changedFiles adapter: ${JSON.stringify(debug.after.fileChangeSources)}`,
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const result = {
ok: true,
task: "task779-openhub-file-edit-document-pane-refresh-smoke",
root,
relativePath,
documentId: localMdDocumentId(relativePath),
changedText,
beforeText: debug.before.editorText,
afterText: debug.after.editorText,
eventBusSource: debug.after.eventBusSource,
eventBusReason: debug.after.eventBusReason,
resultPath: RESULT_PATH,
screenshotPath: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(`ok task779-openhub-file-edit-document-pane-refresh-smoke ${RESULT_PATH}`);
} catch (error) {
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({
ok: false,
error: String(error && error.stack || error),
debug,
}, null, 2)}\n`, "utf8");
throw error;
} finally {
await browser.close().catch(() => {});
fs.rmSync(root, { recursive: true, force: true });
}
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -1,219 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { TextDecoder } = require("node:util");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
const designPath = path.join(
repoRoot,
"design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md"
);
const forbiddenFulltextKeys = [
"message",
"messages",
"messageContent",
"conversation",
"conversationMessages",
"assistantMessage",
"userMessage",
"content",
"text",
"transcript",
];
function read(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function assertCheck(failures, name, passed, details = undefined) {
if (!passed) failures.push({ name, details });
}
function base64UrlJson(value) {
return Buffer.from(JSON.stringify(value), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function hasForbiddenKey(value) {
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some(hasForbiddenKey);
return Object.entries(value).some(([key, entry]) => (
forbiddenFulltextKeys.includes(key) || hasForbiddenKey(entry)
));
}
async function probeEmbedRuntime(embedSource) {
const mnoteScope = {
workspaceScope: {
rootUri: "file:///tmp/mnote-artifact-root",
workspaceId: "ws-task780",
pageResourceId: "page-task780",
},
};
const calls = [];
const sandbox = {
console,
TextDecoder,
URLSearchParams,
Uint8Array,
window: {
location: {
pathname: "/page-ai/openhub/ai",
search: `?scope=session-task780&mnoteScope=${base64UrlJson(mnoteScope)}`,
origin: "http://127.0.0.1:3000",
},
parent: {},
atob: (value) => Buffer.from(value, "base64").toString("binary"),
},
fetch: async (url, options = {}) => {
calls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({ ok: true }),
};
},
module: { exports: {} },
exports: {},
};
const transformed = embedSource
.replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined")
.replace(/\bexport const /g, "const ");
vm.runInNewContext(
`${transformed}\nmodule.exports = { getMNoteArtifactIndexContext, postMNoteArtifactIndex };`,
sandbox,
{ filename: embedPath }
);
const result = await sandbox.module.exports.postMNoteArtifactIndex({
openhubSessionId: "ses-task780",
kind: "changed_file",
providerId: "opencode_tool_event",
path: "/tmp/mnote-artifact-root/page.md",
citationPayload: {
schema: "openhub.changed_file_locator.v1",
rootRelativePath: "page.md",
diffAvailable: false,
},
});
const body = calls[0] ? JSON.parse(calls[0].options.body) : null;
return { result, calls, body };
}
async function main() {
const failures = [];
const embed = read(embedPath);
const smartQuery = read(smartQueryPath);
const diffViewer = read(diffViewerPath);
const design = read(designPath);
assertCheck(
failures,
"mnoteEmbed posts artifact index to MNote root API",
embed.includes("postMNoteArtifactIndex") &&
embed.includes("getMNoteArtifactIndexContext") &&
embed.includes("fetch('/api/page-ai/openhub/artifact-index'") &&
embed.includes("credentials: 'include'")
);
assertCheck(
failures,
"artifact index payload is limited to locator fields",
["openhubSessionId", "kind", "providerId", "path", "citationPayload", "rootUri", "workspaceId", "pageResourceId"]
.every((field) => embed.includes(field))
);
assertCheck(
failures,
"SmartQueryPage indexes changed files after diff metadata is loaded",
smartQuery.includes("indexChangedFiles(sessionId, files)") &&
smartQuery.includes("kind: 'changed_file'") &&
smartQuery.includes("schema: 'openhub.changed_file_locator.v1'") &&
smartQuery.includes("providerId: file?.source || 'opencode_tool_event'")
);
assertCheck(
failures,
"SmartQueryPage indexes WeKnora citation locator payloads",
smartQuery.includes("indexCitationArtifacts") &&
smartQuery.includes("kind: 'citation'") &&
smartQuery.includes("schema: 'openhub.weknora_citation_locator.v1'") &&
smartQuery.includes("citation?.sourceRootRelativePath")
);
assertCheck(
failures,
"citation payload sanitizer strips fulltext-like fields recursively",
forbiddenFulltextKeys.every((key) => smartQuery.includes(`'${key}'`)) &&
smartQuery.includes("sanitizeCitationPayload(entry)") &&
smartQuery.includes(".filter(([key]) => !forbiddenKeys.has(key))")
);
assertCheck(
failures,
"changed file bridge remains connected to MNote open-file event",
diffViewer.includes("postMNoteOpenFile") &&
smartQuery.includes("data-mnote-openhub-changed-file") &&
smartQuery.includes("handleOpenChangedFile")
);
assertCheck(
failures,
"design checklist records task780 runtime artifact index bridge",
design.includes("task780-openhub-artifact-index-runtime-smoke.js") &&
design.includes("changed_file / citation 轻量 artifact index")
);
let runtimeProbe = null;
try {
runtimeProbe = await probeEmbedRuntime(embed);
assertCheck(
failures,
"runtime request uses MNote artifact-index endpoint",
runtimeProbe.calls.length === 1 &&
runtimeProbe.calls[0].url === "/api/page-ai/openhub/artifact-index" &&
runtimeProbe.calls[0].options.method === "POST",
runtimeProbe
);
assertCheck(
failures,
"runtime request body contains required locator fields",
runtimeProbe.body &&
runtimeProbe.body.openhubSessionId === "ses-task780" &&
runtimeProbe.body.kind === "changed_file" &&
runtimeProbe.body.providerId === "opencode_tool_event" &&
runtimeProbe.body.path === "/tmp/mnote-artifact-root/page.md" &&
runtimeProbe.body.rootUri === "file:///tmp/mnote-artifact-root" &&
runtimeProbe.body.workspaceId === "ws-task780" &&
runtimeProbe.body.pageResourceId === "page-task780",
runtimeProbe.body
);
assertCheck(
failures,
"runtime request body does not contain OpenHub message fulltext fields",
runtimeProbe.body && !hasForbiddenKey(runtimeProbe.body),
runtimeProbe.body
);
} catch (error) {
runtimeProbe = { error: error instanceof Error ? error.stack || error.message : String(error) };
assertCheck(failures, "runtime request shape probe executes", false, runtimeProbe);
}
const result = {
ok: failures.length === 0,
task: "task780-openhub-artifact-index-runtime-smoke",
runtimeProbe,
failures,
};
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,188 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const backendBridgePath = path.join(openHubRoot, "smart-query-backend/app/services/mnote_weknora.py");
const backendApiPath = path.join(openHubRoot, "smart-query-backend/app/api/mnote_tools.py");
const backendStreamPath = path.join(openHubRoot, "smart-query-backend/app/services/stream.py");
const backendScopePath = path.join(openHubRoot, "smart-query-backend/app/core/mnote_scope.py");
const frontendEmbedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
const assistantMessagePath = path.join(openHubRoot, "smart-query-frontend/src/components/AssistantMessage.jsx");
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js");
const checklistPath = path.join(repoRoot, "design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md");
const outputDir = path.join(repoRoot, "tmp", "7-68-runtime");
const resultPath = path.join(outputDir, "openhub-weknora-tool-citation-bridge-smoke-result.json");
function read(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function assertCheck(failures, name, passed, details = undefined) {
if (!passed) failures.push({ name, details });
}
function base64UrlJson(value) {
return Buffer.from(JSON.stringify(value), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function extractFunction(source, name) {
const start = source.indexOf(`export const ${name}`);
if (start < 0) throw new Error(`找不到 ${name}`);
const next = source.indexOf("\nexport const ", start + 1);
return source.slice(start, next > start ? next : undefined);
}
async function probeEmbedOpenReference(embedSource) {
const scope = {
workspaceScope: {
rootUri: "file:///tmp/mnote-openhub-task782",
workspaceId: "ws-task782",
pageResourceId: "page-task782",
},
};
const posted = [];
const sandbox = {
console,
TextDecoder,
URLSearchParams,
Uint8Array,
window: {
location: {
pathname: "/page-ai/openhub/ai",
search: `?scope=session-task782&mnoteScope=${base64UrlJson(scope)}`,
origin: "http://127.0.0.1:3000",
},
parent: {
postMessage: (message, origin) => posted.push({ message, origin }),
},
atob: (value) => Buffer.from(value, "base64").toString("binary"),
},
module: { exports: {} },
exports: {},
};
const transformed = embedSource
.slice(0, embedSource.indexOf("const compactObject"))
.replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined")
.replace(/\bexport const /g, "const ");
vm.runInNewContext(
`${transformed}\nmodule.exports = { postMNoteOpenReference };`,
sandbox,
{ filename: frontendEmbedPath }
);
const ok = sandbox.module.exports.postMNoteOpenReference({
schema: "openhub.weknora_citation_locator.v1",
citation: {
provider: "weknora",
sourceRootRelativePath: "knowledge-rag-fixtures-7-68/task782.md",
citationLabel: "task782.md",
},
});
return { ok, posted };
}
async function main() {
const failures = [];
const backendBridge = read(backendBridgePath);
const backendApi = read(backendApiPath);
const backendStream = read(backendStreamPath);
const backendScope = read(backendScopePath);
const frontendEmbed = read(frontendEmbedPath);
const assistantMessage = read(assistantMessagePath);
const smartQuery = read(smartQueryPath);
const mnoteRuntime = read(mnoteRuntimePath);
const checklist = read(checklistPath);
assertCheck(
failures,
"OpenHub backend exposes MNote WeKnora readonly facade endpoint",
backendApi.includes("/api/mnote/tools/call") &&
backendApi.includes("call_mnote_weknora_tool") &&
backendBridge.includes("MNOTE_TOOL_CALL_ENDPOINT = \"/api/mnote/tools/call\"") &&
!backendBridge.includes("MNOTE_TOOL_CALL_ENDPOINT = \"/api/hermes/tools/mnote/call\"")
);
assertCheck(
failures,
"backend tool call carries scope and does not expose WeKnora API key",
backendBridge.includes("capabilityScope") &&
backendBridge.includes("knowledge_rag.read") &&
backendBridge.includes("allowedRoots") &&
backendBridge.includes("mnote_cookie") &&
!backendBridge.includes("MNOTE_WEKNORA_API_KEY")
);
assertCheck(
failures,
"OpenHub stream auto-bridges KB queries through MNote WeKnora",
backendStream.includes("auto_search_for_question") &&
backendStream.includes("<mnote_weknora_tool_result>") &&
backendStream.includes("_push_mnote_weknora_tool_event") &&
backendStream.includes("mnote.weknora.search")
);
assertCheck(
failures,
"MNote session cookie is only forwarded as trusted server header",
backendScope.includes("X-MNote-Session-Cookie") &&
mnoteRuntime.includes("mnote:open-reference") &&
!frontendEmbed.includes("mnote_session=")
);
assertCheck(
failures,
"OpenHub frontend renders clickable WeKnora citations",
assistantMessage.includes("data-mnote-openhub-citation") &&
assistantMessage.includes("openhub.weknora_citation_locator.v1") &&
assistantMessage.includes("onOpenMNoteCitation") &&
smartQuery.includes("postMNoteOpenReference")
);
assertCheck(
failures,
"MNote host accepts citation open-reference postMessage bridge",
mnoteRuntime.includes("message.type !== 'mnote:open-file' && message.type !== 'mnote:open-reference'") &&
mnoteRuntime.includes("openhub-citation") &&
mnoteRuntime.includes("pageAiOpenOpencodeChangedFile")
);
const runtimeProbe = await probeEmbedOpenReference(frontendEmbed);
assertCheck(
failures,
"runtime postMNoteOpenReference posts mnote:open-reference",
runtimeProbe.ok &&
runtimeProbe.posted.length === 1 &&
runtimeProbe.posted[0].message.type === "mnote:open-reference" &&
runtimeProbe.posted[0].message.source === "openhub-citation" &&
runtimeProbe.posted[0].message.path.endsWith("task782.md"),
runtimeProbe
);
assertCheck(
failures,
"checklist has task782 completion note",
checklist.includes("task782-openhub-weknora-tool-citation-bridge-smoke.js")
);
fs.mkdirSync(outputDir, { recursive: true });
const result = {
ok: failures.length === 0,
task: "task782-openhub-weknora-tool-citation-bridge-smoke",
runtimeProbe,
failures,
};
fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
if (failures.length) {
console.error(JSON.stringify(result, null, 2));
process.exit(1);
}
console.log(JSON.stringify(result, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -1,555 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task786-openhub-history-refresh-browser-smoke";
const BASE_URL = (process.env.BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const STREAM_TIMEOUT_MS = Number(process.env.MNOTE_OPENHUB_STREAM_TIMEOUT_MS || 45_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task786-openhub-history-refresh-browser-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "openhub-history-refresh.png");
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
class SmokeFailure extends Error {
constructor(layer, message, details = {}) {
super(message);
this.name = "SmokeFailure";
this.layer = layer;
this.details = details;
}
}
function jsonSnippet(value, limit = 800) {
if (typeof value === "string") return value.slice(0, limit);
return JSON.stringify(value, null, 2).slice(0, limit);
}
function openHubProxyUrl(pathname, scopeQuery, extraParams = {}) {
const url = new URL(`${BASE_URL}/page-ai/openhub/ai${pathname}`);
const scopeParams = new URLSearchParams(String(scopeQuery || "").replace(/^\?/, ""));
for (const [key, value] of scopeParams.entries()) {
url.searchParams.set(key, value);
}
for (const [key, value] of Object.entries(extraParams)) {
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
}
return url.toString();
}
async function parseJsonResponse(response, layer, label) {
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
throw new SmokeFailure(layer, `${label} 返回非 JSONHTTP ${response.status()}`, {
status: response.status(),
bodySnippet: text.slice(0, 800),
});
}
if (!response.ok()) {
throw new SmokeFailure(layer, `${label} 失败:HTTP ${response.status()}`, payload);
}
return payload;
}
async function assertMNoteReachable() {
let response;
try {
response = await fetch(`${BASE_URL}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) });
} catch (error) {
throw new SmokeFailure("mnote_service", `MNote 3000 服务不可达:${error.message}`, { baseUrl: BASE_URL });
}
if (!response.ok && response.status !== 303) {
throw new SmokeFailure("mnote_service", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl: BASE_URL });
}
}
async function signIn(requestContext) {
const response = await requestContext.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: TEST_EMAIL,
email: TEST_EMAIL,
password: TEST_PASSWORD,
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
const payload = await parseJsonResponse(response, "auth", "/api/auth 登录");
const whoami = await requestContext.get(`${BASE_URL}/api/auth/whoami`, { timeout: UI_TIMEOUT_MS });
const viewer = await parseJsonResponse(whoami, "auth", "/api/auth/whoami");
if (!viewer || !viewer.userId) {
throw new SmokeFailure("auth", "登录后 whoami 缺少 userId", { loginPayload: payload, whoami: viewer });
}
return viewer;
}
async function visibleAny(page, selectors, label) {
await page.waitForFunction((candidateSelectors) => {
const visible = (node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
return candidateSelectors.some((selector) => Array.from(document.querySelectorAll(selector)).some(visible));
}, selectors, { timeout: UI_TIMEOUT_MS }).catch((error) => {
throw new SmokeFailure("mnote_page_ai_ui", `${label} 不可见`, { selectors, cause: error.message });
});
}
async function openMNoteOpenHubDrawer(page) {
await page.goto(BASE_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
throw new SmokeFailure("auth", "打开 MNote 主页后仍被重定向到 /auth", { url: page.url() });
}
await page.evaluate(() => {
try {
localStorage.setItem("mnote.page_ai.openhub_host", "1");
} catch {}
});
await page.waitForFunction(() => (
typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function"
|| document.querySelector("[data-testid='wolai-floating-ai']")
|| document.querySelector("[data-testid='wolai-page-ai-drawer']")
), null, { timeout: UI_TIMEOUT_MS });
const openedByRuntime = await page.evaluate(() => {
try {
localStorage.setItem("mnote.page_ai.openhub_host", "1");
if (typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function") {
window.__mnoteSidebarPageAiRuntime.openPageAiDrawer();
return true;
}
} catch {}
return false;
});
if (!openedByRuntime) {
const floating = page.locator("[data-testid='wolai-floating-ai']").first();
if (await floating.isVisible({ timeout: 5_000 }).catch(() => false)) {
await floating.click({ timeout: UI_TIMEOUT_MS });
}
}
await visibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI drawer");
await page.waitForFunction(() => {
const drawer = document.querySelector("[data-testid='wolai-page-ai-drawer']");
return drawer instanceof HTMLElement && drawer.getAttribute("data-page-ai-openhub-host") === "true";
}, null, { timeout: UI_TIMEOUT_MS }).catch((error) => {
throw new SmokeFailure("mnote_page_ai_ui", "Page AI drawer 未切到 OpenHub host", {
cause: error.message,
drawerText: "",
});
});
await page.waitForFunction(() => {
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
return iframe instanceof HTMLIFrameElement && (iframe.getAttribute("src") || "").includes("/page-ai/openhub/ai");
}, null, { timeout: UI_TIMEOUT_MS }).catch((error) => {
throw new SmokeFailure("mnote_page_ai_ui", "OpenHub iframe 未挂载或 src 未指向 /page-ai/openhub/ai", { cause: error.message });
});
}
async function getOpenHubFrame(page) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let lastState = null;
while (Date.now() < deadline) {
const handle = await page.locator("iframe[data-page-ai-openhub-iframe]").first().elementHandle().catch(() => null);
if (handle) {
const frame = await handle.contentFrame();
lastState = {
iframeSrc: await handle.getAttribute("src").catch(() => ""),
frameUrl: frame ? frame.url() : "",
};
if (frame && frame.url().includes("/page-ai/openhub/ai")) {
await frame.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
return frame;
}
}
await page.waitForTimeout(300);
}
throw new SmokeFailure("openhub_iframe_ui", "无法取得 OpenHub iframe frame", lastState || {});
}
async function collectFrameState(frame, marker) {
return frame.evaluate((expectedMarker) => {
const text = (selector) => (document.querySelector(selector)?.textContent || "").replace(/\s+/g, " ").trim();
const bodyText = (document.body?.textContent || "").replace(/\s+/g, " ").trim();
const drawerText = Array.from(document.querySelectorAll(".ant-drawer, [role='dialog']"))
.map((node) => (node.textContent || "").replace(/\s+/g, " ").trim())
.filter(Boolean)
.join("\n");
const messageAreaText = text(".messages-area");
const historyButtons = Array.from(document.querySelectorAll("button"))
.map((button) => (button.textContent || button.getAttribute("title") || button.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim())
.filter(Boolean);
return {
url: location.href,
title: document.title,
bodySnippet: bodyText.slice(0, 1200),
iframeShellKind: document.body?.getAttribute("data-mnote-openhub-ai-shell") || "",
hasReactRoot: Boolean(document.getElementById("root")),
hasOpenHubTitle: bodyText.includes("OpenHub 平台"),
hasHistoryButton: historyButtons.some((entry) => entry.includes("历史记录")),
historyButtons,
drawerTextSnippet: drawerText.slice(0, 1200),
messageAreaSnippet: messageAreaText.slice(0, 1200),
bodyHasMarker: bodyText.includes(expectedMarker),
drawerHasMarker: drawerText.includes(expectedMarker),
messageAreaHasMarker: messageAreaText.includes(expectedMarker),
staticBoundaryVisible: /静态占位|static-boundary|最小 host\/bootstrap 占位/.test(bodyText),
loginVisible: /(登录\s*OpenHub|OpenHub\s*Login|Sign in to OpenHub|WeKnora\s*登录)/i.test(bodyText),
};
}, marker);
}
function scopeQueryFromFrameUrl(frameUrl) {
const url = new URL(frameUrl, BASE_URL);
const query = url.searchParams.toString();
if (!query || !url.searchParams.get("scope") || !url.searchParams.get("mnoteScope")) {
throw new SmokeFailure("openhub_scope", "OpenHub iframe URL 缺少 scope/mnoteScope", { frameUrl });
}
return `?${query}`;
}
function parseStreamEvents(streamText) {
const eventTypes = [];
let returnedSessionId = "";
let errorEvent = "";
for (const line of String(streamText || "").split(/\r?\n/)) {
if (!line.startsWith("data: ")) continue;
try {
const data = JSON.parse(line.slice(6));
const payload = data.payload && data.payload.type
? { type: data.payload.type, ...data.payload.properties }
: data;
if (payload.type) eventTypes.push(payload.type);
if (payload.conversation_id) returnedSessionId = payload.conversation_id;
if (payload.error) errorEvent = String(payload.error);
} catch {}
}
return {
eventTypes: [...new Set(eventTypes)],
returnedSessionId,
errorEvent,
snippet: String(streamText || "").slice(0, 1200),
};
}
async function sendMarkerMessage(requestContext, scopeQuery, sessionId, marker) {
const prompt = `${marker} Page AI OpenHub history refresh smoke. 请只回复 ${marker},不要解释。`;
const body = {
question: prompt,
conversation_id: sessionId,
agent: "build",
};
const url = openHubProxyUrl("/api/query/stream", scopeQuery);
try {
const response = await requestContext.post(url, {
data: body,
headers: { "content-type": "application/json" },
timeout: STREAM_TIMEOUT_MS,
});
const text = await response.text();
return {
ok: response.ok(),
status: response.status(),
prompt,
...parseStreamEvents(text),
};
} catch (error) {
return {
ok: false,
status: 0,
prompt,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function fetchMessagesUntilMarker(requestContext, scopeQuery, sessionId, marker) {
let lastPayload = null;
let lastStatus = 0;
for (let attempt = 0; attempt < 12; attempt += 1) {
const response = await requestContext.get(openHubProxyUrl(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, scopeQuery), {
timeout: UI_TIMEOUT_MS,
});
lastStatus = response.status();
const payload = await response.json().catch(async () => ({ nonJson: await response.text().catch(() => "") }));
lastPayload = payload;
const messages = Array.isArray(payload?.data) ? payload.data : [];
const userMessage = messages.find((message) => message.role === "user" && String(message.content || "").includes(marker));
if (response.ok() && userMessage) {
return {
ok: true,
status: response.status(),
count: messages.length,
roles: messages.map((message) => message.role),
userMessageContent: String(userMessage.content || ""),
lastMessages: messages.slice(-3).map((message) => ({
role: message.role,
content: String(message.content || "").slice(0, 300),
created_at: message.created_at,
})),
};
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new SmokeFailure("openhub_api_messages", "OpenHub API 未读回带 marker 的 user message", {
sessionId,
marker,
status: lastStatus,
payloadSnippet: jsonSnippet(lastPayload),
});
}
async function fetchSessionsUntilMarker(requestContext, scopeQuery, sessionId, marker) {
let lastPayload = null;
for (let attempt = 0; attempt < 8; attempt += 1) {
const response = await requestContext.get(openHubProxyUrl("/api/sessions", scopeQuery, { page: 1, page_size: 10 }), {
timeout: UI_TIMEOUT_MS,
});
const payload = await response.json().catch(async () => ({ nonJson: await response.text().catch(() => "") }));
lastPayload = payload;
const sessions = Array.isArray(payload?.data) ? payload.data : [];
const found = sessions.find((session) => String(session.session_id || session.id || "") === sessionId);
const markerTitle = sessions.find((session) => String(session.title || "").includes(marker));
if (response.ok() && (found || markerTitle)) {
return {
ok: true,
status: response.status(),
total: payload?.pagination?.total,
foundSession: found || markerTitle,
markerInTitle: Boolean(markerTitle),
firstSessions: sessions.slice(0, 5).map((session) => ({
session_id: session.session_id || session.id || "",
title: session.title || "",
updated_at: session.updated_at || "",
})),
};
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new SmokeFailure("openhub_api_history", "OpenHub sessions API 未读回对应 session/history", {
sessionId,
marker,
payloadSnippet: jsonSnippet(lastPayload),
});
}
async function assertOpenHubReactUsable(frame, marker) {
const state = await collectFrameState(frame, marker);
if (state.staticBoundaryVisible) {
throw new SmokeFailure("openhub_iframe_ui", "OpenHub iframe 仍是静态边界页,不能把 API-only 记为 UI 通过", state);
}
if (state.loginVisible) {
throw new SmokeFailure("openhub_iframe_ui", "OpenHub iframe 出现独立登录入口", state);
}
if (!state.hasOpenHubTitle && !state.hasHistoryButton) {
throw new SmokeFailure("openhub_iframe_ui", "OpenHub React AI 面板未渲染出历史入口", state);
}
return state;
}
async function openHistoryAndVerify(frame, sessionId, marker) {
let beforeHistoryState = await collectFrameState(frame, marker);
if (beforeHistoryState.messageAreaHasMarker) {
return {
uiMarkerVerified: true,
historyMarkerVerified: false,
currentMessageMarkerVerified: true,
beforeHistoryState,
afterHistoryState: beforeHistoryState,
afterSessionClickState: beforeHistoryState,
};
}
let historyButton = frame.getByRole("button", { name: /历史记录/ }).first();
if (!(await historyButton.isVisible({ timeout: 3_000 }).catch(() => false))) {
historyButton = frame.locator('button[title="历史记录"]').first();
}
if (!(await historyButton.isVisible({ timeout: 8_000 }).catch(() => false))) {
throw new SmokeFailure("openhub_history_ui", "OpenHub iframe 内未找到历史记录按钮", beforeHistoryState);
}
await historyButton.click({ timeout: UI_TIMEOUT_MS });
let afterHistoryState = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
afterHistoryState = await collectFrameState(frame, marker);
if (afterHistoryState.drawerHasMarker || afterHistoryState.bodyHasMarker) break;
if (attempt === 2) {
const refreshButton = frame.getByRole("button", { name: /刷新/ }).first();
if (await refreshButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
await refreshButton.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
await frame.waitForTimeout(1000);
}
const historyMarkerVerified = Boolean(afterHistoryState && (afterHistoryState.drawerHasMarker || afterHistoryState.bodyHasMarker));
if (!historyMarkerVerified) {
throw new SmokeFailure("openhub_history_ui", "刷新后 OpenHub history/session UI 未显示 marker/session 标题", {
sessionId,
marker,
beforeHistoryState,
afterHistoryState,
});
}
const markerText = frame.getByText(marker, { exact: false }).first();
if (await markerText.isVisible({ timeout: 5_000 }).catch(() => false)) {
await markerText.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
let afterSessionClickState = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
afterSessionClickState = await collectFrameState(frame, marker);
if (afterSessionClickState.messageAreaHasMarker) break;
await frame.waitForTimeout(1000);
}
return {
uiMarkerVerified: historyMarkerVerified || Boolean(afterSessionClickState?.messageAreaHasMarker),
historyMarkerVerified,
currentMessageMarkerVerified: Boolean(afterSessionClickState?.messageAreaHasMarker),
beforeHistoryState,
afterHistoryState,
afterSessionClickState,
};
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const marker = `TASK786_MARKER_${suffix}`;
const sessionId = `task786-openhub-history-${suffix}`;
let browser;
let context;
let page;
let result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
sessionId,
marker,
screenshotPath: SCREENSHOT_PATH,
uiMarker: "",
historyMarker: "",
uiMarkerVerified: false,
historyMarkerVerified: false,
currentMessageMarkerVerified: false,
};
try {
await assertMNoteReachable();
browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
page = await context.newPage();
const viewer = await signIn(context.request);
await openMNoteOpenHubDrawer(page);
let frame = await getOpenHubFrame(page);
const initialFrameState = await assertOpenHubReactUsable(frame, marker);
const scopeQuery = scopeQueryFromFrameUrl(frame.url());
const sendStream = await sendMarkerMessage(context.request, scopeQuery, sessionId, marker);
const apiMessages = await fetchMessagesUntilMarker(context.request, scopeQuery, sessionId, marker);
const apiSessions = await fetchSessionsUntilMarker(context.request, scopeQuery, sessionId, marker);
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await openMNoteOpenHubDrawer(page);
frame = await getOpenHubFrame(page);
const refreshedFrameState = await assertOpenHubReactUsable(frame, marker);
const ui = await openHistoryAndVerify(frame, sessionId, marker);
if (!ui.uiMarkerVerified) {
throw new SmokeFailure("openhub_history_ui", "API 已读回 marker,但刷新后 UI 未恢复 marker/session", {
sessionId,
marker,
ui,
});
}
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
result = {
...result,
ok: true,
viewer,
scopeQueryKeys: Array.from(new URLSearchParams(scopeQuery.slice(1)).keys()),
sendStream,
apiMessageVerified: true,
apiHistoryVerified: true,
apiMessages,
apiSessions,
initialFrameState,
refreshedFrameState,
uiMarkerVerified: ui.uiMarkerVerified,
historyMarkerVerified: ui.historyMarkerVerified,
currentMessageMarkerVerified: ui.currentMessageMarkerVerified,
uiMarker: ui.currentMessageMarkerVerified ? marker : "",
historyMarker: ui.historyMarkerVerified ? marker : "",
beforeHistoryState: ui.beforeHistoryState,
afterHistoryState: ui.afterHistoryState,
afterSessionClickState: ui.afterSessionClickState,
resultPath: RESULT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
if (fs.existsSync(FAILURE_PATH)) fs.rmSync(FAILURE_PATH, { force: true });
} catch (error) {
if (page) {
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
}
result = {
...result,
ok: false,
failureLayer: error instanceof SmokeFailure ? error.layer : "unexpected",
error: error instanceof Error ? error.stack || error.message : String(error),
details: error instanceof SmokeFailure ? error.details : undefined,
failurePath: FAILURE_PATH,
};
fs.writeFileSync(FAILURE_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
if (fs.existsSync(RESULT_PATH)) fs.rmSync(RESULT_PATH, { force: true });
} finally {
if (context) await context.close().catch(() => undefined);
if (browser) await browser.close().catch(() => undefined);
}
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
}
main().catch((error) => {
const failure = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
failureLayer: "fatal",
error: error instanceof Error ? error.stack || error.message : String(error),
failurePath: FAILURE_PATH,
};
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(FAILURE_PATH, `${JSON.stringify(failure, null, 2)}\n`, "utf8");
console.error(JSON.stringify(failure, null, 2));
process.exit(1);
});
@@ -1,101 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const files = {
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
embed: path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"),
api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"),
queryModel: path.join(openHubRoot, "smart-query-backend/app/models/query.py"),
queryApi: path.join(openHubRoot, "smart-query-backend/app/api/query.py"),
stream: path.join(openHubRoot, "smart-query-backend/app/services/stream.py"),
mnoteRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"),
};
function read(file) {
return fs.readFileSync(file, "utf8");
}
function assertCheck(failures, name, passed) {
if (!passed) failures.push(name);
}
const chatInput = read(files.chatInput);
const embed = read(files.embed);
const api = read(files.api);
const queryModel = read(files.queryModel);
const queryApi = read(files.queryApi);
const stream = read(files.stream);
const mnoteRoute = read(files.mnoteRoute);
const failures = [];
assertCheck(
failures,
"OpenHub ChatInput natively owns MNote context toggles",
chatInput.includes("requestMNoteActiveTabAddress") &&
chatInput.includes("data-mnote-openhub-current-tab-toggle") &&
chatInput.includes("data-mnote-openhub-current-folder-toggle") &&
chatInput.includes("mnoteContextMode === 'tab'") &&
chatInput.includes("mnoteContextMode === 'folder'") &&
chatInput.includes("handleSendWithMNoteContext")
);
assertCheck(
failures,
"toggles live beside model quota controls and do not write textarea",
chatInput.indexOf("model?.monthlyLimit") < chatInput.indexOf("data-mnote-openhub-current-tab-toggle") &&
chatInput.includes("handleSend(undefined, context ?") &&
!chatInput.includes("setQuestion(context.value") &&
!chatInput.includes("setQuestion(mnoteContext")
);
assertCheck(
failures,
"OpenHub mnoteEmbed requests active tab/folder from MNote host",
embed.includes("export const requestMNoteActiveTabAddress") &&
embed.includes("mnote:get-active-tab-address") &&
embed.includes("mnote:active-tab-address") &&
embed.includes("kind === 'folder' ? 'folder' : 'tab'")
);
assertCheck(
failures,
"OpenHub request body carries hidden mnote_context",
api.includes("mnoteContext = null") &&
api.includes("requestBody.mnote_context = mnoteContext")
);
assertCheck(
failures,
"OpenHub backend accepts and forwards mnote_context",
queryModel.includes("mnote_context: Optional[dict]") &&
queryApi.includes("mnote_context=request.mnote_context") &&
stream.includes("mnote_context: Optional[dict] = None")
);
assertCheck(
failures,
"OpenHub stream prepends hidden current page/folder context before prompt",
stream.includes("<mnote_current_context>") &&
stream.includes("当前文件夹") &&
stream.includes("当前页面") &&
stream.includes("context_parts.append") &&
stream.includes("_build_mnote_context_block") &&
stream.includes("_mnote_context_from_scope") &&
stream.includes("Effective MNote context") &&
stream.includes("Sent prompt preview")
);
assertCheck(
failures,
"MNote proxy no longer injects floating quick action overlay",
!mnoteRoute.includes("data-mnote-openhub-ai-quick-actions") &&
!mnoteRoute.includes("mnote_openhub_bridge_markup") &&
!mnoteRoute.includes("insertAddress(")
);
if (failures.length) {
console.error("OpenHub native MNote context static smoke failed:");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("OpenHub native MNote context static smoke passed.");
@@ -1,169 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const files = {
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
smartQueryPage: path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx"),
api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"),
openHubFrontendSrc: path.join(openHubRoot, "smart-query-frontend/src"),
mnoteOpenHubRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"),
};
function read(file) {
return fs.readFileSync(file, "utf8");
}
function walkFiles(dir, result = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkFiles(fullPath, result);
} else {
result.push(fullPath);
}
}
return result;
}
function extractBetween(source, startNeedle, endNeedle) {
const start = source.indexOf(startNeedle);
if (start < 0) return "";
const end = source.indexOf(endNeedle, start);
return source.slice(start, end < 0 ? undefined : end);
}
function check(failures, name, passed, detail = "") {
if (!passed) {
failures.push(detail ? `${name}: ${detail}` : name);
}
}
const chatInput = read(files.chatInput);
const smartQueryPage = read(files.smartQueryPage);
const api = read(files.api);
const controlRow = extractBetween(chatInput, "<AgentModeToggle", "{currentTodos");
const handleMNoteSend = extractBetween(chatInput, "const handleSendWithMNoteContext", "useEffect(() =>");
const handleSend = extractBetween(smartQueryPage, "const handleSend = async", "const handleKeyPress");
const legacyFloatingSelectors = [
"mnote-openhub-context-bar",
"mnote-openhub-native-context",
"data-mnote-openhub-ai-quick-actions",
"mnote_openhub_bridge_markup",
"insertAddress(",
];
const frontendLegacyHits = [];
for (const file of walkFiles(files.openHubFrontendSrc)) {
const rel = path.relative(openHubRoot, file);
const source = read(file);
for (const selector of legacyFloatingSelectors) {
if (source.includes(selector)) {
frontendLegacyHits.push(`${rel} -> ${selector}`);
}
}
}
const mnoteRoute = fs.existsSync(files.mnoteOpenHubRoute) ? read(files.mnoteOpenHubRoute) : "";
const mnoteRouteLegacyHits = legacyFloatingSelectors.filter((selector) =>
mnoteRoute.includes(selector)
);
const failures = [];
check(
failures,
"ChatInput owns two native MNote context buttons",
chatInput.includes("isMNoteOpenHubEmbed()") &&
chatInput.includes("requestMNoteActiveTabAddress") &&
chatInput.includes("data-mnote-openhub-current-tab-toggle") &&
chatInput.includes("data-mnote-openhub-current-folder-toggle"),
"missing embed gate, context request helper, or native button data attributes"
);
check(
failures,
"MNote context buttons are in the controls row beside agent/model controls",
controlRow.includes("<AgentModeToggle") &&
controlRow.includes("<ModelSelect") &&
controlRow.includes("data-mnote-openhub-current-tab-toggle") &&
controlRow.includes("data-mnote-openhub-current-folder-toggle") &&
controlRow.indexOf("<AgentModeToggle") < controlRow.indexOf("data-mnote-openhub-current-tab-toggle") &&
controlRow.indexOf("<ModelSelect") < controlRow.indexOf("data-mnote-openhub-current-folder-toggle"),
"native buttons must stay in the same compact controls row as agent toggle/model select"
);
check(
failures,
"Icon-only buttons use tooltips and aria labels for current page/tab and folder",
/Tooltip\s+title=["{][^"'}]*(当前(?:页面|激活\s*Tab|Tab)|current\s*(?:page|tab))/i.test(controlRow) &&
/Tooltip\s+title=["{][^"'}]*(当前(?:文件夹|激活\s*Tab\s*的文件夹)|current\s*folder|folder)/i.test(controlRow) &&
/aria-label="当前页面"/.test(controlRow) &&
/aria-label="文件夹"/.test(controlRow) &&
/icon=\{<FileTextOutlined\s*\/>\}/.test(controlRow) &&
/icon=\{<FolderOpenOutlined\s*\/>\}/.test(controlRow) &&
!/>当前页面<\/Button>/.test(controlRow) &&
!/>文件夹<\/Button>/.test(controlRow),
"expected two icon-only buttons with distinct tooltip and aria labels"
);
check(
failures,
"ChatInput sends mnote_context as hidden metadata instead of writing URL into textarea question",
/handleSend\(undefined,\s*context\s*\?\s*\{/.test(handleMNoteSend) &&
handleMNoteSend.includes("kind: mnoteContextMode") &&
handleMNoteSend.includes("value: context.value") &&
!/setQuestion\s*\(\s*context\./.test(handleMNoteSend) &&
!/setQuestion\s*\(\s*mnoteContext/.test(handleMNoteSend) &&
!/question\s*=\s*context\.value/.test(handleMNoteSend),
"context must flow through handleSend second argument and must not mutate the visible question value"
);
check(
failures,
"SmartQueryPage accepts mnote_context and forwards it to queryDataService.queryDataStream",
/const\s+handleSend\s*=\s*async\s*\(\s*overrideQuestion\s*,\s*mnoteContext\s*=\s*null\s*\)/.test(
smartQueryPage
) &&
handleSend.includes("queryDataService.queryDataStream(") &&
/abortControllerRef\.current\.signal\s*,\s*mnoteContext/.test(handleSend),
"handleSend must accept mnoteContext and pass it through the stream send call"
);
check(
failures,
"OpenHub send body carries hidden mnote_context",
/queryDataStream:\s*async\s*\([^)]*mnoteContext\s*=\s*null/.test(api) &&
api.includes("requestBody.mnote_context = mnoteContext") &&
!/question\s*:\s*.*mnoteContext/.test(api),
"api.js must put mnoteContext into requestBody.mnote_context, not append it to question"
);
check(
failures,
"OpenHub frontend no longer depends on legacy MNote floating button selectors",
frontendLegacyHits.length === 0,
frontendLegacyHits.join("; ")
);
check(
failures,
"MNote OpenHub route no longer injects legacy floating context buttons",
mnoteRouteLegacyHits.length === 0,
mnoteRouteLegacyHits.join("; ")
);
if (failures.length) {
console.error("OpenHub native MNote context source smoke failed:");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("OpenHub native MNote context source smoke passed.");
@@ -1,225 +0,0 @@
#!/usr/bin/env node
"use strict";
const http = require("node:http");
const { spawn } = require("node:child_process");
const OPENHUB_BACKEND_DIR = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend";
const OPENCODE_PORT = Number(process.env.TASK793_OPENCODE_PORT || 19096);
const OPENHUB_PORT = Number(process.env.TASK793_OPENHUB_PORT || 18181);
const SESSION_ID = "ses_mnote_context_fullchain";
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readBody(req) {
return new Promise((resolve) => {
let data = "";
req.on("data", (chunk) => { data += chunk; });
req.on("end", () => resolve(data));
});
}
function sseEvent(type, properties) {
return `data: ${JSON.stringify({ payload: { type, properties } })}\n\n`;
}
function startFakeOpencode() {
const capturedPrompts = [];
let eventResponse = null;
function sendEvents() {
if (!eventResponse) return false;
eventResponse.write(sseEvent("message.updated", {
sessionID: SESSION_ID,
info: { id: "msg_assistant", role: "assistant", sessionID: SESSION_ID },
}));
eventResponse.write(sseEvent("message.part.updated", {
sessionID: SESSION_ID,
part: { id: "prt_text", messageID: "msg_assistant", sessionID: SESSION_ID, type: "text", text: "OK" },
}));
eventResponse.write(sseEvent("session.status", {
sessionID: SESSION_ID,
status: { type: "idle" },
}));
eventResponse.end();
eventResponse = null;
return true;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${OPENCODE_PORT}`);
if (req.method === "GET" && url.pathname === "/global/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === "POST" && url.pathname === "/session") {
await readBody(req);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ id: SESSION_ID }));
return;
}
if (req.method === "GET" && url.pathname === "/global/event") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
});
eventResponse = res;
eventResponse.write(`data: ${JSON.stringify({ payload: { type: "ready", properties: { sessionID: SESSION_ID } } })}\n\n`);
if (capturedPrompts.length) {
setTimeout(sendEvents, 50);
}
return;
}
if (req.method === "POST" && url.pathname === `/session/${SESSION_ID}/prompt_async`) {
const body = JSON.parse(await readBody(req) || "{}");
capturedPrompts.push(body);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
setTimeout(sendEvents, 50);
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not_found", path: url.pathname }));
});
return {
capturedPrompts,
listen: () => new Promise((resolve) => server.listen(OPENCODE_PORT, "127.0.0.1", resolve)),
close: () => new Promise((resolve) => server.close(resolve)),
};
}
async function waitForHealth(url, timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
if (response.status < 500) return;
} catch {}
await wait(250);
}
throw new Error(`等待服务超时: ${url}`);
}
async function sendOpenHub(payload, extraHeaders = {}) {
const body = JSON.stringify(payload);
return new Promise((resolve, reject) => {
const req = http.request({
hostname: "127.0.0.1",
port: OPENHUB_PORT,
path: "/api/query/stream",
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
"x-mnote-user-key": "openhub_user_test",
"x-mnote-workspace-key": "workspace_test",
"x-mnote-session-scope": `session_${Date.now()}_${Math.random().toString(16).slice(2)}`,
"x-mnote-root-uri": "file:///tmp/mnote-fullchain-workspace",
"x-mnote-page-resource-id": "local-md:Inbox~2FPage.md",
...extraHeaders,
},
}, (res) => {
let text = "";
const timeout = setTimeout(() => {
req.destroy();
resolve(text);
}, 10_000);
res.setEncoding("utf8");
res.on("data", (chunk) => {
text += chunk;
if (text.includes("message_complete")) {
clearTimeout(timeout);
req.destroy();
resolve(text);
}
});
res.on("end", () => {
clearTimeout(timeout);
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`OpenHub HTTP ${res.statusCode}: ${text.slice(0, 500)}`));
} else {
resolve(text);
}
});
});
req.on("error", (error) => {
if (error.code === "ECONNRESET") return;
reject(error);
});
req.write(body);
req.end();
});
}
async function main() {
const fakeOpencode = startFakeOpencode();
let uvicorn = null;
let stderr = "";
await fakeOpencode.listen();
try {
uvicorn = spawn(".venv/bin/uvicorn", ["app.main:app", "--host", "127.0.0.1", "--port", String(OPENHUB_PORT)], {
cwd: OPENHUB_BACKEND_DIR,
env: {
...process.env,
OPENCODE_BASE_URL: `http://localhost:${OPENCODE_PORT}`,
MNOTE_OPENHUB_TOOL_BRIDGE_AUTO: "0",
SQLITE_DB_PATH: "/tmp/openhub-fullchain-smoke.db",
NO_PROXY: "127.0.0.1,localhost",
no_proxy: "127.0.0.1,localhost",
},
stdio: ["ignore", "ignore", "pipe"],
});
uvicorn.stderr.on("data", (chunk) => { stderr += String(chunk); });
await waitForHealth(`http://127.0.0.1:${OPENHUB_PORT}/api/health`);
const tabUrl = "http://127.0.0.1:3000/documents/local-md:Inbox~2FPage.md?sourceKind=local_folder&rootUri=file:///tmp/mnote-fullchain-workspace";
await sendOpenHub({
question: "只回答 OK",
conversation_id: "",
agent: "build",
mnote_context: {
kind: "tab",
value: tabUrl,
tabUrl,
rootUri: "file:///tmp/mnote-fullchain-workspace",
documentId: "local-md:Inbox~2FPage.md",
relativePath: "Inbox/Page.md",
},
});
await wait(200);
await sendOpenHub({ question: "只回答 OK fallback", conversation_id: "", agent: "build" });
await wait(200);
const explicitPrompt = fakeOpencode.capturedPrompts[0]?.parts?.[0]?.text || "";
const fallbackPrompt = fakeOpencode.capturedPrompts[1]?.parts?.[0]?.text || "";
const result = {
ok: false,
promptCount: fakeOpencode.capturedPrompts.length,
explicitContextOk: explicitPrompt.includes("<mnote_current_context>")
&& explicitPrompt.includes("当前页面: http://127.0.0.1:3000/documents/")
&& explicitPrompt.includes("relativePath: Inbox/Page.md"),
fallbackContextOk: fallbackPrompt.includes("<mnote_current_context>")
&& fallbackPrompt.includes("source: mnote_scope_fallback")
&& fallbackPrompt.includes("file:///tmp/mnote-fullchain-workspace"),
explicitPromptPreview: explicitPrompt.slice(0, 700),
fallbackPromptPreview: fallbackPrompt.slice(0, 700),
};
result.ok = result.promptCount >= 2 && result.explicitContextOk && result.fallbackContextOk;
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exitCode = 1;
} finally {
if (uvicorn) {
uvicorn.kill("SIGTERM");
await wait(400);
}
await fakeOpencode.close();
if (process.exitCode) process.stderr.write(stderr.slice(-3000));
}
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
@@ -1,458 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
function parseArgs(argv) {
const args = {
fixture: "",
out: "",
dryRun: false,
manifest: "",
conflictReport: "",
rollback: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--fixture") {
args.fixture = argv[++index] || "";
continue;
}
if (arg === "--out") {
args.out = argv[++index] || "";
continue;
}
if (arg === "--dry-run") {
args.dryRun = true;
continue;
}
if (arg === "--manifest") {
args.manifest = argv[++index] || "";
continue;
}
if (arg === "--conflict-report") {
args.conflictReport = argv[++index] || "";
continue;
}
if (arg === "--rollback") {
args.rollback = argv[++index] || "";
continue;
}
if (arg === "--help" || arg === "-h") {
console.log(
[
"用法:node recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js --fixture <fixture.json> --out <dir> [--dry-run] [--manifest <file>] [--conflict-report <file>]",
"回滚:node recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js --rollback <manifest.json>",
].join("\n"),
);
process.exit(0);
}
throw new Error(`未知参数:${arg}`);
}
if (args.rollback) return args;
if (!args.fixture) throw new Error("缺少 --fixture");
if (!args.out) throw new Error("缺少 --out");
return args;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function writeUtf8(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, "utf8");
}
function writeJson(filePath, value) {
writeUtf8(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function sanitizeName(name) {
return String(name || "untitled")
.trim()
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/\s+/g, " ")
.replace(/\.+$/g, "")
.trim() || "untitled";
}
function loadFixture(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function buildDocumentIndex(documents) {
const byId = new Map();
documents.forEach((doc) => {
byId.set(String(doc.id), doc);
});
return byId;
}
function buildMarkdownPath(doc, documentsById) {
const segments = [];
let current = doc;
while (current) {
segments.unshift(sanitizeName(current.title || current.id || "untitled"));
const parentId = current.parent_id || current.parentId || null;
current = parentId ? documentsById.get(String(parentId)) : null;
}
return path.posix.join("pages", ...segments) + ".md";
}
function buildAssetPath(markdownPath, fileName) {
const base = markdownPath.replace(/\.md$/i, ".assets");
return path.posix.join(base, sanitizeName(fileName));
}
function findDocumentForAsset(asset, documents) {
const directId = String(asset.document_id || asset.documentId || asset.page_id || asset.pageId || "").trim();
if (directId) return directId;
const assetId = String(asset.id || "").trim();
if (!assetId) return "";
const doc = documents.find((item) => {
const content = typeof item.content === "string" ? item.content : "";
const rawText = typeof item.raw_text === "string" ? item.raw_text : "";
const editorText = typeof item.editor_document === "string" ? item.editor_document : "";
const tiptapText = typeof item.tiptap_document === "string" ? item.tiptap_document : "";
return [content, rawText, editorText, tiptapText].some((text) => text.includes(assetId));
});
return doc ? String(doc.id) : "";
}
function decodeAssetContent(asset) {
if (typeof asset.contentBase64 === "string" && asset.contentBase64) {
return Buffer.from(asset.contentBase64, "base64");
}
if (typeof asset.content === "string") {
return Buffer.from(asset.content, "utf8");
}
return Buffer.from("", "utf8");
}
function rewriteAssetUrls(markdown, assetPathById) {
return String(markdown || "").replace(/\/api\/media\/sign\?assetId=([^)\s"'&#]+)/g, (_, assetId) => {
return assetPathById.get(String(assetId)) || _;
});
}
function serializePageOptions(doc) {
return {
wideLayout: doc.wide_layout ?? null,
useSmallText: doc.use_small_text ?? null,
showHeadingNumbers: doc.show_heading_numbers ?? null,
showToc: doc.show_toc ?? null,
showStructure: doc.show_structure ?? null,
protectEditing: doc.protect_editing ?? null,
showWordCount: doc.show_word_count ?? null,
collapseBacklinks: doc.collapse_backlinks ?? null,
pageFont: doc.page_font ?? null,
layoutDensity: doc.layout_density ?? null,
hideChildPages: doc.hide_child_pages ?? null,
showBlockRefCount: doc.show_block_ref_count ?? null,
embedDefaultBlockId: doc.embed_default_block_id ?? null,
};
}
function fileUriForPath(localPath) {
return `file://${path.resolve(localPath)}`;
}
function nowId() {
return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
}
function normalizeRelativePath(relativePath) {
const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
if (!normalized || normalized.split("/").some((part) => part === "..")) {
throw new Error(`非法迁移相对路径:${relativePath}`);
}
return normalized;
}
function pushFileOperation(operations, relativePath, content, contentEncoding = "utf8") {
operations.push({
action: "create",
relativePath: normalizeRelativePath(relativePath),
content,
contentEncoding,
});
}
function buildMigrationPlan(fixture, out) {
const documents = Array.isArray(fixture.documents) ? fixture.documents : [];
const mediaAssets = Array.isArray(fixture.mediaAssets) ? fixture.mediaAssets : [];
const aiSessions = Array.isArray(fixture.aiSessions) ? fixture.aiSessions : [];
const docsById = buildDocumentIndex(documents);
const markdownPathByDocId = new Map();
const assetPathById = new Map();
const pageIds = {};
const pageOptions = {};
const resourceIndex = { version: 1, assets: {} };
documents.forEach((doc) => {
const markdownPath = buildMarkdownPath(doc, docsById);
markdownPathByDocId.set(String(doc.id), markdownPath);
pageIds[markdownPath] = `local-mdid:${doc.id}`;
pageOptions[`local-mdid:${doc.id}`] = serializePageOptions(doc);
});
mediaAssets.forEach((asset) => {
const ownerDocId = findDocumentForAsset(asset, documents) || String(documents[0]?.id || "");
const ownerMarkdownPath = markdownPathByDocId.get(ownerDocId) || "pages/attachments.md";
const relativePath = buildAssetPath(ownerMarkdownPath, asset.fileName || asset.name || asset.id);
assetPathById.set(String(asset.id), relativePath);
resourceIndex.assets[String(asset.id)] = {
fileName: sanitizeName(asset.fileName || asset.name || asset.id),
relativePath,
documentId: ownerDocId || null,
};
});
const operations = [];
const indexedDocuments = [];
const indexedResources = [];
documents.forEach((doc) => {
const markdownPath = markdownPathByDocId.get(String(doc.id));
const assetRoot = markdownPath.replace(/\.md$/i, ".assets");
const markdownDir = path.posix.dirname(markdownPath);
const markdownBody = rewriteAssetUrls(
typeof doc.content === "string"
? doc.content
: String(doc.raw_text || doc.editor_document || doc.tiptap_document || ""),
new Map(Array.from(assetPathById.entries()).map(([assetId, absolutePath]) => [
assetId,
path.posix.relative(markdownDir, absolutePath),
])),
);
const frontmatter = [
"---",
`title: ${String(doc.title || doc.id || "untitled")}`,
`mnote_id: ${String(doc.id)}`,
"---",
"",
].join("\n");
pushFileOperation(operations, markdownPath, `${frontmatter}${markdownBody}`);
indexedDocuments.push({
documentId: `local-mdid:${doc.id}`,
title: String(doc.title || doc.id || "untitled"),
path: markdownPath,
rawText: markdownBody,
tags: [],
backlinks: [],
resourceRefs: [],
updatedAt: Date.now(),
});
operations.push({
action: "ensureDir",
relativePath: normalizeRelativePath(assetRoot),
});
});
mediaAssets.forEach((asset) => {
const relativePath = assetPathById.get(String(asset.id));
if (!relativePath) return;
const content = decodeAssetContent(asset).toString("base64");
pushFileOperation(operations, relativePath, content, "base64");
indexedResources.push({
resourceId: String(asset.id),
resourceType: "media",
title: sanitizeName(asset.fileName || asset.name || asset.id),
path: relativePath,
updatedAt: Date.now(),
});
});
aiSessions.forEach((session) => {
const sessionId = sanitizeName(session.sessionId || session.id || "session");
const shareId = String(session.shareId || session.share_id || "").trim();
const visibility = String(session.visibility || "").trim();
const relativePath = shareId || visibility === "shared"
? path.posix.join("ai-sessions", "shared", sanitizeName(shareId || "share"), `${sessionId}.jsonl`)
: path.posix.join("ai-sessions", "private", `${sessionId}.jsonl`);
const events = Array.isArray(session.events) ? session.events : [];
const lines = events.map((event) => JSON.stringify(event)).join("\n");
pushFileOperation(operations, relativePath, lines ? `${lines}\n` : "");
});
const workspaceId = fixture.workspace?.id || "exported-workspace";
pushFileOperation(operations, ".mnote/page-ids.json", `${JSON.stringify({ version: 1, pages: pageIds }, null, 2)}\n`);
pushFileOperation(operations, ".mnote/page-options.json", `${JSON.stringify({ version: 1, pages: pageOptions }, null, 2)}\n`);
pushFileOperation(operations, ".mnote/resource-index.json", `${JSON.stringify(resourceIndex, null, 2)}\n`);
pushFileOperation(
operations,
".mnote/workspace.json",
`${JSON.stringify(
{
workspaceId,
ownerId: fixture.workspace?.ownerId || fixture.workspace?.owner_id || "unknown",
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "exported_from_convex"],
},
null,
2,
)}\n`,
);
pushFileOperation(
operations,
".mnote/index/search-index.json",
`${JSON.stringify(
{
version: 1,
builtAt: Date.now(),
rootUri: fileUriForPath(out),
workspaceId,
documents: indexedDocuments,
resources: indexedResources,
},
null,
2,
)}\n`,
);
return {
workspaceId,
operations,
documentCount: documents.length,
resourceCount: mediaAssets.length,
aiSessionCount: aiSessions.length,
};
}
function detectConflicts(out, operations) {
return operations
.filter((operation) => operation.action === "create")
.filter((operation) => fs.existsSync(path.join(out, operation.relativePath)))
.map((operation) => ({
code: "target_exists",
relativePath: operation.relativePath,
targetPath: path.join(out, operation.relativePath),
message: "目标文件已存在,迁移不会覆盖",
}));
}
function manifestForPlan(out, plan, dryRun, conflicts = []) {
const migrationId = `convex-export-${nowId()}`;
return {
version: 1,
migrationId,
source: "convex_fixture",
dryRun,
out: path.resolve(out),
createdAt: new Date().toISOString(),
backupDir: path.join(path.resolve(out), ".mnote", "migration-backups", migrationId),
workspaceId: plan.workspaceId,
documentCount: plan.documentCount,
resourceCount: plan.resourceCount,
aiSessionCount: plan.aiSessionCount,
operations: plan.operations.map((operation) => ({
action: operation.action,
relativePath: operation.relativePath,
contentEncoding: operation.contentEncoding || null,
})),
createdFiles: [],
backupFiles: [],
conflicts,
indexRefresh: {
status: dryRun ? "planned" : "pending",
path: ".mnote/index/search-index.json",
},
};
}
function writeConflictReport(filePath, out, conflicts) {
if (!filePath) return;
writeJson(filePath, {
version: 1,
code: conflicts.length ? "migration_conflict" : "ok",
out: path.resolve(out),
generatedAt: new Date().toISOString(),
conflicts,
});
}
function applyPlan(out, plan, manifest) {
ensureDir(out);
ensureDir(manifest.backupDir);
for (const operation of plan.operations) {
const target = path.join(out, operation.relativePath);
if (operation.action === "ensureDir") {
ensureDir(target);
continue;
}
if (operation.action !== "create") continue;
ensureDir(path.dirname(target));
const content = operation.contentEncoding === "base64"
? Buffer.from(operation.content, "base64")
: operation.content;
fs.writeFileSync(target, content, operation.contentEncoding === "base64" ? undefined : "utf8");
manifest.createdFiles.push(operation.relativePath);
}
manifest.indexRefresh.status = "written";
}
function rollbackManifest(manifestPath) {
const manifest = loadFixture(manifestPath);
const out = manifest.out;
if (!out) throw new Error("rollback manifest 缺少 out");
const createdFiles = Array.isArray(manifest.createdFiles) ? manifest.createdFiles : [];
createdFiles
.slice()
.reverse()
.forEach((relativePath) => {
const target = path.join(out, normalizeRelativePath(relativePath));
if (fs.existsSync(target)) fs.rmSync(target, { force: true });
});
const backupFiles = Array.isArray(manifest.backupFiles) ? manifest.backupFiles : [];
backupFiles.forEach((backup) => {
if (!backup || !backup.relativePath || !backup.backupPath) return;
const target = path.join(out, normalizeRelativePath(backup.relativePath));
ensureDir(path.dirname(target));
fs.copyFileSync(backup.backupPath, target);
});
console.log(JSON.stringify({ ok: true, rollback: manifestPath, removed: createdFiles.length }, null, 2));
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.rollback) {
rollbackManifest(args.rollback);
return;
}
const fixture = loadFixture(args.fixture);
const plan = buildMigrationPlan(fixture, args.out);
const conflicts = detectConflicts(args.out, plan.operations);
const manifest = manifestForPlan(args.out, plan, args.dryRun, conflicts);
const manifestPath = args.manifest || path.join(args.out, ".mnote", "migration-manifest.json");
writeConflictReport(args.conflictReport, args.out, conflicts);
if (args.dryRun) {
manifest.indexRefresh.status = "planned";
writeJson(manifestPath, manifest);
console.log(JSON.stringify({ ok: true, dryRun: true, out: args.out, conflicts: conflicts.length }, null, 2));
return;
}
if (conflicts.length) {
writeJson(manifestPath, manifest);
console.error(JSON.stringify({ ok: false, code: "migration_conflict", conflicts }, null, 2));
process.exit(2);
}
applyPlan(args.out, plan, manifest);
writeJson(manifestPath, manifest);
if (path.resolve(manifestPath) !== path.resolve(path.join(args.out, ".mnote", "migration-manifest.json"))) {
writeJson(path.join(args.out, ".mnote", "migration-manifest.json"), manifest);
}
console.log(JSON.stringify({ ok: true, out: args.out, manifest: manifestPath }, null, 2));
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
}
}
@@ -1,101 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..", "..", "..");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-convex-export-"));
const fixturePath = path.join(tmpRoot, "fixture.json");
const outRoot = path.join(tmpRoot, "local-workspace");
const fixture = {
workspace: {
id: "ws_legacy_1",
name: "旧云端空间",
ownerId: "user_1",
},
documents: [
{
id: "doc_root",
title: "Project",
parent_id: null,
sort_order: 1,
content: "# Project\n\n![logo](/api/media/sign?assetId=asset_logo)\n",
wide_layout: true,
created_at: "2026-05-01T00:00:00.000Z",
updated_at: "2026-05-02T00:00:00.000Z",
},
{
id: "doc_child",
title: "Child Spec",
parent_id: "doc_root",
sort_order: 1,
content: "Child body with [file](/api/media/sign?assetId=asset_pdf).\n",
created_at: "2026-05-01T00:00:00.000Z",
updated_at: "2026-05-02T00:00:00.000Z",
},
],
mediaAssets: [
{
id: "asset_logo",
fileName: "logo.png",
contentBase64: Buffer.from("PNG-FIXTURE").toString("base64"),
},
{
id: "asset_pdf",
fileName: "spec.pdf",
content: "PDF-FIXTURE",
},
],
aiSessions: [
{
sessionId: "sess_1",
visibility: "private",
events: [
{ eventType: "session.created", sessionId: "sess_1", userId: "user_1" },
{ eventType: "run.completed", sessionId: "sess_1", status: "completed" },
],
},
],
};
fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8");
const result = spawnSync(
process.execPath,
[
path.join(__dirname, "export-convex-workspace-to-local.js"),
"--fixture",
fixturePath,
"--out",
outRoot,
],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr || result.stdout);
const projectMd = fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8");
const childMd = fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.md"), "utf8");
const pageIds = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-ids.json"), "utf8"));
const pageOptions = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-options.json"), "utf8"));
const resourceIndex = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "resource-index.json"), "utf8"));
const sessionJsonl = fs.readFileSync(path.join(outRoot, "ai-sessions", "private", "sess_1.jsonl"), "utf8");
assert.match(projectMd, /mnote_id: doc_root/);
assert.match(projectMd, /!\[logo\]\(Project\.assets\/logo\.png\)/);
assert.match(childMd, /\[file\]\(Child Spec\.assets\/spec\.pdf\)/);
assert.equal(pageIds.pages["pages/Project.md"], "local-mdid:doc_root");
assert.equal(pageIds.pages["pages/Project/Child Spec.md"], "local-mdid:doc_child");
assert.equal(pageOptions.pages["local-mdid:doc_root"].wideLayout, true);
assert.equal(resourceIndex.assets.asset_logo.relativePath, "pages/Project.assets/logo.png");
assert.match(sessionJsonl, /"eventType":"run.completed"/);
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.assets", "logo.png"), "utf8"), "PNG-FIXTURE");
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.assets", "spec.pdf"), "utf8"), "PDF-FIXTURE");
fs.rmSync(tmpRoot, { recursive: true, force: true });
console.log(JSON.stringify({ ok: true, smoke: "task444-convex-workspace-export-local-fixture" }, null, 2));
@@ -1,108 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..", "..", "..");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-convex-export-plan-"));
const fixturePath = path.join(tmpRoot, "fixture.json");
const outRoot = path.join(tmpRoot, "local-workspace");
const manifestPath = path.join(tmpRoot, "migration-manifest.json");
const conflictPath = path.join(tmpRoot, "conflicts.json");
const fixture = {
workspace: {
id: "ws_legacy_plan",
name: "迁移计划空间",
ownerId: "user_plan",
},
documents: [
{
id: "doc_root",
title: "Project",
parent_id: null,
content: "# Project\n\n导出正文\n",
wide_layout: true,
},
],
mediaAssets: [
{
id: "asset_logo",
fileName: "logo.txt",
documentId: "doc_root",
content: "LOGO-NEW",
},
],
aiSessions: [],
};
function runExport(args, expectedStatus = 0) {
const result = spawnSync(
process.execPath,
[path.join(__dirname, "export-convex-workspace-to-local.js"), ...args],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.equal(result.status, expectedStatus, result.stderr || result.stdout);
return result;
}
try {
fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8");
runExport([
"--fixture",
fixturePath,
"--out",
outRoot,
"--dry-run",
"--manifest",
manifestPath,
"--conflict-report",
conflictPath,
]);
assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "dry run 不应写入页面文件");
const dryRunManifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.equal(dryRunManifest.dryRun, true);
assert.equal(dryRunManifest.operations.some((item) => item.action === "create" && item.relativePath === "pages/Project.md"), true);
fs.mkdirSync(path.join(outRoot, "pages"), { recursive: true });
fs.writeFileSync(path.join(outRoot, "pages", "Project.md"), "原有内容\n", "utf8");
const conflict = runExport([
"--fixture",
fixturePath,
"--out",
outRoot,
"--manifest",
manifestPath,
"--conflict-report",
conflictPath,
], 2);
assert.match(conflict.stderr + conflict.stdout, /migration_conflict/);
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), "原有内容\n");
const conflictReport = JSON.parse(fs.readFileSync(conflictPath, "utf8"));
assert.equal(conflictReport.conflicts.some((item) => item.relativePath === "pages/Project.md"), true);
fs.rmSync(outRoot, { recursive: true, force: true });
runExport([
"--fixture",
fixturePath,
"--out",
outRoot,
"--manifest",
manifestPath,
]);
assert.match(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), /导出正文/);
assert.equal(fs.existsSync(path.join(outRoot, ".mnote", "migration-manifest.json")), true);
runExport(["--rollback", path.join(outRoot, ".mnote", "migration-manifest.json")]);
assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "rollback 应删除本次新增页面");
assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.assets", "logo.txt")), false, "rollback 应删除本次新增资源");
console.log(JSON.stringify({ ok: true, smoke: "task455-convex-export-plan-rollback" }, null, 2));
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
@@ -1,234 +0,0 @@
"use strict";
// 说明:
// - 这是 task-019 的最小真实浏览器回归脚本。
// - 目标只覆盖文档页元信息、Sidebar、标题/正文保存主链,不扩大到 Mindmap / OnlyOffice。
// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 30_000;
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
headers:
init.data !== undefined
? {
"content-type": "application/json",
...(init.headers || {}),
}
: {
...(init.headers || {}),
},
timeout: REQUEST_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
const contentType = response.headers()["content-type"] || "";
if (!contentType.includes("application/json")) {
const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200);
throw new Error(
`${path} 返回了非 JSON 内容,当前回归脚本需要可直接调用的 API 会话。` +
`如果页面被重定向到 /auth 或返回 HTML,说明前端未启用 MNOTE_DEV_AUTH=1,或当前节点没有带上有效的 Convex Auth 会话。` +
`响应片段:${snippet}`,
);
}
return payload;
}
async function createTempDocument(requestContext) {
const payload = await requestJson(requestContext, "/api/documents/create", {
method: "POST",
data: { parentId: null },
});
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
return {
documentId: payload.id,
workspaceId: payload.workspace_id,
};
}
async function purgeTempDocument(requestContext, documentId) {
await requestJson(requestContext, "/api/documents/purge", {
method: "POST",
data: { documentId },
});
}
async function getViewerIdentity(requestContext) {
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
return payload;
}
async function ensureAuthenticated(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), {
timeout: UI_TIMEOUT_MS,
});
}
return await getViewerIdentity(requestContext);
}
async function runBrowserRegression(page, target) {
const uniqueSuffix = Date.now().toString();
const nextTitle = `task019-ui-${uniqueSuffix}`;
const nextBody = `task019 正文保存回归 ${uniqueSuffix}`;
const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const sidebarPanel = page.getByText("页面树");
const privateSection = page.getByText("私有 / 我的页面");
const titleInput = page.getByLabel("页面标题");
const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first();
const saveIndicator = page.locator("text=已保存");
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await titleInput.fill(nextTitle);
const titleSaveResponse = page.waitForResponse(
(response) =>
response.url().includes("/api/documents/title") &&
response.request().method() === "POST" &&
response.status() === 200,
{ timeout: UI_TIMEOUT_MS },
);
await titleInput.evaluate((node) => {
node.blur();
});
await titleSaveResponse;
const saveResponse = page.waitForResponse(
(response) =>
response.url().includes("/api/documents/save") &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes(nextBody),
{ timeout: UI_TIMEOUT_MS },
);
await editorSurface.click({ timeout: UI_TIMEOUT_MS });
await editorSurface.fill(nextBody, { timeout: UI_TIMEOUT_MS });
await saveResponse;
await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const persistedTitle = await titleInput.inputValue();
assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`);
const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS });
assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容");
return {
documentUrl,
nextTitle,
nextBody,
};
}
async function main() {
const health = await fetch(`${BASE_URL}/`, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
assert(
[200, 307, 308].includes(health.status),
`首页探活失败:收到状态码 ${health.status}`,
);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let tempDocument = null;
let regressionResult = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
tempDocument = await createTempDocument(context.request);
regressionResult = await runBrowserRegression(page, tempDocument);
console.log(
JSON.stringify(
{
ok: true,
workspaceId: tempDocument.workspaceId,
documentId: tempDocument.documentId,
...regressionResult,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (tempDocument?.documentId) {
try {
await purgeTempDocument(context.request, tempDocument.documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,492 +0,0 @@
"use strict";
// 说明:
// - 这是 task-021 的最小真实浏览器回归脚本。
// - 目标覆盖 Mindmap 全屏页、节点新增/删除、保存链与 requestId/traceId 元信息同步。
// - 脚本会创建临时页面和临时导图,回归结束后清理,避免污染现有数据。
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 30_000;
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
headers:
init.data !== undefined
? {
"content-type": "application/json",
...(init.headers || {}),
}
: {
...(init.headers || {}),
},
timeout: REQUEST_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
return payload;
}
async function createTempDocument(requestContext) {
const payload = await requestJson(requestContext, "/api/documents/create", {
method: "POST",
data: { parentId: null },
});
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
return {
documentId: payload.id,
workspaceId: payload.workspace_id,
};
}
async function createTempMindmap(requestContext, documentId, mindmapId) {
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
method: "POST",
data: {
createOnly: true,
data: {
data: { text: "中心主题" },
children: [],
},
},
});
}
async function cleanupTempMindmap(requestContext, documentId, mindmapId) {
try {
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
method: "DELETE",
});
} catch {
// 忽略清理失败,继续尝试 purge 文档。
}
}
async function purgeTempDocument(requestContext, documentId) {
await requestJson(requestContext, "/api/documents/purge", {
method: "POST",
data: { documentId },
});
}
async function getViewerIdentity(requestContext) {
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
return payload;
}
async function ensureAuthenticated(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), {
timeout: UI_TIMEOUT_MS,
});
}
return await getViewerIdentity(requestContext);
}
async function waitForMindmapInstance(page, mindmapId) {
await page.waitForFunction(
(id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance),
mindmapId,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForMindmapReady(page, mindmapId) {
await page.waitForFunction(
(id) => {
const instance = window.__mindmapInstancesById?.[id] || window.__mindmapInstance;
const persist = window.__mindmapPersistById?.[id];
const fullscreen = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
const requestId = fullscreen?.getAttribute("data-request-id");
const traceId = fullscreen?.getAttribute("data-trace-id");
return Boolean(instance && persist && requestId && traceId);
},
mindmapId,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readMindmapMetaAttrs(page) {
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
return {
documentId: await fullscreen.getAttribute("data-document-id"),
pageId: await fullscreen.getAttribute("data-page-id"),
attachmentId: await fullscreen.getAttribute("data-attachment-id"),
mindmapId: await fullscreen.getAttribute("data-mindmap-id"),
workspaceId: await fullscreen.getAttribute("data-workspace-id"),
requestId: await fullscreen.getAttribute("data-request-id"),
traceId: await fullscreen.getAttribute("data-trace-id"),
};
}
async function waitForMetaAttrs(page, meta) {
await page.waitForFunction(
({ requestId, traceId }) => {
const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
if (!el) return false;
return (
el.getAttribute("data-request-id") === requestId &&
el.getAttribute("data-trace-id") === traceId
);
},
{
requestId: meta.requestId,
traceId: meta.traceId,
},
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForMetaMutation(page, previousMeta) {
await page.waitForFunction(
({ requestId, traceId }) => {
const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
if (!el) return false;
const nextRequestId = el.getAttribute("data-request-id");
const nextTraceId = el.getAttribute("data-trace-id");
return Boolean(
nextRequestId &&
nextTraceId &&
nextRequestId !== requestId &&
nextTraceId !== traceId,
);
},
{
requestId: previousMeta.requestId,
traceId: previousMeta.traceId,
},
{ timeout: UI_TIMEOUT_MS },
);
return await readMindmapMetaAttrs(page);
}
async function waitForMindmapState(requestContext, documentId, mindmapId, check, description) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let lastPayload = null;
while (Date.now() < deadline) {
lastPayload = await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`);
if (check(lastPayload)) {
return lastPayload;
}
await sleep(500);
}
throw new Error(`${description} 超时:${JSON.stringify(lastPayload)}`);
}
function assertRouteMeta(meta, expected) {
assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId");
assert(meta && typeof meta.traceId === "string" && meta.traceId, "缺少 meta.traceId");
assert(meta.documentId === expected.documentId, `documentId 不一致:${meta.documentId}`);
assert(meta.pageId === expected.documentId, `pageId 不一致:${meta.pageId}`);
assert(meta.mindmapId === expected.mindmapId, `mindmapId 不一致:${meta.mindmapId}`);
assert(meta.attachmentId === expected.mindmapId, `attachmentId 不一致:${meta.attachmentId}`);
assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`);
}
async function persistInsertAndRename(page, mindmapId) {
return page.evaluate(
({ currentMindmapId }) => {
const instance =
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
if (!instance) {
throw new Error("未找到 mindmap 实例");
}
const renderer = instance.renderer;
const root = renderer?.root ?? renderer?.renderTree?._node;
if (!root) {
throw new Error("未找到根节点");
}
renderer?.clearActiveNodeList?.();
renderer?.addNodeToActiveList?.(root, true);
renderer.lastActiveNodeList = [root];
renderer?.emitNodeActiveEvent?.(root);
instance.execCommand?.("SET_NODE_ACTIVE", root, true);
instance.execCommand?.("INSERT_CHILD_NODE", false, [root]);
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
if (!snapshot?.root?.children?.[0]?.data) {
throw new Error("插入子节点后未拿到快照");
}
const persist = window.__mindmapPersistById?.[currentMindmapId];
if (!persist) {
throw new Error("未找到 mindmap 持久化回调");
}
persist(snapshot);
return {
childText: String(snapshot.root.children[0].data.text ?? ""),
childCount: snapshot.root.children.length,
};
},
{
currentMindmapId: mindmapId,
},
);
}
async function persistDeleteChild(page, mindmapId, childUid) {
return page.evaluate(async ({ currentMindmapId, currentChildUid }) => {
const instance =
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
if (!instance) {
throw new Error("未找到 mindmap 实例");
}
const renderer = instance.renderer;
const child =
typeof renderer?.findNodeByUid === "function"
? renderer.findNodeByUid(currentChildUid)
: null;
if (!child) {
throw new Error("删除子节点时未找到目标节点");
}
renderer?.clearActiveNodeList?.();
renderer?.addNodeToActiveList?.(child, true);
renderer.lastActiveNodeList = [child];
renderer?.emitNodeActiveEvent?.(child);
instance.execCommand?.("SET_NODE_ACTIVE", child, true);
await new Promise((resolve) => window.setTimeout(resolve, 0));
instance.execCommand?.("REMOVE_NODE");
await new Promise((resolve) => window.setTimeout(resolve, 0));
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
const root = snapshot?.root ?? snapshot;
return {
childCount:
root && typeof root === "object" && Array.isArray(root.children)
? root.children.length
: -1,
};
}, { currentMindmapId: mindmapId, currentChildUid: childUid });
}
async function openOutlinePanel(page) {
const outlineButton = page.getByRole("button", { name: "大纲" });
await outlineButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await outlineButton.click();
}
async function runBrowserRegression(page, requestContext, target) {
const uniqueSuffix = Date.now().toString();
const mindmapId = `task021-${uniqueSuffix}`;
const defaultChildText = "二级节点";
try {
await createTempMindmap(requestContext, target.documentId, mindmapId);
const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`;
await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
const canvas = page.locator("[data-testid=\"mindmap-canvas\"]");
const rootText = page.getByText("中心主题").first();
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await waitForMindmapInstance(page, mindmapId);
await waitForMindmapReady(page, mindmapId);
const initialMetaAttrs = await readMindmapMetaAttrs(page);
assert(initialMetaAttrs.documentId === target.documentId, "页面 data-document-id 不正确");
assert(initialMetaAttrs.pageId === target.documentId, "页面 data-page-id 不正确");
assert(initialMetaAttrs.mindmapId === mindmapId, "页面 data-mindmap-id 不正确");
assert(initialMetaAttrs.attachmentId === mindmapId, "页面 data-attachment-id 不正确");
assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确");
const insertMutation = await persistInsertAndRename(page, mindmapId);
assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`);
assert(insertMutation.childText, "插入子节点后名称为空");
const insertSaveMeta = await waitForMetaMutation(page, initialMetaAttrs);
assertRouteMeta(insertSaveMeta, {
documentId: target.documentId,
mindmapId,
workspaceId: target.workspaceId,
});
await waitForMindmapState(
requestContext,
target.documentId,
mindmapId,
(payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 1,
"插入子节点后后端回查",
);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await waitForMindmapInstance(page, mindmapId);
await waitForMindmapReady(page, mindmapId);
const beforeDeleteMeta = await readMindmapMetaAttrs(page);
const insertSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
assert(
Array.isArray(insertSavedMindmap.data?.children) &&
insertSavedMindmap.data.children.length === 1,
"刷新后导图子节点数量不正确",
);
assert(
typeof insertSavedMindmap.data.children[0]?.data?.text === "string" &&
insertSavedMindmap.data.children[0].data.text.trim(),
"刷新后导图子节点名称为空",
);
const persistedChildText =
String(insertSavedMindmap.data.children[0]?.data?.text ?? "")
.replace(/<[^>]+>/g, "")
.trim() || defaultChildText;
const persistedChildUid = String(insertSavedMindmap.data.children[0]?.data?.uid ?? "");
assert(persistedChildUid, "刷新后导图子节点缺少 uid");
await openOutlinePanel(page);
await page.getByText(persistedChildText).first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const deleteMutation = await persistDeleteChild(page, mindmapId, persistedChildUid);
assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`);
const deleteSaveMeta = await waitForMetaMutation(page, beforeDeleteMeta);
assertRouteMeta(deleteSaveMeta, {
documentId: target.documentId,
mindmapId,
workspaceId: target.workspaceId,
});
await waitForMindmapState(
requestContext,
target.documentId,
mindmapId,
(payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 0,
"删除子节点后后端回查",
);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await waitForMindmapInstance(page, mindmapId);
await waitForMindmapReady(page, mindmapId);
await openOutlinePanel(page);
await page.getByText("中心主题").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const deleteSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
assert(
Array.isArray(deleteSavedMindmap.data?.children) &&
deleteSavedMindmap.data.children.length === 0,
"删除子节点后后端仍保留子节点",
);
const canvasText = await canvas.innerText({ timeout: UI_TIMEOUT_MS });
assert(!canvasText.includes(persistedChildText), "删除子节点后画布仍残留旧节点文本");
return {
mindmapUrl,
mindmapId,
childText: persistedChildText,
initialMetaAttrs,
insertMeta: insertSaveMeta,
deleteMeta: deleteSaveMeta,
};
} finally {
await cleanupTempMindmap(requestContext, target.documentId, mindmapId);
}
}
async function main() {
const health = await fetch(`${BASE_URL}/`, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
assert(
[200, 307, 308].includes(health.status),
`首页探活失败:收到状态码 ${health.status}`,
);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let tempDocument = null;
let regressionResult = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
tempDocument = await createTempDocument(context.request);
regressionResult = await runBrowserRegression(page, context.request, tempDocument);
console.log(
JSON.stringify(
{
ok: true,
workspaceId: tempDocument.workspaceId,
documentId: tempDocument.documentId,
...regressionResult,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (tempDocument?.documentId) {
try {
await purgeTempDocument(context.request, tempDocument.documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,414 +0,0 @@
"use strict";
// 说明:
// - 这是 task-022 的最小真实浏览器回归脚本。
// - 目标覆盖 OnlyOffice 页面打开、插件桥接插入文本、forcesave 按钮、callback 写回闭环。
// - 脚本会创建临时页面并上传临时 docx,回归结束后 purge 页面,避免污染现有数据。
const fs = require("node:fs");
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 120_000;
const CALLBACK_TIMEOUT_MS = 90_000;
const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || "/tmp/mnote-onlyoffice-probe/probe.docx";
const ONLYOFFICE_PLUGIN_CHANNEL = "mnote_onlyoffice_agent_tools_v1";
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function requestPayload(requestContext, path, init = {}) {
const headers =
init.multipart || init.form
? { ...(init.headers || {}) }
: init.data !== undefined
? {
"content-type": "application/json",
...(init.headers || {}),
}
: { ...(init.headers || {}) };
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
headers,
timeout: REQUEST_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
return payload;
}
async function createTempDocument(requestContext) {
const payload = await requestPayload(requestContext, "/api/documents/create", {
method: "POST",
data: { parentId: null },
});
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
return {
documentId: payload.id,
workspaceId: payload.workspace_id,
};
}
async function purgeTempDocument(requestContext, documentId) {
await requestPayload(requestContext, "/api/documents/purge", {
method: "POST",
data: { documentId },
});
}
async function getViewerIdentity(requestContext) {
const payload = await requestPayload(requestContext, "/api/auth/whoami", { method: "GET" });
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
return payload;
}
async function uploadProbeDocx(requestContext, target) {
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`);
const buffer = fs.readFileSync(PROBE_DOCX_PATH);
const payload = await requestPayload(requestContext, "/api/media/upload", {
method: "POST",
multipart: {
file: {
name: "task022-probe.docx",
mimeType: DOCX_MIME,
buffer,
},
workspaceId: target.workspaceId,
documentId: target.documentId,
},
});
assert(payload && payload.asset && typeof payload.asset.id === "string", "上传探测 docx 失败:缺少 asset.id");
return payload.asset;
}
async function getSignedAsset(requestContext, assetId) {
const payload = await requestPayload(requestContext, `/api/media/sign?assetId=${encodeURIComponent(assetId)}`, {
method: "GET",
});
assert(payload && typeof payload.signedUrl === "string" && payload.signedUrl, "缺少 signedUrl");
return payload;
}
async function ensureAuthenticated(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), {
timeout: UI_TIMEOUT_MS,
});
}
return await getViewerIdentity(requestContext);
}
async function installPluginBridge(page) {
await page.addInitScript(
({ channel }) => {
const state = {
channel,
ready: false,
origin: "*",
target: null,
pending: new Map(),
};
window.__TASK022_ONLYOFFICE_PLUGIN__ = state;
window.addEventListener("message", (event) => {
const data = event?.data;
if (!data || typeof data !== "object") return;
if (data.channel !== channel) return;
if (data.type === "ready") {
state.ready = true;
state.origin = String(event.origin || "*");
state.target =
event.source && typeof event.source.postMessage === "function" ? event.source : null;
return;
}
if (data.type === "result") {
const callId = String(data.callId || "").trim();
if (!callId) return;
const pending = state.pending.get(callId);
if (!pending) return;
state.pending.delete(callId);
window.clearTimeout(pending.timeoutId);
if (data.ok) {
pending.resolve(data.result ?? null);
} else {
pending.reject(new Error(String(data.error || "插件执行失败")));
}
}
});
},
{ channel: ONLYOFFICE_PLUGIN_CHANNEL },
);
}
async function waitForOnlyOfficeReady(page) {
await page.waitForFunction(() => window.__MNOTE_ONLYOFFICE_READY__ === true, {
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const root = document.getElementById("onlyoffice-frame");
if (root && root.querySelector("iframe,canvas")) return true;
return Boolean(document.querySelector("iframe,canvas"));
},
{ timeout: UI_TIMEOUT_MS },
);
}
function getEditorIframe(page) {
return page.locator('iframe[src*="/documenteditor/main/index.html"]').first();
}
async function waitForPluginReady(page) {
await page.waitForFunction(
() => Boolean(window.__TASK022_ONLYOFFICE_PLUGIN__?.ready && window.__TASK022_ONLYOFFICE_PLUGIN__?.target),
{ timeout: UI_TIMEOUT_MS },
);
}
async function callOnlyOfficePlugin(page, tool, args) {
return page.evaluate(
async ({ channel, toolName, toolArgs }) => {
const state = window.__TASK022_ONLYOFFICE_PLUGIN__;
if (!state || !state.ready || !state.target) {
throw new Error("OnlyOffice 插件桥未就绪");
}
const callId = `task022-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return await new Promise((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
state.pending.delete(callId);
reject(new Error(`插件调用超时: ${toolName}`));
}, 60_000);
state.pending.set(callId, { resolve, reject, timeoutId });
state.target.postMessage(
{
channel,
type: "call",
callId,
tool: toolName,
args: toolArgs,
},
state.origin || "*",
);
});
},
{
channel: ONLYOFFICE_PLUGIN_CHANNEL,
toolName: tool,
toolArgs: args,
},
);
}
async function getOnlyOfficeDebug(page) {
return page.evaluate(() => ({
ready: Boolean(window.__MNOTE_ONLYOFFICE_READY__),
debug: window.__MNOTE_ONLYOFFICE_DEBUG__ ?? null,
errlog: window.__MNOTE_ONLYOFFICE_ERRLOG__ ?? [],
}));
}
async function waitForStorageIdChange(requestContext, assetId, previousStorageId) {
const startedAt = Date.now();
while (Date.now() - startedAt < CALLBACK_TIMEOUT_MS) {
const payload = await getSignedAsset(requestContext, assetId);
const nextStorageId = String(payload.asset?.storage_id || "").trim();
if (nextStorageId && nextStorageId !== previousStorageId) {
return payload;
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
throw new Error(`等待 callback 写回超时:storage_id 仍为 ${previousStorageId || "<empty>"}`);
}
async function runBrowserRegression(page, requestContext, viewer, target) {
const asset = await uploadProbeDocx(requestContext, target);
const initialSigned = await getSignedAsset(requestContext, asset.id);
const initialStorageId = String(initialSigned.asset?.storage_id || "").trim();
assert(initialStorageId, "初始 storage_id 为空");
const uniqueSuffix = Date.now().toString();
const insertedText = ` task022-onlyoffice-${uniqueSuffix} `;
await installPluginBridge(page);
try {
const pageUrl = new URL("/onlyoffice", BASE_URL);
pageUrl.searchParams.set("fileUrl", String(initialSigned.signedUrl));
pageUrl.searchParams.set("fileName", "task022-probe.docx");
pageUrl.searchParams.set("fileType", "docx");
pageUrl.searchParams.set("mode", "edit");
pageUrl.searchParams.set("assetId", asset.id);
pageUrl.searchParams.set("documentId", target.documentId);
pageUrl.searchParams.set("userId", viewer.userId);
pageUrl.searchParams.set("channel", "web");
await page.goto(pageUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.getByText("ONLYOFFICE 加载失败").waitFor({ state: "hidden", timeout: 5_000 }).catch(() => null);
await waitForOnlyOfficeReady(page);
await waitForPluginReady(page);
const editorIframe = getEditorIframe(page);
await editorIframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await editorIframe.click({ position: { x: 160, y: 120 }, timeout: UI_TIMEOUT_MS });
const initialDebug = await getOnlyOfficeDebug(page);
assert(initialDebug.ready === true, "OnlyOffice ready 标记未就绪");
assert(initialDebug.debug && initialDebug.debug.assetId === asset.id, "OnlyOffice debug.assetId 不正确");
assert(initialDebug.debug && initialDebug.debug.documentId === target.documentId, "OnlyOffice debug.documentId 不正确");
assert(initialDebug.debug && initialDebug.debug.baseUrl === "/onlyoffice-server", `OnlyOffice baseUrl 异常:${JSON.stringify(initialDebug.debug)}`);
assert(
initialDebug.debug && typeof initialDebug.debug.resolvedFileUrl === "string" && initialDebug.debug.resolvedFileUrl.includes("/api/onlyoffice/proxy"),
`OnlyOffice resolvedFileUrl 未走 proxy${JSON.stringify(initialDebug.debug)}`,
);
assert(initialDebug.debug && typeof initialDebug.debug.docKey === "string" && initialDebug.debug.docKey, "OnlyOffice debug.docKey 为空");
const pluginResult = await callOnlyOfficePlugin(page, "oo_insert_text", { text: insertedText });
assert(pluginResult && pluginResult.ok === true, `插件插入文本失败:${JSON.stringify(pluginResult)}`);
await page.waitForTimeout(2_000);
const forceSaveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/api/onlyoffice/forcesave?assetId=${encodeURIComponent(asset.id)}`) &&
response.request().method() === "POST" &&
response.status() === 200,
{ timeout: UI_TIMEOUT_MS },
);
const forceSaveButton = page.getByRole("button", { name: "同步保存" });
await forceSaveButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await forceSaveButton.click({ timeout: UI_TIMEOUT_MS });
const forceSaveResponse = await forceSaveResponsePromise;
const forceSavePayload = await forceSaveResponse.json();
assert(forceSavePayload && forceSavePayload.ok === true, `forcesave 返回异常:${JSON.stringify(forceSavePayload)}`);
await page.getByText("已触发同步保存").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const updatedSigned = await waitForStorageIdChange(requestContext, asset.id, initialStorageId);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForOnlyOfficeReady(page);
await waitForPluginReady(page);
const reloadDebug = await getOnlyOfficeDebug(page);
assert(reloadDebug.ready === true, "刷新后 OnlyOffice ready 标记未就绪");
assert(
String(updatedSigned.asset?.storage_id || "").trim() !== initialStorageId,
"callback 写回后 storage_id 未发生变化",
);
return {
pageUrl: pageUrl.toString(),
assetId: asset.id,
initialStorageId,
updatedStorageId: String(updatedSigned.asset?.storage_id || "").trim(),
insertedText,
debug: reloadDebug.debug,
errlog: reloadDebug.errlog,
};
} catch (error) {
const debug = await getOnlyOfficeDebug(page).catch(() => null);
if (debug) {
console.error(JSON.stringify({ onlyofficeDebug: debug }, null, 2));
}
throw error;
}
}
async function main() {
const health = await fetch(`${BASE_URL}/`, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
assert([200, 307, 308].includes(health.status), `首页探活失败:收到状态码 ${health.status}`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let tempDocument = null;
let caughtError = null;
try {
const viewer = await ensureAuthenticated(page, context.request);
tempDocument = await createTempDocument(context.request);
const result = await runBrowserRegression(page, context.request, viewer, tempDocument);
console.log(
JSON.stringify(
{
ok: true,
workspaceId: tempDocument.workspaceId,
documentId: tempDocument.documentId,
...result,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (tempDocument?.documentId) {
try {
await purgeTempDocument(context.request, tempDocument.documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});