feat(ai-chat): show creation time and origin document in chat list

Each chat row in the AI-chat history now shows a dimmed second line with
how long ago the chat was created and the document it was created in
("N ago / <document>", or "No document" when started outside a page).

Server:
- New migration: nullable ai_chats.page_id (FK pages.id, ON DELETE SET NULL).
- Capture the origin page at chat creation from the client-supplied openPage,
  but validate it first: it must be a real page in the same workspace that the
  user may read (PageAccessService.validateCanView), else null. This keeps the
  "openPage.id is attacker-controllable but harmless" invariant - preventing a
  cross-workspace/cross-space page-title leak and a post-hijack FK crash.
- findByCreator left-joins pages (scoped by workspace, defense-in-depth) and
  returns pageTitle.

Client:
- IAiChat gains pageId/pageTitle; ConversationList renders a ChatMetaLine
  (useTimeAgo + origin document) as a dimmed second line.
- Add i18n key "No document" (en-US, ru-RU).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-22 16:16:26 +03:00
parent 89ac8fa37b
commit 7ce1a24f82
9 changed files with 119 additions and 10 deletions

View File

@@ -50,6 +50,8 @@ describe('AiChatService.resolveRoleForRequest', () => {
{} as never, // tools
{} as never, // mcpClients
aiAgentRoleRepo as never,
{} as never, // pageRepo
{} as never, // pageAccess
);
return { service, aiChatRepo, aiAgentRoleRepo };
}

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger } from '@nestjs/common';
import { FastifyReply } from 'fastify';
import {
streamText,
@@ -14,6 +14,8 @@ import { describeProviderError } from '../../integrations/ai/ai-error.util';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
import {
User,
Workspace,
@@ -126,6 +128,8 @@ export class AiChatService {
private readonly tools: AiChatToolsService,
private readonly mcpClients: McpClientsService,
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
private readonly pageRepo: PageRepo,
private readonly pageAccess: PageAccessService,
) {}
/**
@@ -195,12 +199,44 @@ export class AiChatService {
}
}
if (!chatId) {
// Resolve the origin document for the history list. body.openPage.id is
// attacker-controllable, so validate it before persisting: it must be a
// real page in THIS workspace that the user is allowed to read. Anything
// else (foreign workspace, inaccessible/restricted, or non-existent) is
// dropped to null — persisting it would leak the page's title via the
// chat-list join, or violate the page_id FK on insert (this runs after
// res.hijack(), so a DB error would break the stream).
let originPageId: string | null = null;
const candidatePageId = body.openPage?.id;
if (candidatePageId) {
const page = await this.pageRepo.findById(candidatePageId);
if (page && page.workspaceId === workspace.id) {
try {
await this.pageAccess.validateCanView(page, user);
originPageId = page.id;
} catch (e) {
// Fail-closed: no provenance on any failure. A ForbiddenException is
// the expected "user cannot read this page" case; log anything else
// (e.g. a DB error) so a real fault is not masked as "no access".
if (!(e instanceof ForbiddenException)) {
this.logger.warn(
`origin page access check failed: ${
e instanceof Error ? e.message : 'unknown error'
}`,
);
}
originPageId = null;
}
}
}
const chat = await this.aiChatRepo.insert({
creatorId: user.id,
workspaceId: workspace.id,
// Bind the chat to the resolved role (if any) at creation time. The role
// is immutable afterwards (later turns read it from this column).
roleId: role?.id ?? null,
// Validated above: a real, readable page in this workspace, else null.
pageId: originPageId,
});
chatId = chat.id;
isNewChat = true;