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:
@@ -953,6 +953,7 @@
|
||||
"Try a different search term.": "Try a different search term.",
|
||||
"Try again": "Try again",
|
||||
"Untitled chat": "Untitled chat",
|
||||
"No document": "No document",
|
||||
"You": "You",
|
||||
"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}}",
|
||||
|
||||
@@ -954,6 +954,7 @@
|
||||
"Try a different search term.": "Попробуйте другой поисковый запрос.",
|
||||
"Try again": "Попробовать снова",
|
||||
"Untitled chat": "Чат без названия",
|
||||
"No document": "Без документа",
|
||||
"What can I help you with?": "Чем я могу вам помочь?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||
|
||||
@@ -18,8 +18,31 @@ import {
|
||||
useRenameAiChatMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.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";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
activeChatId: string | null;
|
||||
onSelect: (chatId: string) => void;
|
||||
@@ -127,16 +150,24 @@ export default function ConversationList({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.roleName && (
|
||||
<Text size="sm" span title={chat.roleName} style={{ flex: "none" }}>
|
||||
{chat.roleEmoji || "🤖"}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{chat.roleName && (
|
||||
<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 size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<ChatMetaLine createdAt={chat.createdAt} pageTitle={chat.pageTitle} />
|
||||
</Box>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface IAiChat {
|
||||
// Null when the chat has no role or the role was soft-deleted.
|
||||
roleName?: 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`). */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -43,10 +43,21 @@ export class AiChatRepo {
|
||||
.on('aiAgentRoles.deletedAt', 'is', null)
|
||||
.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')
|
||||
.select([
|
||||
'aiAgentRoles.name as roleName',
|
||||
'aiAgentRoles.emoji as roleEmoji',
|
||||
'pages.title as pageTitle',
|
||||
])
|
||||
.where('aiChats.creatorId', '=', creatorId)
|
||||
.where('aiChats.workspaceId', '=', workspaceId)
|
||||
|
||||
3
apps/server/src/database/types/db.d.ts
vendored
3
apps/server/src/database/types/db.d.ts
vendored
@@ -575,6 +575,9 @@ export interface AiChats {
|
||||
// chat to universal instead of breaking it. Resolved from this column on every
|
||||
// turn — NOT from the request body.
|
||||
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>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
|
||||
Reference in New Issue
Block a user