add reasoning block

This commit is contained in:
duanfuxiang 2025-02-18 11:02:24 +08:00
parent dc520535fc
commit d15681b0d5
12 changed files with 285 additions and 169 deletions

View File

@ -44,10 +44,12 @@ import AssistantMessageActions from './AssistantMessageActions'
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
import { ChatHistory } from './ChatHistory'
import MarkdownReasoningBlock from './MarkdownReasoningBlock'
import QueryProgress, { QueryProgressState } from './QueryProgress'
import ReactMarkdown from './ReactMarkdown'
import ShortcutInfo from './ShortcutInfo'
import SimilaritySearchResults from './SimilaritySearchResults'
// Add an empty line here
const getNewInputMessage = (app: App): ChatUserMessage => {
return {
@ -242,6 +244,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
{
role: 'assistant',
content: '',
reasoningContent: '',
id: responseMessageId,
metadata: {
usage: undefined,
@ -269,6 +272,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
{
role: 'assistant',
content: '',
reasoningContent: '',
id: responseMessageId,
metadata: {
usage: undefined,
@ -290,12 +294,14 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content ?? ''
const reasoning_content = chunk.choices[0]?.delta?.reasoning_content ?? ''
setChatMessages((prevChatHistory) =>
prevChatHistory.map((message) =>
message.role === 'assistant' && message.id === responseMessageId
? {
...message,
content: message.content + content,
reasoningContent: message.reasoningContent + reasoning_content,
metadata: {
...message.metadata,
usage: chunk.usage ?? message.metadata?.usage, // Keep existing usage if chunk has no usage data
@ -584,13 +590,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
{
// If the chat is empty, show a message to start a new chat
chatMessages.length === 0 && (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
width: '100%'
}}>
<div className="infio-chat-empty-state">
<ShortcutInfo />
</div>
)
@ -638,6 +638,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
</div>
) : (
<div key={message.id} className="infio-chat-messages-assistant">
<MarkdownReasoningBlock reasoningContent={message.reasoningContent} />
<ReactMarkdownItem
handleApply={handleApply}
isApplying={applyMutation.isPending}

View File

@ -0,0 +1,56 @@
import { ChevronDown, ChevronRight } from 'lucide-react'
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
import { useDarkModeContext } from '../../contexts/DarkModeContext'
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
export default function MarkdownReasoningBlock({
reasoningContent,
}: PropsWithChildren<{
reasoningContent: string
}>) {
const { isDarkMode } = useDarkModeContext()
const containerRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(true)
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [reasoningContent])
return (
reasoningContent && (
<div
className={`infio-chat-code-block has-filename infio-reasoning-block`}
>
<div className={'infio-chat-code-block-header'}>
<div className={'infio-chat-code-block-header-filename'}>
Reasoning
</div>
<button
className="clickable-icon infio-chat-list-dropdown"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
</div>
<div
ref={containerRef}
className="infio-reasoning-content-wrapper"
>
<MemoizedSyntaxHighlighterWrapper
isDarkMode={isDarkMode}
language="markdown"
hasFilename={true}
wrapLines={true}
isOpen={isOpen}
>
{reasoningContent}
</MemoizedSyntaxHighlighterWrapper>
</div>
</div>
)
)
}

View File

@ -11,13 +11,17 @@ function SyntaxHighlighterWrapper({
hasFilename,
wrapLines,
children,
isOpen = true,
}: {
isDarkMode: boolean
language: string | undefined
hasFilename: boolean
wrapLines: boolean
children: string
isOpen?: boolean
}) {
if (!isOpen) return null;
return (
<SyntaxHighlighter
language={language}

View File

@ -121,7 +121,8 @@ export class OpenAIMessageAdapter {
choices: response.choices.map((choice) => ({
finish_reason: choice.finish_reason,
message: {
content: choice.message.content,
content: choice.message.content,
reasoning_content: choice.message.reasoning_content,
role: choice.message.role,
},
})),
@ -135,13 +136,14 @@ export class OpenAIMessageAdapter {
static parseStreamingResponseChunk(
chunk: ChatCompletionChunk,
): LLMResponseStreaming {
): LLMResponseStreaming {
return {
id: chunk.id,
choices: chunk.choices.map((choice) => ({
finish_reason: choice.finish_reason ?? null,
delta: {
content: choice.delta.content ?? null,
reasoning_content: choice.delta.reasoning_content ?? null,
role: choice.delta.role,
},
})),

View File

@ -1,162 +1,164 @@
import { SerializedEditorState } from 'lexical'
import { App } from 'obsidian'
import { editorStateToPlainText } from '../../../components/chat-view/chat-input/utils/editor-state-to-plain-text'
import { ChatAssistantMessage, ChatConversationMeta, ChatMessage, ChatUserMessage } from '../../../types/chat'
import { ContentPart } from '../../../types/llm/request'
import { Mentionable, SerializedMentionable } from '../../../types/mentionable'
import { Mentionable } from '../../../types/mentionable'
import { deserializeMentionable, serializeMentionable } from '../../../utils/mentionable'
import { DBManager } from '../../database-manager'
import { InsertMessage } from '../../schema'
import { InsertMessage, SelectConversation, SelectMessage } from '../../schema'
import { ConversationRepository } from './conversation-repository'
export class ConversationManager {
private app: App
private repository: ConversationRepository
private dbManager: DBManager
private app: App
private repository: ConversationRepository
private dbManager: DBManager
constructor(app: App, dbManager: DBManager) {
this.app = app
this.dbManager = dbManager
const db = dbManager.getPgClient()
if (!db) throw new Error('Database not initialized')
this.repository = new ConversationRepository(app, db)
}
constructor(app: App, dbManager: DBManager) {
this.app = app
this.dbManager = dbManager
const db = dbManager.getPgClient()
if (!db) throw new Error('Database not initialized')
this.repository = new ConversationRepository(app, db)
}
async createConversation(id: string, title = 'New chat'): Promise<void> {
const conversation = {
id,
title,
createdAt: new Date(),
updatedAt: new Date(),
}
await this.repository.create(conversation)
await this.dbManager.save()
}
async createConversation(id: string, title = 'New chat'): Promise<void> {
const conversation = {
id,
title,
createdAt: new Date(),
updatedAt: new Date(),
}
await this.repository.create(conversation)
await this.dbManager.save()
}
async saveConversation(id: string, messages: ChatMessage[]): Promise<void> {
const conversation = await this.repository.findById(id)
if (!conversation) {
let title = 'New chat'
if (messages.length > 0 && messages[0].role === 'user') {
const query = editorStateToPlainText(messages[0].content)
if (query.length > 20) {
title = `${query.slice(0, 20)}...`
} else {
title = query
}
}
await this.createConversation(id, title)
}
async saveConversation(id: string, messages: ChatMessage[]): Promise<void> {
const conversation = await this.repository.findById(id)
if (!conversation) {
let title = 'New chat'
if (messages.length > 0 && messages[0].role === 'user') {
const query = editorStateToPlainText(messages[0].content)
if (query.length > 20) {
title = `${query.slice(0, 20)}...`
} else {
title = query
}
}
await this.createConversation(id, title)
}
// Delete existing messages
await this.repository.deleteAllMessagesFromConversation(id)
// Delete existing messages
await this.repository.deleteAllMessagesFromConversation(id)
// Insert new messages
for (const message of messages) {
const insertMessage = this.serializeMessage(message, id)
await this.repository.createMessage(insertMessage)
}
// Insert new messages
for (const message of messages) {
const insertMessage = this.serializeMessage(message, id)
await this.repository.createMessage(insertMessage)
}
// Update conversation timestamp
await this.repository.update(id, { updatedAt: new Date() })
await this.dbManager.save()
}
// Update conversation timestamp
await this.repository.update(id, { updatedAt: new Date() })
await this.dbManager.save()
}
async findConversation(id: string): Promise<ChatMessage[] | null> {
const conversation = await this.repository.findById(id)
if (!conversation) {
return null
}
async findConversation(id: string): Promise<ChatMessage[] | null> {
const conversation = await this.repository.findById(id)
if (!conversation) {
return null
}
const messages = await this.repository.findMessagesByConversationId(id)
return messages.map(msg => this.deserializeMessage(msg))
}
const messages = await this.repository.findMessagesByConversationId(id)
return messages.map(msg => this.deserializeMessage(msg))
}
async deleteConversation(id: string): Promise<void> {
await this.repository.delete(id)
await this.dbManager.save()
}
async deleteConversation(id: string): Promise<void> {
await this.repository.delete(id)
await this.dbManager.save()
}
getAllConversations(callback: (conversations: ChatConversationMeta[]) => void): void {
const db = this.dbManager.getPgClient()
db?.live.query('SELECT * FROM conversations ORDER BY updated_at', [], (results) => {
callback(results.rows.map(conv => ({
id: conv.id,
title: conv.title,
schemaVersion: 2,
createdAt: conv.createdAt instanceof Date ? conv.createdAt.getTime() : conv.createdAt,
updatedAt: conv.updatedAt instanceof Date ? conv.updatedAt.getTime() : conv.updatedAt,
})))
})
}
getAllConversations(callback: (conversations: ChatConversationMeta[]) => void): void {
const db = this.dbManager.getPgClient()
db?.live.query('SELECT * FROM conversations ORDER BY updated_at DESC', [], (results: { rows: Array<SelectConversation> }) => {
callback(results.rows.map(conv => ({
schemaVersion: 2,
id: conv.id,
title: conv.title,
createdAt: conv.created_at instanceof Date ? conv.created_at.getTime() : conv.created_at,
updatedAt: conv.updated_at instanceof Date ? conv.updated_at.getTime() : conv.updated_at,
})))
})
}
async updateConversationTitle(id: string, title: string): Promise<void> {
await this.repository.update(id, { title })
await this.dbManager.save()
}
async updateConversationTitle(id: string, title: string): Promise<void> {
await this.repository.update(id, { title })
await this.dbManager.save()
}
private serializeMessage(message: ChatMessage, conversationId: string): InsertMessage {
const base = {
id: message.id,
conversationId,
role: message.role,
createdAt: new Date(),
}
// convert ChatMessage to InsertMessage
private serializeMessage(message: ChatMessage, conversationId: string): InsertMessage {
const base = {
id: message.id,
conversationId: conversationId,
role: message.role,
createdAt: new Date(),
}
if (message.role === 'user') {
const userMessage: ChatUserMessage = message
return {
...base,
content: userMessage.content ? JSON.stringify(userMessage.content) : null,
promptContent: userMessage.promptContent
? typeof userMessage.promptContent === 'string'
? userMessage.promptContent
: JSON.stringify(userMessage.promptContent)
: null,
mentionables: JSON.stringify(userMessage.mentionables.map(serializeMentionable)),
similaritySearchResults: userMessage.similaritySearchResults
? JSON.stringify(userMessage.similaritySearchResults)
: null,
}
} else {
const assistantMessage: ChatAssistantMessage = message
return {
...base,
content: assistantMessage.content,
metadata: assistantMessage.metadata ? JSON.stringify(assistantMessage.metadata) : null,
}
}
}
if (message.role === 'user') {
const userMessage: ChatUserMessage = message
return {
...base,
content: userMessage.content ? JSON.stringify(userMessage.content) : null,
promptContent: userMessage.promptContent
? typeof userMessage.promptContent === 'string'
? userMessage.promptContent
: JSON.stringify(userMessage.promptContent)
: null,
mentionables: JSON.stringify(userMessage.mentionables.map(serializeMentionable)),
similaritySearchResults: userMessage.similaritySearchResults
? JSON.stringify(userMessage.similaritySearchResults)
: null,
}
} else {
const assistantMessage: ChatAssistantMessage = message
return {
...base,
content: assistantMessage.content,
reasoningContent: assistantMessage.reasoningContent,
metadata: assistantMessage.metadata ? JSON.stringify(assistantMessage.metadata) : null,
}
}
}
private deserializeMessage(message: InsertMessage): ChatMessage {
if (message.role === 'user') {
return {
id: message.id,
role: 'user',
content: message.content ? JSON.parse(message.content) as SerializedEditorState : null,
promptContent: message.promptContent
? message.promptContent.startsWith('{')
? JSON.parse(message.promptContent) as ContentPart[]
: message.promptContent
: null,
mentionables: message.mentionables
? (JSON.parse(message.mentionables) as SerializedMentionable[])
.map(m => deserializeMentionable(m, this.app))
.filter((m: Mentionable | null): m is Mentionable => m !== null)
: [],
similaritySearchResults: message.similaritySearchResults
? JSON.parse(message.similaritySearchResults)
: undefined,
}
} else {
return {
id: message.id,
role: 'assistant',
content: message.content || '',
metadata: message.metadata ? JSON.parse(message.metadata) : undefined,
}
}
}
// convert SelectMessage to ChatMessage
private deserializeMessage(message: SelectMessage): ChatMessage {
if (message.role === 'user') {
return {
id: message.id,
role: 'user',
content: message.content ? JSON.parse(message.content) : null,
promptContent: message.prompt_content
? message.prompt_content.startsWith('{')
? JSON.parse(message.prompt_content)
: message.prompt_content
: null,
mentionables: message.mentionables
? JSON.parse(message.mentionables)
.map(m => deserializeMentionable(m, this.app))
.filter((m: Mentionable | null): m is Mentionable => m !== null)
: [],
similaritySearchResults: message.similarity_search_results
? JSON.parse(message.similarity_search_results)
: undefined,
}
} else {
return {
id: message.id,
role: 'assistant',
content: message.content || '',
reasoningContent: message.reasoning_content || '',
metadata: message.metadata ? JSON.parse(message.metadata) : undefined,
}
}
}
}

View File

@ -8,10 +8,6 @@ import {
SelectMessage,
} from '../../schema'
type QueryResult<T> = {
rows: T[]
}
export class ConversationRepository {
private app: App
private db: PGliteInterface
@ -32,31 +28,32 @@ export class ConversationRepository {
conversation.createdAt || new Date(),
conversation.updatedAt || new Date()
]
) as QueryResult<SelectConversation>
)
return result.rows[0]
}
async createMessage(message: InsertMessage): Promise<SelectMessage> {
const result = await this.db.query<SelectMessage>(
const result = await this.db.query<SelectMessage>(
`INSERT INTO messages (
id, conversation_id, role, content,
id, conversation_id, role, content, reasoning_content,
prompt_content, metadata, mentionables,
similarity_search_results, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
message.id,
message.conversationId,
message.role,
message.content,
message.reasoningContent,
message.promptContent,
message.metadata,
message.mentionables,
message.similaritySearchResults,
message.createdAt || new Date()
]
) as QueryResult<SelectMessage>
)
return result.rows[0]
}
@ -64,30 +61,30 @@ export class ConversationRepository {
const result = await this.db.query<SelectConversation>(
`SELECT * FROM conversations WHERE id = $1 LIMIT 1`,
[id]
) as QueryResult<SelectConversation>
)
return result.rows[0]
}
async findMessagesByConversationId(conversationId: string): Promise<SelectMessage[]> {
async findMessagesByConversationId(conversationId: string): Promise<SelectMessage[]> {
const result = await this.db.query<SelectMessage>(
`SELECT * FROM messages
WHERE conversation_id = $1
ORDER BY created_at`,
[conversationId]
) as QueryResult<SelectMessage>
)
return result.rows
}
async findAll(): Promise<SelectConversation[]> {
const result = await this.db.query<SelectConversation>(
`SELECT * FROM conversations ORDER BY updated_at DESC`
) as QueryResult<SelectConversation>
`SELECT * FROM conversations ORDER BY created_at DESC`
)
return result.rows
}
async update(id: string, data: Partial<InsertConversation>): Promise<SelectConversation> {
const setClauses: string[] = []
const values: any[] = []
const values: (string | Date)[] = []
let paramIndex = 1
if (data.title !== undefined) {
@ -110,7 +107,7 @@ export class ConversationRepository {
WHERE id = $${paramIndex}
RETURNING *`,
values
) as QueryResult<SelectConversation>
)
return result.rows[0]
}
@ -118,7 +115,7 @@ export class ConversationRepository {
const result = await this.db.query<SelectConversation>(
`DELETE FROM conversations WHERE id = $1 RETURNING *`,
[id]
) as QueryResult<SelectConversation>
)
return result.rows.length > 0
}

View File

@ -125,6 +125,7 @@ export type Message = {
conversationId: string // uuid
role: 'user' | 'assistant'
content: string | null
reasoningContent?: string | null
promptContent?: string | null
metadata?: string | null
mentionables?: string | null
@ -139,13 +140,19 @@ export type InsertConversation = {
updatedAt?: Date
}
export type SelectConversation = Conversation
export type SelectConversation = {
id: string // uuid
title: string
created_at: Date
updated_at: Date
}
export type InsertMessage = {
id: string
conversationId: string
role: 'user' | 'assistant'
content: string | null
reasoningContent?: string | null
promptContent?: string | null
metadata?: string | null
mentionables?: string | null
@ -153,4 +160,15 @@ export type InsertMessage = {
createdAt?: Date
}
export type SelectMessage = Message
export type SelectMessage = {
id: string // uuid
conversation_id: string // uuid
role: 'user' | 'assistant'
content: string | null
reasoning_content?: string | null
prompt_content?: string | null
metadata?: string | null
mentionables?: string | null
similarity_search_results?: string | null
created_at: Date
}

View File

@ -102,11 +102,23 @@ export const migrations: Record<string, SqlMigration> = {
"updated_at" timestamp DEFAULT now() NOT NULL
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages'
AND column_name = 'reasoning_content'
) THEN
ALTER TABLE "messages" ADD COLUMN "reasoning_content" text;
END IF;
END $$;
CREATE TABLE IF NOT EXISTS "messages" (
"id" uuid PRIMARY KEY NOT NULL,
"conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE,
"role" text NOT NULL,
"content" text,
"reasoning_content" text,
"prompt_content" text,
"metadata" text,
"mentionables" text,

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useApp } from '../contexts/AppContext'
import { useDatabase } from '../contexts/DatabaseContext'
import { DBManager } from '../database/database-manager'
import { ChatConversationMeta, ChatMessage } from '../types/chat'
@ -17,7 +16,6 @@ type UseChatHistory = {
}
export function useChatHistory(): UseChatHistory {
const app = useApp()
const { getDatabaseManager } = useDatabase()
// 这里更新有点繁琐, 但是能保持 chatList 实时更新
@ -29,7 +27,9 @@ export function useChatHistory(): UseChatHistory {
const fetchChatList = useCallback(async () => {
const dbManager = await getManager()
dbManager.getConversationManager().getAllConversations(setChatList)
dbManager.getConversationManager().getAllConversations((conversations) => {
setChatList(conversations)
})
}, [getManager])
useEffect(() => {

View File

@ -21,6 +21,7 @@ export type ChatUserMessage = {
export type ChatAssistantMessage = {
role: 'assistant'
content: string
reasoningContent: string
id: string
metadata?: {
usage?: ResponseUsage
@ -44,6 +45,7 @@ export type SerializedChatUserMessage = {
export type SerializedChatAssistantMessage = {
role: 'assistant'
content: string
reasoningContent: string
id: string
metadata?: {
usage?: ResponseUsage

View File

@ -39,7 +39,8 @@ type NonStreamingChoice = {
type StreamingChoice = {
finish_reason: string | null
delta: {
content: string | null
content: string | null
reasoning_content: string | null
role?: string
}
error?: Error

View File

@ -622,6 +622,19 @@ input[type='text'].infio-chat-list-dropdown-item-title-input {
border-radius: var(--radius-s);
}
.infio-chat-code-block.infio-reasoning-block {
max-height: 222px;
overflow: hidden;
margin-top: 22px;
margin-bottom: 22px;
}
.infio-reasoning-content-wrapper {
height: calc(100% - 28px);
overflow-y: auto;
scroll-behavior: smooth;
}
.infio-chat-code-block code {
padding: 0;
}
@ -1777,3 +1790,11 @@ button.infio-chat-input-model-select {
position: absolute;
display: block;
}
.infio-chat-empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}