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:
@@ -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);
|
||||
});
|
||||
-170
@@ -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
|
||||
Owner:07-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 permission;MCP/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 island;Page 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 文本知识源做短知识。
|
||||
- Citation:WeKnora 结果必须保留 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 B:OpenHub-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 session,directory 固定为当前打开 rootUri。
|
||||
- [ ] `/query/stream` 转发到 opencode prompt_async + global event。
|
||||
- [ ] 保存 user/assistant/tool/diff 消息。
|
||||
- [ ] 解析 changed files 并驱动 MNote changed file chips。
|
||||
|
||||
### Phase D:MNote 文件与刷新融合
|
||||
|
||||
- [ ] changed file chip 点击走 `openResourceInActiveTab()`。
|
||||
- [ ] 当前页被修改后调用 `refreshPrimaryDocument()` 或 watcher 刷新链路。
|
||||
- [ ] DiffViewer 中所有 file path 点击都映射到 MNote resource/file open。
|
||||
- [ ] 文件路径必须限制在当前 rootUri / allowed roots 内。
|
||||
|
||||
### Phase E:Knowledge / 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 修改测试 Markdown,MNote 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 工具/知识底座边界。
|
||||
-280
@@ -1,280 +0,0 @@
|
||||
# [stale] 7-68 OpenHub + WeKnora + MNote 深度融合执行 Checklist v1
|
||||
|
||||
状态:stale / reference-only(不再作为当前 process 主线)
|
||||
Owner:07-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 client;WeKnora 保留 KB/API/CLI/MCP/search/index;MNote 保留 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 真相在 SQLite,MNote 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 自带 fallback,MNote 集成时必须由 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`。
|
||||
- [~] smoke:OpenHub 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 passed(base 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 message,Owner 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 smoke:OpenHub session/message 已持久化并可读;刷新后 UI history 恢复已完成真实浏览器验收。
|
||||
|
||||
P1 Page AI host 记录:
|
||||
|
||||
- `sidebar-page-ai-runtime.js` 默认打开 OpenHub host drawer,iframe 指向 `/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` basename,API 默认走 `/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` 发送真实 stream,OpenHub 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 可见同一 marker;OpenHub 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 内测试 Markdown,MNote 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 fixture;OpenHub 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 bridge;MNote 侧复用 `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 source,scoped 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 添加本地文件夹 source,source registry 映射 2 个 source,UI 指定新 KB 检索并展示 source result,API 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] smoke:WeKnora 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 fetch;LightRAG 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 权限审批。
|
||||
- [~] smoke:OpenHub 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 可写 worker:OpenHub 前端 `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` 登录 MNote;Page AI 不出现 OpenHub/WeKnora 登录。
|
||||
- [x] Page AI 可发送消息,OpenHub SQLite session/message/history 持久化,刷新恢复。
|
||||
- [x] AI 修改当前 rootUri 内 Markdown,MNote 当前页面刷新后可见。当前证据由 `task774` 真实 opencode 文件修改 + `task779` 浏览器模拟 OpenHub changed-file bridge 刷新组成。
|
||||
- [x] OpenHub Login/Admin/FileManager/KnowledgeManager/GitTimeMachine 非 AI 入口不接管 MNote。
|
||||
- [x] WeKnora 知识库页完成 KB 创建、source 添加、状态显示、检索、citation 回跳。
|
||||
- [x] 旧 LightRAG 默认入口不再出现在主 UI/smoke;legacy 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 bridge,document 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` ready,opencode `127.0.0.1:4096` ready,WeKnora public `/health` ready,OpenHub `/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 summary(KB/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 为 WeKnora,LightRAG 仅作为显式 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 写链。
|
||||
-809
@@ -1,809 +0,0 @@
|
||||
# [stale] 7-68 OpenHub + WeKnora + MNote Page AI 嵌入式集成设计 v1
|
||||
|
||||
状态:stale / reference-only(不再作为当前 process 主线)
|
||||
Owner:07-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 runtime:OpenHub FastAPI 调用 opencode serve / opencode provider
|
||||
├─ Knowledge provider:WeKnora,通过 MNote 注册给 OpenHub/opencode 的 MCP/CLI/skill 工具调用
|
||||
└─ MNote boundary:统一登录授权、workspace/rootUri scope、文件打开、citation 回跳
|
||||
```
|
||||
|
||||
核心取舍:
|
||||
|
||||
总原则:**各部分尽量保持原有已完成能力,只有发生产品真相冲突时才做最小胶水/嫁接/裁剪**。OpenHub 保持 AI 面板、FastAPI、Redis、SQLite session/message、skill/tool permission、opencode client;WeKnora 保持知识库底座、MCP/CLI/API;MNote 保持 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 归 MNote;OpenHub 的 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 tables;Redis 只作缓存/临时态 | WeKnora chat sessions 暂弃用 | OpenHub session/message 是 Page AI 真相;MNote 不复制消息全文 |
|
||||
| agent runtime | MNote 启停/健康检查/反代边界 | OpenHub FastAPI + Redis + opencode client | WeKnora CLI/MCP/API,agent-chat 暂弃用 | 运行 OpenHub 后端;由 OpenHub 调 opencode;MNote 不重写 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 permissions;MCP 走 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 用户与 workspace;MCP/tool scope 由 MNote 注册的 WeKnora/opencode 工具配置体现;不能所有 MNote 用户共用一个 OpenHub runtime identity。
|
||||
- **WeKnora 是唯一知识库底座,并通过 MCP / CLI / API 暴露给 OpenHub/opencode**:它只接收 MNote 已授权 workspace 的文件/文件夹集合 ingest/search/query 或工具调用;知识库可见性本身依赖 MNote 的 source registry 和 allowed roots,WeKnora 用户认证可以保持简单。
|
||||
- **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/reference,MNote 负责 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 call;MNote 只提供受限工具配置和 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 调用 WeKnora,tool 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 自己就是 FileManager:resource 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 check;OpenHub `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
|
||||
- 当前页面 context:page path / title / selection / rootUri / allowed roots
|
||||
- OpenHub scope:user/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 scope;scope 在注册 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 默认关闭,并在启动环境或配置中落实。
|
||||
- [ ] 写 smoke:OpenHub FastAPI 可列 session,Redis 可达,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。
|
||||
- [ ] 写浏览器 smoke:Page 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 内 Markdown,MNote 当前页面可看到变更。
|
||||
|
||||
### 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 决定本地路径。
|
||||
- [ ] 写真实 smoke:OpenHub 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 内 Markdown,MNote 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 |
|
||||
| 权限 | 模型/工具/skill;MCP 由工具配置承接 | 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 工作目录过大导致找不到文件
|
||||
|
||||
防线:工作目录固定为当前打开 rootUri;context 中传相对 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/`
|
||||
-1351
File diff suppressed because it is too large
Load Diff
-4
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
require("./task779-openhub-file-edit-document-pane-refresh-smoke.js");
|
||||
-46
@@ -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.');
|
||||
-98
@@ -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.');
|
||||
-135
@@ -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.');
|
||||
-553
@@ -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);
|
||||
});
|
||||
-294
@@ -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);
|
||||
});
|
||||
-255
@@ -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);
|
||||
});
|
||||
-234
@@ -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);
|
||||
});
|
||||
-92
@@ -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);
|
||||
-230
@@ -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;
|
||||
});
|
||||
-219
@@ -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);
|
||||
});
|
||||
-188
@@ -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);
|
||||
});
|
||||
-555
@@ -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} 返回非 JSON:HTTP ${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);
|
||||
});
|
||||
-101
@@ -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.");
|
||||
-169
@@ -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.");
|
||||
-225
@@ -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);
|
||||
});
|
||||
-458
@@ -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);
|
||||
}
|
||||
}
|
||||
-101
@@ -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\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));
|
||||
-108
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user