From 6cf8c63a9a30a3570448df6ef0b2766408fa874f Mon Sep 17 00:00:00 2001 From: fishyuemail Date: Thu, 10 Sep 2026 15:04:51 +0800 Subject: [PATCH 01/40] feat(jwt-auth): enhance SSO authentication to support agent tokens --- .../src/common/guards/jwt-auth.guard.spec.ts | 192 ++++++++++++++++++ .../src/common/guards/jwt-auth.guard.ts | 72 ++++++- 2 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/common/guards/jwt-auth.guard.spec.ts diff --git a/apps/server/src/common/guards/jwt-auth.guard.spec.ts b/apps/server/src/common/guards/jwt-auth.guard.spec.ts new file mode 100644 index 00000000..050405ed --- /dev/null +++ b/apps/server/src/common/guards/jwt-auth.guard.spec.ts @@ -0,0 +1,192 @@ +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { JwtAuthGuard } from './jwt-auth.guard'; + +// The guard lazily `require`s the EE service; stub the module so tests do not +// pull in the enterprise dependency chain. moduleRef.get returns the fake below. +jest.mock('../../ee/sso/hoidc.service', () => ({ HoidcService: class {} })); + +describe('JwtAuthGuard SSO / iself-agent auth', () => { + const reflector = { getAllAndOverride: jest.fn() }; + const environmentService = { + getHoidcSsoApi: jest.fn(), + getHoidcPlatformId: jest.fn(), + isCloud: jest.fn(), + }; + const hoidcService = { + verifyToken: jest.fn(), + verifyAgentToken: jest.fn(), + }; + const moduleRef = { get: jest.fn() }; + const userRepo = { findByEmail: jest.fn() }; + + const guard = new JwtAuthGuard( + reflector as any, + environmentService as any, + moduleRef as any, + userRepo as any, + ); + + const activeUser = { id: 'u1', deletedAt: null, deactivatedAt: null }; + + const makeContext = (opts: { + xToken?: string; + agent?: string; + workspace?: { id: string } | undefined; + }) => { + const request: any = { + headers: {}, + raw: { workspace: opts.workspace }, + }; + if (opts.xToken !== undefined) request.headers['x-token'] = opts.xToken; + if (opts.agent !== undefined) request.headers['x-iself-agent'] = opts.agent; + const context = { + getHandler: () => undefined, + getClass: () => undefined, + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => ({ setCookie: jest.fn() }), + }), + } as unknown as ExecutionContext; + return { context, request }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + reflector.getAllAndOverride.mockReturnValue(false); + environmentService.getHoidcSsoApi.mockReturnValue('https://sso.example'); + environmentService.getHoidcPlatformId.mockReturnValue('platform-1'); + environmentService.isCloud.mockReturnValue(false); + moduleRef.get.mockReturnValue(hoidcService); + }); + + it('authenticates a regular SSO token without touching the agent verifier', async () => { + hoidcService.verifyToken.mockResolvedValue({ + email: 'user@example.com', + name: 'User', + avatar: null, + }); + userRepo.findByEmail.mockResolvedValue(activeUser); + + const { context, request } = makeContext({ + xToken: 'sso-token', + workspace: { id: 'ws-1' }, + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(hoidcService.verifyAgentToken).not.toHaveBeenCalled(); + expect(request.user).toEqual({ user: activeUser, workspace: { id: 'ws-1' } }); + expect(request.sso).toEqual({ email: 'user@example.com' }); + expect(request.iselfAgent).toBeUndefined(); + }); + + it('falls back to the agent-token verifier when X-Iself-Agent:1 and verifyToken fails', async () => { + hoidcService.verifyToken.mockRejectedValue( + new UnauthorizedException('SSO response missing email'), + ); + hoidcService.verifyAgentToken.mockResolvedValue({ + uid: 42, + email: 'agent-user@example.com', + name: 'Agent User', + digital_employee_id: 'de-1', + target_platform_id: 'tp-1', + }); + userRepo.findByEmail.mockResolvedValue(activeUser); + + const { context, request } = makeContext({ + xToken: 'agent-token', + agent: '1', + workspace: { id: 'ws-1' }, + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(hoidcService.verifyAgentToken).toHaveBeenCalledWith( + { ssoApi: 'https://sso.example', platformId: 'platform-1' }, + 'agent-token', + ); + expect(userRepo.findByEmail).toHaveBeenLastCalledWith( + 'agent-user@example.com', + 'ws-1', + ); + expect(request.user).toEqual({ user: activeUser, workspace: { id: 'ws-1' } }); + expect(request.sso).toEqual({ email: 'agent-user@example.com' }); + expect(request.iselfAgent).toEqual({ + uid: 42, + digitalEmployeeId: 'de-1', + targetPlatformId: 'tp-1', + }); + }); + + it('rejects when the agent token itself is invalid', async () => { + hoidcService.verifyToken.mockRejectedValue( + new UnauthorizedException('SSO response missing email'), + ); + hoidcService.verifyAgentToken.mockRejectedValue( + new UnauthorizedException('Invalid digital employee token'), + ); + + const { context } = makeContext({ + xToken: 'bad-agent-token', + agent: '1', + workspace: { id: 'ws-1' }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + 'Invalid digital employee token', + ); + }); + + it('rejects an agent whose email is not a member of the workspace', async () => { + hoidcService.verifyToken.mockRejectedValue( + new UnauthorizedException('SSO response missing email'), + ); + hoidcService.verifyAgentToken.mockResolvedValue({ + uid: 42, + email: 'stranger@example.com', + name: null, + }); + userRepo.findByEmail.mockResolvedValue(null); + + const { context } = makeContext({ + xToken: 'agent-token', + agent: '1', + workspace: { id: 'ws-1' }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + 'SSO user is not a member of this workspace', + ); + }); + + it('does NOT fall back to the agent verifier without X-Iself-Agent:1', async () => { + hoidcService.verifyToken.mockRejectedValue( + new UnauthorizedException('SSO response missing email'), + ); + + const { context } = makeContext({ + xToken: 'sso-token', + workspace: { id: 'ws-1' }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + 'SSO response missing email', + ); + expect(hoidcService.verifyAgentToken).not.toHaveBeenCalled(); + }); + + it('ignores the agent header when its value is not exactly "1"', async () => { + hoidcService.verifyToken.mockRejectedValue( + new UnauthorizedException('SSO response missing email'), + ); + + const { context } = makeContext({ + xToken: 'sso-token', + agent: 'true', + workspace: { id: 'ws-1' }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + 'SSO response missing email', + ); + expect(hoidcService.verifyAgentToken).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/common/guards/jwt-auth.guard.ts b/apps/server/src/common/guards/jwt-auth.guard.ts index 4c75da75..8b5c5b21 100644 --- a/apps/server/src/common/guards/jwt-auth.guard.ts +++ b/apps/server/src/common/guards/jwt-auth.guard.ts @@ -36,12 +36,16 @@ export class JwtAuthGuard extends AuthGuard('jwt') { const request = context.switchToHttp().getRequest(); const tokenHeader = request.headers['x-token']; const ssoToken = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; + const agentHeader = request.headers['x-iself-agent']; + const isAgentRequest = + (Array.isArray(agentHeader) ? agentHeader[0] : agentHeader)?.trim() === + '1'; // X-Token is reserved for SSO. Do not fall through to JWT/API-key auth // when it is present: otherwise a typo or an expired SSO token could be // interpreted as an unrelated credential. if (typeof ssoToken === 'string' && ssoToken.trim()) { - await this.authenticateSso(request, ssoToken.trim()); + await this.authenticateSso(request, ssoToken.trim(), isAgentRequest); this.setJoinedWorkspacesCookie((request as any).user, context); return true; } @@ -52,6 +56,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') { private async authenticateSso( request: FastifyRequest, token: string, + isAgentRequest = false, ): Promise { const workspace = (request.raw as any)?.workspace ?? (request as any).workspace; @@ -107,6 +112,18 @@ export class JwtAuthGuard extends AuthGuard('jwt') { token, ); } catch (error) { + // iself's agent proxy forwards a digital-employee token (marked by the + // X-Iself-Agent header) that a regular access-token verify cannot parse. + // Fall back to the agent-token verifier, which hits a different upstream + // endpoint and response contract, then map it to the real workspace user. + if (isAgentRequest) { + await this.authenticateAgent(request, token, { + ssoApi, + platformId, + workspace, + }); + return; + } if (error instanceof UnauthorizedException) { throw error; } @@ -124,6 +141,59 @@ export class JwtAuthGuard extends AuthGuard('jwt') { (request as any).sso = { email: info.email }; } + private async authenticateAgent( + request: FastifyRequest, + token: string, + ctx: { ssoApi: string; platformId: string; workspace: { id: string } }, + ): Promise { + let hoidcService: { + verifyAgentToken: ( + config: { ssoApi: string; platformId: string }, + token: string, + ) => Promise<{ + uid: number; + email: string; + name: string | null; + digital_employee_id?: string; + target_platform_id?: string; + }>; + }; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { HoidcService } = require('../../ee/sso/hoidc.service'); + hoidcService = this.moduleRef.get(HoidcService, { strict: false }); + } catch { + throw new UnauthorizedException('SSO authentication is unavailable'); + } + if (!hoidcService) { + throw new UnauthorizedException('SSO authentication is unavailable'); + } + + const agentInfo = await hoidcService.verifyAgentToken( + { ssoApi: ctx.ssoApi, platformId: ctx.platformId }, + token, + ); + + const user = await this.userRepo.findByEmail( + agentInfo.email, + ctx.workspace.id, + ); + if (!user || isUserDisabled(user)) { + throw new UnauthorizedException( + 'SSO user is not a member of this workspace', + ); + } + + (request as any).user = { user, workspace: ctx.workspace }; + (request.raw as any).workspace = ctx.workspace; + (request as any).sso = { email: agentInfo.email }; + (request as any).iselfAgent = { + uid: agentInfo.uid, + digitalEmployeeId: agentInfo.digital_employee_id, + targetPlatformId: agentInfo.target_platform_id, + }; + } + handleRequest(err: any, user: any, info: any, ctx: ExecutionContext) { if (err || !user) { throw err || new UnauthorizedException(); From 4c51a21fc6628453e75ce37801d1f69eba69cd54 Mon Sep 17 00:00:00 2001 From: fishyuemail Date: Fri, 11 Sep 2026 18:00:42 +0800 Subject: [PATCH 02/40] refactor(llm-wiki): remove AI review feature --- apps/client/src/App.tsx | 2 - .../src/ee/audit/lib/audit-event-labels.ts | 25 - .../space-compilation-review-toggle.tsx | 53 - apps/client/src/ee/llm-wiki/pages/review.tsx | 1924 ----------------- .../llm-wiki/services/review-service.test.ts | 427 ---- .../ee/llm-wiki/services/review-service.ts | 366 ---- .../src/ee/llm-wiki/types/review.types.ts | 183 -- .../space/components/settings-modal.tsx | 4 - .../src/features/space/types/space.types.ts | 6 - .../repos/llm-wiki/knowledge-capsule.repo.ts | 23 - .../knowledge-review-application.repo.ts | 131 -- .../knowledge-review-snapshot.repo.ts | 70 - ...nowledge-space-reset.repo.postgres.spec.ts | 8 +- apps/server/src/ee/ee.module.ts | 2 - .../src/ee/llm-wiki/llm-wiki.module.spec.ts | 2 - .../server/src/ee/llm-wiki/llm-wiki.module.ts | 2 - .../processors/knowledge-text.processor.ts | 2 - .../server/src/ee/llm-wiki/review/approval.ts | 106 - .../review/dto/discover-review.dto.ts | 14 - .../ee/llm-wiki/review/dto/load-review.dto.ts | 6 - .../review/dto/negotiate-review.dto.ts | 24 - .../ee/llm-wiki/review/dto/plan-review.dto.ts | 6 - .../review/knowledge-artifact-wiki-source.ts | 163 -- .../review/review-apply.service.spec.ts | 636 ------ .../llm-wiki/review/review-apply.service.ts | 1061 --------- .../review/review-snapshot.service.ts | 303 --- .../llm-wiki/review/review.controller.spec.ts | 654 ------ .../ee/llm-wiki/review/review.controller.ts | 502 ----- .../src/ee/llm-wiki/review/review.module.ts | 15 - .../src/ee/llm-wiki/review/review.schema.ts | 319 --- .../ee/llm-wiki/review/review.service.spec.ts | 251 --- .../src/ee/llm-wiki/review/review.service.ts | 587 ----- .../src/ee/llm-wiki/review/search-provider.ts | 25 - .../src/ee/llm-wiki/review/structured-wiki.ts | 64 - .../src/ee/llm-wiki/review/wiki-source.ts | 7 - .../services/knowledge-queue.utils.spec.ts | 13 +- .../services/knowledge-queue.utils.ts | 20 - .../knowledge-text-job.handler.spec.ts | 57 - .../services/knowledge-text-job.handler.ts | 274 +-- .../queue/constants/queue.constants.ts | 2 - .../queue/constants/queue.interface.ts | 12 - doc/cli-mcp-capability-mapping.md | 68 - doc/skill-tool-auto-registration-design.md | 158 -- 43 files changed, 6 insertions(+), 8571 deletions(-) delete mode 100644 apps/client/src/ee/llm-wiki/components/space-compilation-review-toggle.tsx delete mode 100644 apps/client/src/ee/llm-wiki/pages/review.tsx delete mode 100644 apps/client/src/ee/llm-wiki/services/review-service.test.ts delete mode 100644 apps/client/src/ee/llm-wiki/services/review-service.ts delete mode 100644 apps/client/src/ee/llm-wiki/types/review.types.ts delete mode 100644 apps/server/src/database/repos/llm-wiki/knowledge-review-application.repo.ts delete mode 100644 apps/server/src/database/repos/llm-wiki/knowledge-review-snapshot.repo.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/approval.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/dto/discover-review.dto.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/dto/load-review.dto.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/dto/negotiate-review.dto.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/dto/plan-review.dto.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/knowledge-artifact-wiki-source.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/review-apply.service.spec.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/review-apply.service.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/review-snapshot.service.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/review.controller.spec.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/review.controller.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/review.module.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/review.schema.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/review.service.spec.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/review.service.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/search-provider.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/structured-wiki.ts delete mode 100644 apps/server/src/ee/llm-wiki/review/wiki-source.ts delete mode 100644 doc/cli-mcp-capability-mapping.md delete mode 100644 doc/skill-tool-auto-registration-design.md diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 03032d0a..9cbd3c87 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -46,7 +46,6 @@ import FavoritesPage from "@/pages/favorites/favorites-page"; import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; import KnowledgeAdminPage from "@/ee/llm-wiki/pages/knowledge-admin"; import KnowledgeGraphPage from "@/ee/llm-wiki/pages/knowledge-graph"; -import ReviewPage from "@/ee/llm-wiki/pages/review"; import VerifyEmail from "@/ee/pages/verify-email.tsx"; import LabelPage from "@/pages/label/label-page"; import { ThemeFavicon } from "@/components/common/brand-logo.tsx"; @@ -109,7 +108,6 @@ export default function App() { path={"/s/:spaceSlug/graph"} element={} /> - } /> } /> } /> diff --git a/apps/client/src/ee/audit/lib/audit-event-labels.ts b/apps/client/src/ee/audit/lib/audit-event-labels.ts index de913b94..d4f7105d 100644 --- a/apps/client/src/ee/audit/lib/audit-event-labels.ts +++ b/apps/client/src/ee/audit/lib/audit-event-labels.ts @@ -85,11 +85,6 @@ export const auditEventLabels: Record = { "knowledge.compile_queued": "Queued knowledge compile", "knowledge.compile_cancelled": "Cancelled knowledge compile", "knowledge.delayed_page_removed": "Removed delayed page from queue", - "knowledge.review_discovered": "Discovered knowledge review items", - "knowledge.review_negotiated": "Negotiated knowledge review item", - "knowledge.review_planned": "Planned knowledge review application", - "knowledge.review_applied": "Applied knowledge review application", - "knowledge.review_reverted": "Reverted knowledge review application", }; export function getEventLabel(event: string): string { @@ -225,26 +220,6 @@ export const eventFilterOptions: EventGroup[] = [ value: "knowledge.delayed_page_removed", label: "Removed delayed page from queue", }, - { - value: "knowledge.review_discovered", - label: "Discovered knowledge review items", - }, - { - value: "knowledge.review_negotiated", - label: "Negotiated knowledge review item", - }, - { - value: "knowledge.review_planned", - label: "Planned knowledge review application", - }, - { - value: "knowledge.review_applied", - label: "Applied knowledge review application", - }, - { - value: "knowledge.review_reverted", - label: "Reverted knowledge review application", - }, ], }, ]; diff --git a/apps/client/src/ee/llm-wiki/components/space-compilation-review-toggle.tsx b/apps/client/src/ee/llm-wiki/components/space-compilation-review-toggle.tsx deleted file mode 100644 index 8d71128d..00000000 --- a/apps/client/src/ee/llm-wiki/components/space-compilation-review-toggle.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Group, Switch, Text } from "@mantine/core"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useUpdateSpaceMutation } from "@/features/space/queries/space-query"; -import type { ISpace } from "@/features/space/types/space.types"; - -type Props = { - space: ISpace; -}; - -export default function SpaceCompilationReviewToggle({ space }: Props) { - const { t } = useTranslation(); - const updateSpaceMutation = useUpdateSpaceMutation(); - const enabled = - space.settings?.knowledge?.compilationReviewEnabled === true; - const [checked, setChecked] = useState(enabled); - - useEffect(() => { - setChecked(enabled); - }, [enabled, space.id]); - - const handleChange = async (event: React.ChangeEvent) => { - const value = event.currentTarget.checked; - try { - await updateSpaceMutation.mutateAsync({ - spaceId: space.id, - enableCompilationReview: value, - }); - setChecked(value); - } catch { - // error handled by mutation - } - }; - - return ( - -
- {t("Compilation content review")} - - {t( - "Allow space administrators to review compiled knowledge content with AI.", - )} - -
- -
- ); -} diff --git a/apps/client/src/ee/llm-wiki/pages/review.tsx b/apps/client/src/ee/llm-wiki/pages/review.tsx deleted file mode 100644 index 294036c2..00000000 --- a/apps/client/src/ee/llm-wiki/pages/review.tsx +++ /dev/null @@ -1,1924 +0,0 @@ -import { Fragment, useEffect, useMemo, useState } from "react"; -import { - Alert, - Anchor, - Badge, - Button, - Container, - Group, - Loader, - Paper, - SimpleGrid, - Stack, - Text, - Textarea, - TextInput, - Title, -} from "@mantine/core"; -import { notifications } from "@mantine/notifications"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { IconCheck, IconSearch, IconX } from "@tabler/icons-react"; -import { Helmet } from "react-helmet-async"; -import { useTranslation } from "react-i18next"; -import { Navigate, useParams } from "react-router-dom"; -import { getAppName } from "@/lib/config"; -import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query"; -import { useSpaceAbility } from "@/features/space/permissions/use-space-ability"; -import { - SpaceCaslAction, - SpaceCaslSubject, -} from "@/features/space/permissions/permissions.type"; -import { - applyReviewApplication, - discoverReview, - getReviewApplicationDiff, - loadReviewSnapshot, - negotiateReview, - planReviewApplication, - pollReviewJob, - revertReviewApplication, -} from "../services/review-service"; -import type { - DraftApplyOperation, - NegotiationTurn, - ReviewJob, - ReviewApplication, - ReviewApplicationDiff, - ResolvedReview, - ReviewDocMeta, - ReviewItem, - ReviewSnapshot, - ReviewType, -} from "../types/review.types"; - -const TYPE_LABEL: Record = { - "missing-page": "缺页", - suggestion: "改进建议", - contradiction: "冲突", - duplicate: "重合", -}; - -const TYPE_COLOR: Record = { - "missing-page": "blue", - suggestion: "teal", - contradiction: "red", - duplicate: "orange", -}; - -const FEEDBACK_DEEPSEARCH = "DeepSearch"; -const FEEDBACK_ACCEPT = "采纳"; -const FEEDBACK_SKIP = "暂时跳过"; - -type ItemState = { - busy?: "draft" | "plan" | "diff" | "apply" | "revert"; - resolved?: ResolvedReview; - application?: ReviewApplication; - diff?: ReviewApplicationDiff; - freeText: string; - followUpText: string; -}; - -const ARTIFACT_LIMIT = 200; - -const BUSY_LABEL: Record, string> = { - draft: "AI is generating the draft in the background ...", - plan: "Generating application preview ...", - diff: "Loading diff ...", - apply: "Applying to wiki ...", - revert: "Reverting application ...", -}; - -export default function ReviewPage() { - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const { spaceSlug = "" } = useParams(); - const { data: space, isLoading: spaceLoading } = - useGetSpaceBySlugQuery(spaceSlug); - const spaceAbility = useSpaceAbility(space?.membership?.permissions); - const canReview = spaceAbility.can( - SpaceCaslAction.Manage, - SpaceCaslSubject.Settings, - ); - const reviewEnabled = - space?.settings?.knowledge?.compilationReviewEnabled === true; - const spaceId = space?.id ?? null; - - const [items, setItems] = useState(null); - const [docMap, setDocMap] = useState>({}); - const [states, setStates] = useState>({}); - - useEffect(() => { - setItems(null); - setDocMap({}); - setStates({}); - }, [spaceId]); - - const snapshotQuery = useQuery({ - queryKey: ["review-snapshot", spaceId], - queryFn: () => loadReviewSnapshot({ spaceId: spaceId! }), - enabled: !!spaceId && canReview && reviewEnabled, - }); - const activeReviewJobs = useMemo( - () => (snapshotQuery.data?.jobs ?? []).filter(isActiveReviewJob), - [snapshotQuery.data?.jobs], - ); - const activeJobKey = useMemo( - () => - activeReviewJobs - .map((job) => `${job.jobId}:${job.status}`) - .sort() - .join("|"), - [activeReviewJobs], - ); - const isDiscoverJobRunning = activeReviewJobs.some( - (job) => job.kind === "discover", - ); - - useEffect(() => { - if (snapshotQuery.isSuccess && snapshotQuery.data) { - applySnapshot(snapshotQuery.data, setItems, setDocMap, setStates); - } - }, [snapshotQuery.data, snapshotQuery.isSuccess]); - - useEffect(() => { - if (!spaceId || activeReviewJobs.length === 0) return; - let cancelled = false; - - for (const job of activeReviewJobs) { - pollReviewJob({ spaceId, jobId: job.jobId }) - .then(async (result) => { - if (cancelled) return; - if (result.job.status === "failed") { - notifications.show({ - color: "red", - message: result.job.error || t("Review job failed"), - }); - } - const snapshot = await loadReviewSnapshot({ spaceId }); - if (cancelled || !snapshot) return; - queryClient.setQueryData(["review-snapshot", spaceId], snapshot); - applySnapshot(snapshot, setItems, setDocMap, setStates); - if ( - result.job.kind === "discover" && - result.job.status === "done" && - snapshot.items.length === 0 - ) { - notifications.show({ message: t("No review items found") }); - } - }) - .catch((error) => { - if (cancelled) return; - notifications.show({ - color: "red", - message: error instanceof Error ? error.message : String(error), - }); - }); - } - - return () => { - cancelled = true; - }; - }, [activeJobKey, activeReviewJobs, queryClient, spaceId, t]); - - const discoverMutation = useMutation({ - mutationFn: (params: { spaceId: string; limit?: number }) => - discoverReview({ - ...params, - onJob: (job) => { - queryClient.setQueryData( - ["review-snapshot", params.spaceId], - (snapshot: ReviewSnapshot | null | undefined) => - upsertJobSnapshot(snapshot, job), - ); - }, - }), - onSuccess: (snapshot) => { - if (spaceId) { - queryClient.setQueryData(["review-snapshot", spaceId], snapshot); - } - applySnapshot(snapshot, setItems, setDocMap, setStates); - if (snapshot.items.length === 0) { - notifications.show({ message: t("No review items found") }); - } - }, - onError: (error) => { - notifications.show({ color: "red", message: error.message }); - }, - }); - - const patchState = (id: string, patch: Partial) => { - setStates((prev) => ({ - ...prev, - [id]: { freeText: "", followUpText: "", ...prev[id], ...patch }, - })); - }; - - const resolveItem = async ( - item: ReviewItem, - feedback: string, - priorTurns?: NegotiationTurn[], - ) => { - if (!spaceId) return; - patchState(item.id, { busy: "draft" }); - try { - const resolved = await negotiateReview({ - spaceId, - item, - feedback, - priorTurns, - onJob: (job) => { - patchState(item.id, { busy: "draft" }); - queryClient.setQueryData( - ["review-snapshot", spaceId], - (snapshot: ReviewSnapshot | null | undefined) => - upsertJobSnapshot(snapshot, job), - ); - }, - }); - patchState(item.id, { - busy: undefined, - resolved, - application: undefined, - diff: undefined, - followUpText: "", - }); - queryClient.setQueryData( - ["review-snapshot", spaceId], - (snapshot: ReviewSnapshot | null | undefined) => - upsertResolvedSnapshot(snapshot, resolved), - ); - - if (!resolved.skipped && resolved.draft) { - patchState(item.id, { busy: "plan" }); - const application = await planReviewApplication({ - spaceId, - itemId: item.id, - }); - patchState(item.id, { - busy: undefined, - application, - diff: { - application, - beforeContent: application.beforeContent, - afterContent: application.afterContent, - }, - }); - queryClient.setQueryData( - ["review-snapshot", spaceId], - (snapshot: ReviewSnapshot | null | undefined) => - upsertApplicationSnapshot(snapshot, application), - ); - } - } catch (error) { - patchState(item.id, { busy: undefined }); - notifications.show({ - color: "red", - message: error instanceof Error ? error.message : String(error), - }); - } - }; - - const planItem = async (item: ReviewItem) => { - if (!spaceId) return; - patchState(item.id, { busy: "plan" }); - try { - const application = await planReviewApplication({ - spaceId, - itemId: item.id, - }); - patchState(item.id, { - busy: undefined, - application, - diff: { - application, - beforeContent: application.beforeContent, - afterContent: application.afterContent, - }, - }); - queryClient.setQueryData( - ["review-snapshot", spaceId], - (snapshot: ReviewSnapshot | null | undefined) => - upsertApplicationSnapshot(snapshot, application), - ); - } catch (error) { - patchState(item.id, { busy: undefined }); - notifications.show({ - color: "red", - message: error instanceof Error ? error.message : String(error), - }); - } - }; - - const previewApplication = async (item: ReviewItem) => { - const application = states[item.id]?.application; - if (!application) return; - patchState(item.id, { busy: "diff" }); - try { - const diff = await getReviewApplicationDiff({ - applicationId: application.id, - }); - patchState(item.id, { busy: undefined, diff }); - } catch (error) { - patchState(item.id, { busy: undefined }); - notifications.show({ - color: "red", - message: error instanceof Error ? error.message : String(error), - }); - } - }; - - const applyApplication = async (item: ReviewItem) => { - if (!spaceId) return; - const application = states[item.id]?.application; - if (!application) return; - patchState(item.id, { busy: "apply" }); - try { - const updated = await applyReviewApplication({ - applicationId: application.id, - }); - patchState(item.id, { - busy: undefined, - application: updated, - diff: states[item.id]?.diff - ? { ...states[item.id]!.diff!, application: updated } - : undefined, - }); - queryClient.setQueryData( - ["review-snapshot", spaceId], - (snapshot: ReviewSnapshot | null | undefined) => - upsertApplicationSnapshot(snapshot, updated), - ); - } catch (error) { - patchState(item.id, { busy: undefined }); - notifications.show({ - color: "red", - message: error instanceof Error ? error.message : String(error), - }); - } - }; - - const revertApplication = async (item: ReviewItem) => { - if (!spaceId) return; - const application = states[item.id]?.application; - if (!application) return; - patchState(item.id, { busy: "revert" }); - try { - const updated = await revertReviewApplication({ - applicationId: application.id, - }); - patchState(item.id, { - busy: undefined, - application: updated, - diff: states[item.id]?.diff - ? { ...states[item.id]!.diff!, application: updated } - : undefined, - }); - queryClient.setQueryData( - ["review-snapshot", spaceId], - (snapshot: ReviewSnapshot | null | undefined) => - upsertApplicationSnapshot(snapshot, updated), - ); - } catch (error) { - patchState(item.id, { busy: undefined }); - notifications.show({ - color: "red", - message: error instanceof Error ? error.message : String(error), - }); - } - }; - - const handleDiscover = () => { - if (!spaceId) return; - discoverMutation.mutate({ spaceId, limit: ARTIFACT_LIMIT }); - }; - - const hasSnapshot = Boolean(snapshotQuery.data); - const isDiscoverReviewRunning = - discoverMutation.isPending || isDiscoverJobRunning; - const orderedItems = useMemo(() => { - if (!items) return []; - - return items - .map((item, index) => ({ - item, - index, - handled: isItemHandled(states[item.id]), - })) - .sort((left, right) => { - if (left.handled === right.handled) { - return left.index - right.index; - } - return left.handled ? 1 : -1; - }); - }, [items, states]); - - if (spaceLoading) { - return ( - - - - ); - } - - if (!space || !canReview || !reviewEnabled) { - return ; - } - - return ( - - - - {t("Compilation content review")} - {getAppName()} - - - - -
- {t("Compilation content review")} - - {t( - "Let AI review the structured wiki of a space, then decide each item.", - )} - -
- - - - - - {snapshotQuery.isLoading && !isDiscoverReviewRunning && ( - - - {t("Loading saved review ...")} - - )} - - {isDiscoverReviewRunning && ( - }> - {t(getDiscoverReviewJobMessage())} - - )} - - {items?.length === 0 && !isDiscoverReviewRunning && ( - } color="green"> - {t("AI found nothing worth reviewing.")} - - )} - - {items && items.length > 0 && !isDiscoverReviewRunning && ( - - {orderedItems.map(({ item, index }) => ( - patchState(item.id, { freeText: v })} - onFollowUpChange={(v) => - patchState(item.id, { followUpText: v }) - } - onDeepSearch={() => resolveItem(item, FEEDBACK_DEEPSEARCH)} - onAccept={() => resolveItem(item, FEEDBACK_ACCEPT)} - onSkip={() => resolveItem(item, FEEDBACK_SKIP)} - onSubmitFreeText={() => - resolveItem(item, states[item.id]?.freeText?.trim() || "") - } - onSubmitFollowUp={() => { - const resolved = states[item.id]?.resolved; - if (!resolved) return; - resolveItem( - item, - states[item.id]?.followUpText?.trim() || "", - resolved.turns, - ); - }} - onPlan={() => planItem(item)} - onPreviewDiff={() => previewApplication(item)} - onApply={() => applyApplication(item)} - onRevert={() => revertApplication(item)} - /> - ))} - - )} -
-
- ); -} - -function applySnapshot( - snapshot: ReviewSnapshot, - setItems: (items: ReviewItem[] | null) => void, - setDocMap: (value: Record) => void, - setStates: (value: Record) => void, -) { - setItems(snapshot.items); - setDocMap(Object.fromEntries(snapshot.docs.map((doc) => [doc.id, doc]))); - setStates( - buildStatesFromResolvedReviews( - snapshot.resolvedReviews, - snapshot.applications, - snapshot.jobs, - ), - ); -} - -function buildStatesFromResolvedReviews( - resolvedReviews: ResolvedReview[], - applications: ReviewApplication[] = [], - jobs: ReviewJob[] = [], -): Record { - // 每个 reviewItem 取"最新"的 application。修复:不能简单按数组顺序取第一个, - // 否则同一 reviewItem 有历史 application(如先前撤回过)+ 新 draft 时, - // 旧的 reverted 排在前会被误当成"最新",前端显示成"已撤回"。 - // 正确策略:同一 reviewItem 下,优先取 draft;否则按 createdAt 取最新。 - const sortedApplications = [...applications] - .filter((application) => application.status !== "superseded") - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - const latestApplications = new Map(); - for (const application of sortedApplications) { - const existing = latestApplications.get(application.reviewItemId); - if (!existing) { - latestApplications.set(application.reviewItemId, application); - } else if (existing.status !== "draft" && application.status === "draft") { - // 已有非 draft(如 reverted),后面又遇到 draft —— draft 优先。 - latestApplications.set(application.reviewItemId, application); - } - } - - const states: Record = Object.fromEntries( - resolvedReviews.map((resolved) => { - const application = latestApplications.get(resolved.item.id); - return [ - resolved.item.id, - { - freeText: "", - followUpText: "", - resolved, - application, - diff: application - ? { - application, - beforeContent: application.beforeContent, - afterContent: application.afterContent, - } - : undefined, - }, - ]; - }), - ); - for (const job of jobs) { - if (!isActiveReviewJob(job) || job.kind !== "negotiate" || !job.itemId) { - continue; - } - states[job.itemId] = { - freeText: "", - followUpText: "", - ...states[job.itemId], - busy: "draft", - }; - } - return states; -} - -function upsertResolvedSnapshot( - snapshot: ReviewSnapshot | null | undefined, - resolved: ResolvedReview, -): ReviewSnapshot | null { - if (!snapshot) return snapshot ?? null; - - const resolvedReviews = [ - ...snapshot.resolvedReviews.filter( - (entry) => entry.item.id !== resolved.item.id, - ), - resolved, - ]; - - return { - ...snapshot, - resolvedReviews, - updatedAt: new Date().toISOString(), - }; -} - -function upsertApplicationSnapshot( - snapshot: ReviewSnapshot | null | undefined, - application: ReviewApplication, -): ReviewSnapshot | null { - if (!snapshot) return snapshot ?? null; - - const applications = [ - application, - ...snapshot.applications.filter((entry) => entry.id !== application.id), - ]; - - return { - ...snapshot, - applications, - updatedAt: new Date().toISOString(), - }; -} - -function upsertJobSnapshot( - snapshot: ReviewSnapshot | null | undefined, - job: ReviewJob, -): ReviewSnapshot { - const now = new Date().toISOString(); - const base: ReviewSnapshot = snapshot ?? { - version: "2", - items: [], - docs: [], - resolvedReviews: [], - jobs: [], - applications: [], - discoveredAt: now, - updatedAt: now, - }; - - return { - ...base, - jobs: [job, ...base.jobs.filter((entry) => entry.jobId !== job.jobId)], - updatedAt: now, - }; -} - -function isItemHandled(state?: ItemState): boolean { - if (!state?.resolved) return false; - if (state.resolved.skipped || state.resolved.applied) return true; - return ( - state.application?.status === "applied" || - state.application?.status === "reverted" - ); -} - -function isActiveReviewJob(job: ReviewJob): boolean { - return job.status === "pending" || job.status === "running"; -} - -function getDiscoverReviewJobMessage(): string { - return "AI is reviewing the wiki in the background. This page will update automatically."; -} - -function previewText(value: string, maxLength: number): string { - const normalized = value.trim(); - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, maxLength).trimEnd()}...`; -} - -function DocLink({ - docId, - docMap, - size = "xs", -}: { - docId: string; - docMap: Record; - size?: "xs" | "sm"; -}) { - const meta = docMap[docId]; - const label = meta?.title || `${docId.slice(0, 8)}…`; - - if (meta?.sourcePageId) { - return ( - - {label} - - ); - } - - return ( - - {label} - - ); -} - -function ReviewRichText({ - text, - docMap, - size = "sm", - c = "dimmed", - fw, - td, -}: { - text: string; - docMap: Record; - size?: "xs" | "sm" | "md"; - c?: string; - fw?: number; - td?: string; -}) { - const parts = splitDocReferences(text); - - return ( - - {parts.map((part, index) => - part.type === "text" ? ( - {part.value} - ) : ( - - ), - )} - - ); -} - -function ReviewItemCard({ - item, - index, - docMap, - state, - onFreeTextChange, - onFollowUpChange, - onDeepSearch, - onAccept, - onSkip, - onSubmitFreeText, - onSubmitFollowUp, - onPlan, - onPreviewDiff, - onApply, - onRevert, -}: { - item: ReviewItem; - index: number; - docMap: Record; - state?: ItemState; - onFreeTextChange: (value: string) => void; - onFollowUpChange: (value: string) => void; - onDeepSearch: () => void; - onAccept: () => void; - onSkip: () => void; - onSubmitFreeText: () => void; - onSubmitFollowUp: () => void; - onPlan: () => void; - onPreviewDiff: () => void; - onApply: () => void; - onRevert: () => void; -}) { - const { t } = useTranslation(); - const busy = state?.busy; - const resolved = state?.resolved; - const handled = isItemHandled(state); - const hasResolution = Boolean(resolved); - - return ( - - - - #{index + 1} - - {TYPE_LABEL[item.type]} - - {handled && ( - - {t("Reviewed")} - - )} - - - -
- - {t("Report")} - - -
-
- - {t("AI recommendation")} - - -
- {item.relatedDocIds.length > 0 && ( - - - {t("Related docs")}: - - {item.relatedDocIds.map((id, i) => ( - - {i > 0 && ( - - 、 - - )} - - - ))} - - )} - {item.type === "missing-page" && item.outline.length > 0 && ( - - {t("Suggested outline")}: {item.outline.join(" / ")} - - )} - {item.type === "duplicate" && ( - - - {t("Suggested primary")}: - - {item.suggestedPrimaryId ? ( - - ) : ( - - {t("(undecided)")} - - )} - - )} - - {busy ? ( - - - - {t(BUSY_LABEL[busy])} - - - ) : hasResolution ? ( - - ) : ( - <> - - - - - - - - onFreeTextChange(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && (state?.freeText?.trim() ?? "")) { - onSubmitFreeText(); - } - }} - style={{ flex: 1 }} - /> - - - - )} -
-
- ); -} - -function splitDocReferences(text: string) { - const parts: Array< - { type: "text"; value: string } | { type: "doc"; docId: string } - > = []; - const pattern = /\[id=([0-9a-fA-F-]{36})\]/g; - let lastIndex = 0; - - for (const match of text.matchAll(pattern)) { - const start = match.index ?? 0; - if (start > lastIndex) { - parts.push({ type: "text", value: text.slice(lastIndex, start) }); - } - - const docId = match[1]; - if (docId) { - parts.push({ type: "doc", docId }); - } - lastIndex = start + match[0].length; - } - - if (lastIndex < text.length) { - parts.push({ type: "text", value: text.slice(lastIndex) }); - } - - return parts.length > 0 ? parts : [{ type: "text", value: text }]; -} - -function ResolvedBlock({ - resolved, - application, - diff, - docMap, - onPlan, - onPreviewDiff, - onApply, - onRevert, - followUpText, - onFollowUpChange, - onSubmitFollowUp, -}: { - resolved: ResolvedReview; - application?: ReviewApplication; - diff?: ReviewApplicationDiff; - docMap: Record; - onPlan: () => void; - onPreviewDiff: () => void; - onApply: () => void; - onRevert: () => void; - followUpText: string; - onFollowUpChange: (value: string) => void; - onSubmitFollowUp: () => void; -}) { - const { t } = useTranslation(); - - if (resolved.skipped || !resolved.draft) { - return ( - }> - {t("Skipped, no draft produced.")} - - ); - } - - const draft = resolved.draft; - const applyOperations = getDraftApplyOperations(draft.applyOperation); - const isRenameOnly = - applyOperations.length === 1 && applyOperations[0] === "rename-page"; - const showDraftBody = !diff; - const canContinue = - application?.status !== "applied" && application?.status !== "reverted"; - return ( - - - - - {resolved.deepSearched && ( - - {t("DeepSearch")} · {resolved.searchResults.length} - - )} - {draft.notes && ( - - {t("Tradeoff")}: {draft.notes} - - )} - {showDraftBody && ( - <> - - {draft.title} - - {isRenameOnly ? ( - - {t("Only renames the page; body is unchanged.")} - - ) : ( -