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

@@ -953,6 +953,7 @@
"Try a different search term.": "Try a different search term.", "Try a different search term.": "Try a different search term.",
"Try again": "Try again", "Try again": "Try again",
"Untitled chat": "Untitled chat", "Untitled chat": "Untitled chat",
"No document": "No document",
"You": "You", "You": "You",
"What can I help you with?": "What can I help you with?", "What can I help you with?": "What can I help you with?",
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}", "Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",

View File

@@ -954,6 +954,7 @@
"Try a different search term.": "Попробуйте другой поисковый запрос.", "Try a different search term.": "Попробуйте другой поисковый запрос.",
"Try again": "Попробовать снова", "Try again": "Попробовать снова",
"Untitled chat": "Чат без названия", "Untitled chat": "Чат без названия",
"No document": "Без документа",
"What can I help you with?": "Чем я могу вам помочь?", "What can I help you with?": "Чем я могу вам помочь?",
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}", "Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.", "Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",

View File

@@ -18,8 +18,31 @@ import {
useRenameAiChatMutation, useRenameAiChatMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts"; } from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts"; import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css"; import classes from "@/features/ai-chat/components/ai-chat.module.css";
/**
* The dimmed second line of a chat row: how long ago the chat was created and
* the document it was created in. Its own component so the self-updating
* `useTimeAgo` hook is called per row legally (hooks cannot run inside `.map()`).
*/
function ChatMetaLine({
createdAt,
pageTitle,
}: {
createdAt: string;
pageTitle?: string | null;
}) {
const { t } = useTranslation();
const ago = useTimeAgo(createdAt);
// e.g. "2 hours ago · Onboarding guide" / "2 hours ago · No document"
return (
<Text size="xs" c="dimmed" lineClamp={1}>
{ago} · {pageTitle || t("No document")}
</Text>
);
}
interface ConversationListProps { interface ConversationListProps {
activeChatId: string | null; activeChatId: string | null;
onSelect: (chatId: string) => void; onSelect: (chatId: string) => void;
@@ -127,16 +150,24 @@ export default function ConversationList({
} }
}} }}
> >
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
{chat.roleName && ( <Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" span title={chat.roleName} style={{ flex: "none" }}> {chat.roleName && (
{chat.roleEmoji || "🤖"} <Text
size="sm"
span
title={chat.roleName}
style={{ flex: "none" }}
>
{chat.roleEmoji || "🤖"}
</Text>
)}
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{chat.title || t("Untitled chat")}
</Text> </Text>
)} </Group>
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}> <ChatMetaLine createdAt={chat.createdAt} pageTitle={chat.pageTitle} />
{chat.title || t("Untitled chat")} </Box>
</Text>
</Group>
<Menu shadow="md" width={180} position="bottom-end"> <Menu shadow="md" width={180} position="bottom-end">
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon

View File

@@ -19,6 +19,12 @@ export interface IAiChat {
// Null when the chat has no role or the role was soft-deleted. // Null when the chat has no role or the role was soft-deleted.
roleName?: string | null; roleName?: string | null;
roleEmoji?: string | null; roleEmoji?: string | null;
// The document the chat was created in (ai_chats.page_id). Null when started
// outside any document.
pageId?: string | null;
// Denormalized via a JOIN in the chat list response: the origin page's title.
// Null when there is no origin page (or it was hard-deleted).
pageTitle?: string | null;
} }
/** Supported model drivers (mirrors the server `AI_DRIVERS`). */ /** Supported model drivers (mirrors the server `AI_DRIVERS`). */

View File

@@ -50,6 +50,8 @@ describe('AiChatService.resolveRoleForRequest', () => {
{} as never, // tools {} as never, // tools
{} as never, // mcpClients {} as never, // mcpClients
aiAgentRoleRepo as never, aiAgentRoleRepo as never,
{} as never, // pageRepo
{} as never, // pageAccess
); );
return { service, aiChatRepo, aiAgentRoleRepo }; 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 { FastifyReply } from 'fastify';
import { import {
streamText, 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 { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.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 { 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 { import {
User, User,
Workspace, Workspace,
@@ -126,6 +128,8 @@ export class AiChatService {
private readonly tools: AiChatToolsService, private readonly tools: AiChatToolsService,
private readonly mcpClients: McpClientsService, private readonly mcpClients: McpClientsService,
private readonly aiAgentRoleRepo: AiAgentRoleRepo, private readonly aiAgentRoleRepo: AiAgentRoleRepo,
private readonly pageRepo: PageRepo,
private readonly pageAccess: PageAccessService,
) {} ) {}
/** /**
@@ -195,12 +199,44 @@ export class AiChatService {
} }
} }
if (!chatId) { 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({ const chat = await this.aiChatRepo.insert({
creatorId: user.id, creatorId: user.id,
workspaceId: workspace.id, workspaceId: workspace.id,
// Bind the chat to the resolved role (if any) at creation time. The role // Bind the chat to the resolved role (if any) at creation time. The role
// is immutable afterwards (later turns read it from this column). // is immutable afterwards (later turns read it from this column).
roleId: role?.id ?? null, roleId: role?.id ?? null,
// Validated above: a real, readable page in this workspace, else null.
pageId: originPageId,
}); });
chatId = chat.id; chatId = chat.id;
isNewChat = true; isNewChat = true;

View File

@@ -0,0 +1,18 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// The document a chat was created in (the user's open page at first message).
// Informational provenance shown in the chat-history list. NULL => the chat
// was started outside any document. ON DELETE SET NULL: a hard-deleted page
// degrades the chat to "no document" instead of breaking it.
await db.schema
.alterTable('ai_chats')
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('set null'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('ai_chats').dropColumn('page_id').execute();
}

View File

@@ -43,10 +43,21 @@ export class AiChatRepo {
.on('aiAgentRoles.deletedAt', 'is', null) .on('aiAgentRoles.deletedAt', 'is', null)
.on('aiAgentRoles.enabled', '=', true), .on('aiAgentRoles.enabled', '=', true),
) )
// Left-join the origin page for its title (provenance shown in the list).
// Scoped to the chat's workspace as defense-in-depth so a page id can only
// ever surface a same-workspace title. No deletedAt filter: a soft-deleted
// page keeps showing its historical title; a hard-deleted page already
// nulls aiChats.pageId via the FK.
.leftJoin('pages', (join) =>
join
.onRef('pages.id', '=', 'aiChats.pageId')
.onRef('pages.workspaceId', '=', 'aiChats.workspaceId'),
)
.selectAll('aiChats') .selectAll('aiChats')
.select([ .select([
'aiAgentRoles.name as roleName', 'aiAgentRoles.name as roleName',
'aiAgentRoles.emoji as roleEmoji', 'aiAgentRoles.emoji as roleEmoji',
'pages.title as pageTitle',
]) ])
.where('aiChats.creatorId', '=', creatorId) .where('aiChats.creatorId', '=', creatorId)
.where('aiChats.workspaceId', '=', workspaceId) .where('aiChats.workspaceId', '=', workspaceId)

View File

@@ -575,6 +575,9 @@ export interface AiChats {
// chat to universal instead of breaking it. Resolved from this column on every // chat to universal instead of breaking it. Resolved from this column on every
// turn — NOT from the request body. // turn — NOT from the request body.
roleId: string | null; roleId: string | null;
// The document the chat was created in (open page at first message). NULL =>
// started outside any document. ON DELETE SET NULL on the page FK.
pageId: string | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null; deletedAt: Timestamp | null;