feat(core): cite source documents in the AI answer (#9863)

Support issue [BS-2424](https://linear.app/affine-design/issue/BS-2424).

### What changed?
- Add relevant document prompt templates.
- Add citation rules in system prompts.
- Change message `params` type to `Record<string, any>`
- Add unit test.

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/sJGviKxfE3Ap685cl5bj/ec24e664-039e-4fab-bd26-b3312f011daf.mov">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/sJGviKxfE3Ap685cl5bj/ec24e664-039e-4fab-bd26-b3312f011daf.mov">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/ec24e664-039e-4fab-bd26-b3312f011daf.mov">录屏2025-01-23 10.40.38.mov</video>
This commit is contained in:
akumatus
2025-01-24 04:04:00 +00:00
parent 48c26017ae
commit 95cf2e047f
13 changed files with 131 additions and 37 deletions

View File

@@ -2,6 +2,8 @@ import type { getCopilotHistoriesQuery, RequestOptions } from '@affine/graphql';
import type { EditorHost } from '@blocksuite/affine/block-std';
import type { BlockModel } from '@blocksuite/affine/store';
import type { DocContext } from '../chat-panel/chat-context';
export const translateLangs = [
'English',
'Spanish',
@@ -37,7 +39,7 @@ export const imageProcessingTypes = [
] as const;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
// oxlint-disable-next-line @typescript-eslint/no-namespace
namespace BlockSuitePresets {
type TrackerControl =
| 'format-bar'
@@ -57,6 +59,7 @@ declare global {
}
interface AITextActionOptions {
// user input text
input?: string;
stream?: boolean;
attachments?: (string | File | Blob)[]; // blob could only be strings for the moments (url or data urls)
@@ -101,6 +104,8 @@ declare global {
T['stream'] extends true ? TextStream : Promise<string>;
interface ChatOptions extends AITextActionOptions {
// related documents
docs?: DocContext[];
sessionId?: string;
isRootSession?: boolean;
}

View File

@@ -76,7 +76,7 @@ export interface BaseChip {
export interface DocChip extends BaseChip {
docId: string;
content?: Signal<string>;
markdown?: Signal<string>;
}
export interface FileChip extends BaseChip {

View File

@@ -25,7 +25,7 @@ import { AIProvider } from '../provider';
import { reportResponse } from '../utils/action-reporter';
import { readBlobAsURL } from '../utils/image';
import type { AINetworkSearchConfig } from './chat-config';
import type { ChatContextValue, ChatMessage } from './chat-context';
import type { ChatContextValue, ChatMessage, DocContext } from './chat-context';
import { isDocChip } from './components/utils';
const MaximumImageCount = 32;
@@ -512,16 +512,11 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
if (status === 'loading' || status === 'transmitting') return;
const { images } = this.chatContextValue;
if (!text && images.length === 0) {
if (!text) {
return;
}
const { doc } = this.host;
const docsContent = chips
.filter(isDocChip)
.map(chip => chip.content?.value || '')
.join('\n');
this.updateContext({
images: [],
status: 'loading',
@@ -534,16 +529,14 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
images?.map(image => readBlobAsURL(image))
);
const content =
(markdown ? `${markdown}\n` : '') + `${docsContent}\n` + text;
const userInput = (markdown ? `${markdown}\n` : '') + text;
this.updateContext({
items: [
...this.chatContextValue.items,
{
id: '',
role: 'user',
content: content,
content: userInput,
createdAt: new Date().toISOString(),
attachments,
},
@@ -558,8 +551,16 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
try {
const abortController = new AbortController();
const docs: DocContext[] = chips
.filter(isDocChip)
.filter(chip => !!chip.markdown?.value && chip.state === 'success')
.map(chip => ({
docId: chip.docId,
markdown: chip.markdown?.value || '',
}));
const stream = AIProvider.actions.chat?.({
input: content,
input: userInput,
docs: docs,
docId: doc.id,
attachments: images,
workspaceId: doc.workspace.id,

View File

@@ -100,10 +100,10 @@ export class ChatPanelDocChip extends SignalWatcher(
doc.load();
}
const result = await extractMarkdownFromDoc(doc, this.host.std.provider);
if (this.chip.content) {
this.chip.content.value = result.markdown;
if (this.chip.markdown) {
this.chip.markdown.value = result.markdown;
} else {
this.chip.content = new Signal<string>(result.markdown);
this.chip.markdown = new Signal<string>(result.markdown);
}
this.updateChip(this.chip, {
state: 'success',

View File

@@ -17,7 +17,7 @@ export type TextToTextOptions = {
sessionId?: string | Promise<string>;
content?: string;
attachments?: (string | Blob | File)[];
params?: Record<string, string>;
params?: Record<string, any>;
timeout?: number;
stream?: boolean;
signal?: AbortSignal;

View File

@@ -117,11 +117,22 @@ export function setupAIProvider(
const sessionId =
options.sessionId ??
getChatSessionId(options.workspaceId, options.docId, options.attachments);
const { input, docs, ...rest } = options;
const params = docs?.length
? {
docs: docs.map((doc, i) => ({
docId: doc.docId,
markdown: doc.markdown,
index: i + 1,
})),
}
: undefined;
return textToText({
...options,
...rest,
client,
content: options.input,
content: input,
sessionId,
params,
});
});