feat: integrate Lexical rich text editor and update chat components

- Added support for rich text messages in the chat interface using Lexical.
- Updated ChatInput and related components to handle EditorState and rich text formats.
- Introduced AIRichTextMessage component for rendering rich text messages.
- Removed legacy LexicalRichEditor component and adjusted database schema to drop richTextSchema.
- Updated dependencies to include Lexical packages at version 0.33.1.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-07-31 18:25:59 +08:00
parent eb7e6515e1
commit 73a1e6eb6c
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
22 changed files with 1261 additions and 169 deletions

View File

@ -343,7 +343,7 @@ export const AIDisplayEntriesPart = ({ part }: { part: AIDisplayEntriesTool }) =
return (
<Card className="mb-2 w-full min-w-0">
<div className="w-[9999px]" />
<div className="w-[9999px] max-w-[calc(var(--ai-chat-layout-width,65ch)_-120px)]" />
<CardHeader>
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
<span className="text-lg">📰</span>

View File

@ -135,7 +135,7 @@ export const AIDisplayFeedsPart = ({ part }: { part: AIDisplayFeedsTool }) => {
return (
<Card className="mb-2 w-full min-w-0">
<div className="w-[9999px]" />
<div className="w-[9999px] max-w-[calc(var(--ai-chat-layout-width,65ch)_-120px)]" />
<CardHeader>
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
<span className="text-lg">📡</span>

View File

@ -254,7 +254,7 @@ export const AIDisplaySubscriptionsPart = ({ part }: { part: AIDisplaySubscripti
return (
<Card className="mb-2 w-full min-w-0">
<div className="w-[9999px]" />
<div className="w-[9999px] max-w-[calc(var(--ai-chat-layout-width,65ch)_-120px)]" />
<CardHeader>
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
<span className="text-lg">📋</span>

View File

@ -1,3 +1,5 @@
import type { LexicalRichEditorRef } from "@follow/components/ui/lexical-rich-editor/index.js"
import { LexicalRichEditor } from "@follow/components/ui/lexical-rich-editor/index.js"
import { cn, stopPropagation } from "@follow/utils"
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
@ -6,13 +8,10 @@ import { $getRoot } from "lexical"
import { memo, useCallback, useRef, useState } from "react"
import { AIChatContextBar } from "~/modules/ai/chat/components/AIChatContextBar"
import { convertLexicalToMarkdown } from "~/modules/ai/chat/utils/lexical-markdown"
import { useChatActions, useChatError, useChatStatus } from "../../__internal__/hooks"
import { AIChatSendButton } from "./AIChatSendButton"
import { CollapsibleError } from "./CollapsibleError"
import type { LexicalRichEditorRef } from "./LexicalRichEditor"
import { LexicalRichEditor } from "./LexicalRichEditor"
const chatInputVariants = cva(
[
@ -33,7 +32,7 @@ const chatInputVariants = cva(
)
interface ChatInputProps extends VariantProps<typeof chatInputVariants> {
onSend: (message: string) => void
onSend: (message: EditorState | string, editor: LexicalEditor | null) => void
}
export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
@ -52,11 +51,8 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
const handleSend = useCallback(() => {
if (currentEditor && editorRef.current && !editorRef.current.isEmpty()) {
const markdown = convertLexicalToMarkdown(currentEditor)
if (markdown.trim()) {
onSend(markdown.trim())
editorRef.current.clear()
}
onSend(currentEditor.getEditorState(), currentEditor)
editorRef.current.clear()
}
}, [currentEditor, onSend])
@ -102,6 +98,7 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
onChange={handleEditorChange}
onKeyDown={handleKeyDown}
autoFocus
namespace="AIChatRichEditor"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<AIChatSendButton
@ -117,7 +114,7 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
<div className="border-border/20 relative z-10 border-t bg-transparent">
<AIChatContextBar
className="border-0 bg-transparent px-4 py-2.5"
onSendShortcut={onSend}
onSendShortcut={(prompt) => onSend(prompt, null)}
/>
</div>
</div>

View File

@ -1,6 +1,8 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { cn, nextFrame } from "@follow/utils"
import { springScrollTo } from "@follow/utils/scroller"
import type { BizUIMessage } from "@folo-services/ai-tools"
import type { EditorState, LexicalEditor } from "lexical"
import { nanoid } from "nanoid"
import { useCallback, useEffect, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
@ -21,6 +23,7 @@ import {
import { useAutoScroll } from "~/modules/ai/chat/hooks/useAutoScroll"
import { useLoadMessages } from "~/modules/ai/chat/hooks/useLoadMessages"
import { convertLexicalToMarkdown } from "../../utils/lexical-markdown"
import { ChatInput } from "./ChatInput"
import { WelcomeScreen } from "./WelcomeScreen"
@ -88,11 +91,11 @@ export const ChatInterface = () => {
}, [])
const blockActions = useBlockActions()
const handleSendMessage = useEventCallback((message: string) => {
resetScrollState()
const handleSendMessage = useEventCallback(
(message: string | EditorState, editor: LexicalEditor | null) => {
resetScrollState()
chatActions.sendMessage({
parts: [
const parts: BizUIMessage["parts"] = [
{
type: "data-block",
data: blockActions.getBlocks().map((b) => ({
@ -100,12 +103,30 @@ export const ChatInterface = () => {
value: b.value,
})),
},
{ type: "text", text: message },
],
role: "user",
id: nanoid(),
})
})
]
if (typeof message === "string") {
parts.push({
type: "text",
text: message,
})
} else if (editor) {
parts.push({
type: "data-rich-text",
data: {
state: message.toJSON(),
text: convertLexicalToMarkdown(editor),
},
})
}
chatActions.sendMessage({
parts,
role: "user",
id: nanoid(),
})
},
)
useEffect(() => {
if (status === "submitted") {

View File

@ -1,3 +1,4 @@
import type { EditorState, LexicalEditor } from "lexical"
import { m } from "motion/react"
import { useTranslation } from "react-i18next"
@ -8,7 +9,7 @@ import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack
import { ChatInput } from "./ChatInput"
interface WelcomeScreenProps {
onSend: (message: string) => void
onSend: (message: EditorState | string, editor: LexicalEditor | null) => void
}
const DEFAULT_SHORTCUTS = [
"Generate today daily report",
@ -71,7 +72,7 @@ export const WelcomeScreen = ({ onSend }: WelcomeScreenProps) => {
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
onClick={() => onSend(shortcut.prompt)}
onClick={() => onSend(shortcut.prompt, null)}
title={shortcut.hotkey ? `${shortcut.name} (${shortcut.hotkey})` : shortcut.name}
>
{shortcut.name}
@ -87,7 +88,7 @@ export const WelcomeScreen = ({ onSend }: WelcomeScreenProps) => {
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: (enabledShortcuts.length + index) * 0.1 }}
onClick={() => onSend(suggestion)}
onClick={() => onSend(suggestion, null)}
>
{suggestion}
</m.button>

View File

@ -1,4 +1,5 @@
import type { ToolUIPart } from "ai"
import type { SerializedEditorState } from "lexical"
import { m } from "motion/react"
import * as React from "react"
@ -20,6 +21,7 @@ import {
import { ToolInvocationComponent } from "../ToolInvocationComponent"
import { AIDataBlockPart } from "./AIDataBlockPart"
import { AIMarkdownMessage } from "./AIMarkdownMessage"
import { AIRichTextMessage } from "./AIRichTextMessage"
interface MessagePartsProps {
message: BizUIMessage
@ -95,6 +97,16 @@ export const AIMessageParts: React.FC<MessagePartsProps> = React.memo(({ message
return <AIDataBlockPart key={partKey} blocks={part.data as AIChatContextBlock[]} />
}
case "data-rich-text": {
return (
<AIRichTextMessage
key={partKey}
data={part.data as { state: SerializedEditorState; text: string }}
className={isUser ? "text-white" : "text-text"}
/>
)
}
case "tool-displayAnalytics": {
return <AIDisplayAnalyticsPart key={partKey} part={part as AIDisplayAnalyticsTool} />
}

View File

@ -0,0 +1,74 @@
import { defaultLexicalTheme } from "@follow/components/ui/lexical-rich-editor/index.js"
import { cn } from "@follow/utils"
import { CodeHighlightNode, CodeNode } from "@lexical/code"
import { LinkNode } from "@lexical/link"
import { ListItemNode, ListNode } from "@lexical/list"
import { MarkNode } from "@lexical/mark"
import type { InitialConfigType } from "@lexical/react/LexicalComposer"
import { LexicalComposer } from "@lexical/react/LexicalComposer"
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import type { SerializedEditorState } from "lexical"
import { ParagraphNode, TextNode } from "lexical"
import * as React from "react"
function onError(error: Error) {
console.error("Lexical Read-Only Editor Error:", error)
}
interface AIRichTextMessageProps {
data: {
state: SerializedEditorState
text: string
}
className?: string
}
export const AIRichTextMessage: React.FC<AIRichTextMessageProps> = ({ data, className }) => {
const initialConfig: InitialConfigType = {
namespace: "AIRichTextDisplay",
theme: defaultLexicalTheme,
onError,
editable: false, // Read-only mode
editorState: JSON.stringify(data.state),
nodes: [
// Core nodes
ParagraphNode,
TextNode,
// Rich text nodes
HeadingNode,
QuoteNode,
// List nodes
ListNode,
ListItemNode,
// Code nodes
CodeNode,
CodeHighlightNode,
// Link nodes
LinkNode,
// Text format nodes
MarkNode,
],
}
return (
<div className={cn("text-text relative text-sm", className)}>
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={
<ContentEditable className="focus:outline-none" style={{ outline: "none" }} />
}
ErrorBoundary={LexicalErrorBoundary}
placeholder={null}
/>
</LexicalComposer>
</div>
)
}

View File

@ -2,10 +2,8 @@ import type { AsyncDb } from "@follow/database/db"
import { db } from "@follow/database/db"
import { aiChatMessagesTable, aiChatTable } from "@follow/database/schemas/index"
import { asc, count, eq, inArray, sql } from "drizzle-orm"
import type { SerializedEditorState } from "lexical"
import type { BizUIMessage } from "../__internal__/types"
import type { MessageContent } from "../utils/lexical-markdown"
class AIPersistServiceStatic {
async loadMessages(chatId: string) {
@ -51,27 +49,6 @@ class AIPersistServiceStatic {
return dbMessages.map((msg) => this.convertToUIMessage(msg))
}
/**
* Store a rich text message from user input
*/
async insertRichTextMessage(chatId: string, messageId: string, content: MessageContent) {
let richTextSchema: SerializedEditorState | undefined
if (content.format === "richtext") {
richTextSchema = content.content as SerializedEditorState
}
await db.insert(aiChatMessagesTable).values({
id: messageId,
chatId,
role: "user",
richTextSchema,
createdAt: new Date(),
status: "completed",
})
}
async insertMessages(chatId: string, messages: BizUIMessage[]) {
if (messages.length === 0) {
return
@ -90,7 +67,6 @@ class AIPersistServiceStatic {
role: message.role,
contentFormat: "plaintext" as const,
richTextSchema: undefined,
createdAt: new Date(),
status: "completed" as const,
finishedAt: message.metadata?.finishTime
@ -109,7 +85,6 @@ class AIPersistServiceStatic {
finishedAt: sql`excluded.finished_at`,
createdAt: sql`excluded.created_at`,
status: sql`excluded.status`,
richTextSchema: sql`excluded.rich_text_schema`,
},
})
}
@ -142,7 +117,6 @@ class AIPersistServiceStatic {
chatId,
role: message.role,
contentFormat: "plaintext" as const,
richTextSchema: undefined,
createdAt: new Date(),
status: "completed" as const,
finishedAt: message.metadata?.finishTime
@ -160,7 +134,6 @@ class AIPersistServiceStatic {
metadata: sql`excluded.metadata`,
finishedAt: sql`excluded.finished_at`,
status: sql`excluded.status`,
richTextSchema: sql`excluded.rich_text_schema`,
},
})
}
@ -239,7 +212,6 @@ class AIPersistServiceStatic {
metadata: sql`excluded.metadata`,
finishedAt: sql`excluded.finished_at`,
status: sql`excluded.status`,
richTextSchema: sql`excluded.rich_text_schema`,
},
})
}

View File

@ -28,6 +28,13 @@
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@headlessui/react": "2.2.4",
"@lexical/code": "0.33.1",
"@lexical/link": "0.33.1",
"@lexical/list": "0.33.1",
"@lexical/mark": "0.33.1",
"@lexical/markdown": "0.33.1",
"@lexical/react": "0.33.1",
"@lexical/rich-text": "0.33.1",
"@microflash/remark-callout-directives": "4.4.0",
"@radix-ui/react-accordion": "1.2.11",
"@radix-ui/react-avatar": "1.1.10",
@ -60,6 +67,7 @@
"input-otp": "1.4.2",
"jotai": "2.12.5",
"katex": "0.16.22",
"lexical": "0.33.1",
"masonic": "4.1.0",
"motion": "12.23.0",
"react-blurhash": "0.3.0",

View File

@ -6,7 +6,6 @@ import { MarkNode } from "@lexical/mark"
import { TRANSFORMERS } from "@lexical/markdown"
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"
import { LexicalComposer } from "@lexical/react/LexicalComposer"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
@ -18,113 +17,16 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import type { EditorState, LexicalEditor } from "lexical"
import { $getRoot, ParagraphNode, TextNode } from "lexical"
import { useEffect, useImperativeHandle, useRef, useState } from "react"
import { useImperativeHandle, useRef, useState } from "react"
const theme = {
paragraph: "mb-1",
text: {
bold: "font-semibold",
italic: "italic",
strikethrough: "line-through",
underline: "underline",
code: "bg-fill px-1 py-0.5 rounded text-sm font-mono",
},
heading: {
h1: "text-2xl font-bold mb-2",
h2: "text-xl font-bold mb-2",
h3: "text-lg font-bold mb-1",
h4: "text-base font-bold mb-1",
h5: "text-sm font-bold mb-1",
h6: "text-xs font-bold mb-1",
},
list: {
nested: {
listitem: "list-none",
},
ol: "list-decimal list-inside mb-2",
ul: "list-disc list-inside mb-2",
listitem: "mb-1",
},
quote: "border-l-4 border-accent pl-4 italic mb-2",
code: "bg-fill px-3 py-2 rounded font-mono text-sm mb-2 block overflow-x-auto",
codeHighlight: {
atrule: "text-purple-400",
attr: "text-blue-400",
boolean: "text-orange-400",
builtin: "text-purple-400",
cdata: "text-gray-400",
char: "text-green-400",
class: "text-blue-400",
"class-name": "text-blue-400",
comment: "text-gray-400",
constant: "text-orange-400",
deleted: "text-red-400",
doctype: "text-gray-400",
entity: "text-orange-400",
function: "text-yellow-400",
important: "text-red-400",
inserted: "text-green-400",
keyword: "text-purple-400",
namespace: "text-blue-400",
number: "text-orange-400",
operator: "text-pink-400",
prolog: "text-gray-400",
property: "text-blue-400",
punctuation: "text-gray-300",
regex: "text-green-400",
selector: "text-green-400",
string: "text-green-400",
symbol: "text-orange-400",
tag: "text-red-400",
url: "text-blue-400",
variable: "text-orange-400",
},
link: "text-accent underline hover:text-accent/80",
mark: "bg-yellow-200 px-1 py-0.5 rounded",
}
import { KeyboardPlugin } from "./plugins"
import { defaultLexicalTheme } from "./theme"
import type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
function onError(error: Error) {
console.error("Lexical Editor Error:", error)
}
export interface LexicalRichEditorRef {
getEditor: () => LexicalEditor
focus: () => void
clear: () => void
isEmpty: () => boolean
}
interface LexicalRichEditorProps {
placeholder?: string
className?: string
onChange?: (editorState: EditorState, editor: LexicalEditor) => void
onKeyDown?: (event: KeyboardEvent) => boolean
autoFocus?: boolean
}
function KeyboardPlugin({ onKeyDown }: { onKeyDown?: (event: KeyboardEvent) => boolean }) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!onKeyDown) return
const handleKeyDown = (event: KeyboardEvent) => {
onKeyDown(event)
}
return editor.registerRootListener((rootElement, prevRootElement) => {
if (prevRootElement !== null) {
prevRootElement.removeEventListener("keydown", handleKeyDown)
}
if (rootElement !== null) {
rootElement.addEventListener("keydown", handleKeyDown)
}
})
}, [editor, onKeyDown])
return null
}
export const LexicalRichEditor = ({
ref,
placeholder = "Enter your message...",
@ -132,12 +34,21 @@ export const LexicalRichEditor = ({
onChange,
onKeyDown,
autoFocus = false,
namespace = "LexicalRichEditor",
theme = defaultLexicalTheme,
enabledPlugins = {
history: true,
markdown: true,
list: true,
link: true,
autoFocus: true,
},
}: LexicalRichEditorProps & { ref?: React.RefObject<LexicalRichEditorRef | null> }) => {
const editorRef = useRef<LexicalEditor | null>(null)
const [isEmpty, setIsEmpty] = useState(true)
const initialConfig = {
namespace: "AIChatRichEditor",
namespace,
theme,
onError,
nodes: [
@ -214,14 +125,17 @@ export const LexicalRichEditor = ({
ErrorBoundary={LexicalErrorBoundary}
/>
<OnChangePlugin onChange={handleChange} />
<HistoryPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
<ListPlugin />
<LinkPlugin />
{enabledPlugins.history && <HistoryPlugin />}
{enabledPlugins.markdown && <MarkdownShortcutPlugin transformers={TRANSFORMERS} />}
{enabledPlugins.list && <ListPlugin />}
{enabledPlugins.link && <LinkPlugin />}
<KeyboardPlugin onKeyDown={onKeyDown} />
{autoFocus && <AutoFocusPlugin />}
{autoFocus && enabledPlugins.autoFocus && <AutoFocusPlugin />}
</div>
</LexicalComposer>
)
}
LexicalRichEditor.displayName = "LexicalRichEditor"

View File

@ -0,0 +1,4 @@
export { LexicalRichEditor } from "./LexicalRichEditor"
export { KeyboardPlugin } from "./plugins"
export { defaultLexicalTheme } from "./theme"
export type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"

View File

@ -0,0 +1,29 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { useEffect } from "react"
interface KeyboardPluginProps {
onKeyDown?: (event: KeyboardEvent) => boolean
}
export function KeyboardPlugin({ onKeyDown }: KeyboardPluginProps) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!onKeyDown) return
const handleKeyDown = (event: KeyboardEvent) => {
onKeyDown(event)
}
return editor.registerRootListener((rootElement, prevRootElement) => {
if (prevRootElement !== null) {
prevRootElement.removeEventListener("keydown", handleKeyDown)
}
if (rootElement !== null) {
rootElement.addEventListener("keydown", handleKeyDown)
}
})
}, [editor, onKeyDown])
return null
}

View File

@ -0,0 +1 @@
export { KeyboardPlugin } from "./KeyboardPlugin"

View File

@ -0,0 +1,69 @@
/**
* Default Lexical theme configuration for consistent styling
* across editable and read-only rich text components.
*
* Uses Follow's UIKit color system with Tailwind classes for
* automatic light/dark mode adaptation.
*/
export const defaultLexicalTheme = {
paragraph: "mb-1",
text: {
bold: "font-semibold",
italic: "italic",
strikethrough: "line-through",
underline: "underline",
code: "bg-fill px-1 py-0.5 rounded text-sm font-mono",
},
heading: {
h1: "text-2xl font-bold mb-2",
h2: "text-xl font-bold mb-2",
h3: "text-lg font-bold mb-1",
h4: "text-base font-bold mb-1",
h5: "text-sm font-bold mb-1",
h6: "text-xs font-bold mb-1",
},
list: {
nested: {
listitem: "list-none",
},
ol: "list-decimal list-inside mb-2",
ul: "list-disc list-inside mb-2",
listitem: "mb-1",
},
quote: "border-l-4 border-accent pl-4 italic mb-2",
code: "bg-fill px-3 py-2 rounded font-mono text-sm mb-2 block overflow-x-auto",
codeHighlight: {
atrule: "text-purple-400",
attr: "text-blue-400",
boolean: "text-orange-400",
builtin: "text-purple-400",
cdata: "text-gray-400",
char: "text-green-400",
class: "text-blue-400",
"class-name": "text-blue-400",
comment: "text-gray-400",
constant: "text-orange-400",
deleted: "text-red-400",
doctype: "text-gray-400",
entity: "text-orange-400",
function: "text-yellow-400",
important: "text-red-400",
inserted: "text-green-400",
keyword: "text-purple-400",
namespace: "text-blue-400",
number: "text-orange-400",
operator: "text-pink-400",
prolog: "text-gray-400",
property: "text-blue-400",
punctuation: "text-gray-300",
regex: "text-green-400",
selector: "text-green-400",
string: "text-green-400",
symbol: "text-orange-400",
tag: "text-red-400",
url: "text-blue-400",
variable: "text-orange-400",
},
link: "text-accent underline hover:text-accent/80",
mark: "bg-yellow-200 px-1 py-0.5 rounded",
}

View File

@ -0,0 +1,25 @@
import type { EditorState, LexicalEditor } from "lexical"
export interface LexicalRichEditorRef {
getEditor: () => LexicalEditor
focus: () => void
clear: () => void
isEmpty: () => boolean
}
export interface LexicalRichEditorProps {
placeholder?: string
className?: string
onChange?: (editorState: EditorState, editor: LexicalEditor) => void
onKeyDown?: (event: KeyboardEvent) => boolean
autoFocus?: boolean
namespace?: string
theme?: any
enabledPlugins?: {
history?: boolean
markdown?: boolean
list?: boolean
link?: boolean
autoFocus?: boolean
}
}

View File

@ -0,0 +1 @@
ALTER TABLE `ai_chat_messages` DROP COLUMN `rich_text_schema`;

View File

@ -0,0 +1,938 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6cfe24ba-739c-4f3f-a894-dd70ecb47d02",
"prevId": "339611d0-cf1e-4ec9-9bf6-275608b815f5",
"tables": {
"ai_chat_messages": {
"name": "ai_chat_messages",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"chat_id": {
"name": "chat_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'completed'"
},
"finished_at": {
"name": "finished_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"message_parts": {
"name": "message_parts",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_ai_chat_messages_chat_id_created_at": {
"name": "idx_ai_chat_messages_chat_id_created_at",
"columns": ["chat_id", "created_at"],
"isUnique": false
},
"idx_ai_chat_messages_status": {
"name": "idx_ai_chat_messages_status",
"columns": ["status"],
"isUnique": false
},
"idx_ai_chat_messages_chat_id_role": {
"name": "idx_ai_chat_messages_chat_id_role",
"columns": ["chat_id", "role"],
"isUnique": false
}
},
"foreignKeys": {
"ai_chat_messages_chat_id_ai_chat_sessions_id_fk": {
"name": "ai_chat_messages_chat_id_ai_chat_sessions_id_fk",
"tableFrom": "ai_chat_messages",
"tableTo": "ai_chat_sessions",
"columnsFrom": ["chat_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ai_chat_sessions": {
"name": "ai_chat_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"idx_ai_chat_sessions_updated_at": {
"name": "idx_ai_chat_sessions_updated_at",
"columns": ["updated_at"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collections": {
"name": "collections",
"columns": {
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"entries": {
"name": "entries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_content": {
"name": "source_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"readability_updated_at": {
"name": "readability_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"guid": {
"name": "guid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_url": {
"name": "author_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_avatar": {
"name": "author_avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inserted_at": {
"name": "inserted_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"media": {
"name": "media",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categories": {
"name": "categories",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"attachments": {
"name": "attachments",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"extra": {
"name": "extra",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inbox_handle": {
"name": "inbox_handle",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sources": {
"name": "sources",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"feeds": {
"name": "feeds",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error_at": {
"name": "error_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"site_url": {
"name": "site_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"owner_user_id": {
"name": "owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subscription_count": {
"name": "subscription_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updates_per_week": {
"name": "updates_per_week",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_entry_published_at": {
"name": "latest_entry_published_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tip_users": {
"name": "tip_users",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"images": {
"name": "images",
"columns": {
"url": {
"name": "url",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"colors": {
"name": "colors",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"inboxes": {
"name": "inboxes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"secret": {
"name": "secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lists": {
"name": "lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feed_ids": {
"name": "feed_ids",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fee": {
"name": "fee",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"owner_user_id": {
"name": "owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subscription_count": {
"name": "subscription_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"purchase_amount": {
"name": "purchase_amount",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscriptions": {
"name": "subscriptions",
"columns": {
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"list_id": {
"name": "list_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inbox_id": {
"name": "inbox_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_private": {
"name": "is_private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hide_from_timeline": {
"name": "hide_from_timeline",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"summaries": {
"name": "summaries",
"columns": {
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"readability_summary": {
"name": "readability_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"unq": {
"name": "unq",
"columns": ["entry_id", "language"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"translations": {
"name": "translations",
"columns": {
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"readability_content": {
"name": "readability_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"translation-unique-index": {
"name": "translation-unique-index",
"columns": ["entry_id", "language"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"unread": {
"name": "unread",
"columns": {
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"handle": {
"name": "handle",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_me": {
"name": "is_me",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"bio": {
"name": "bio",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"website": {
"name": "website",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"social_links": {
"name": "social_links",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -239,6 +239,13 @@
"when": 1753890574250,
"tag": "0033_shiny_sebastian_shaw",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1753937164043,
"tag": "0034_curly_darkstar",
"breakpoints": true
}
]
}

View File

@ -34,6 +34,7 @@ import m0030 from "./0030_common_gabe_jones.sql"
import m0031 from "./0031_kind_ikaris.sql"
import m0032 from "./0032_orange_prima.sql"
import m0033 from "./0033_shiny_sebastian_shaw.sql"
import m0034 from "./0034_curly_darkstar.sql"
import journal from "./meta/_journal.json"
export default {
@ -73,5 +74,6 @@ export default {
m0031,
m0032,
m0033,
m0034,
},
}

View File

@ -228,18 +228,11 @@ export const aiChatMessagesTable = sqliteTable(
.notNull()
.references(() => aiChatTable.chatId, { onDelete: "cascade" }),
// Core message properties matching Vercel AI SDK UIMessage
role: t.text("role").notNull().$type<"user" | "assistant" | "system">(),
richTextSchema: t
.text("rich_text_schema", { mode: "json" })
.$type<import("lexical").SerializedEditorState>(), // Lexical schema for user rich text
// Vercel AI SDK UIMessage properties
createdAt: t.integer("created_at", { mode: "timestamp_ms" }),
metadata: t.text("metadata", { mode: "json" }).$type<any>(),
// Message processing status
status: t
.text("status")
.$type<"pending" | "streaming" | "completed" | "error">()

View File

@ -1462,6 +1462,27 @@ importers:
'@headlessui/react':
specifier: 2.2.4
version: 2.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@lexical/code':
specifier: 0.33.1
version: 0.33.1
'@lexical/link':
specifier: 0.33.1
version: 0.33.1
'@lexical/list':
specifier: 0.33.1
version: 0.33.1
'@lexical/mark':
specifier: 0.33.1
version: 0.33.1
'@lexical/markdown':
specifier: 0.33.1
version: 0.33.1
'@lexical/react':
specifier: 0.33.1
version: 0.33.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(yjs@13.6.27)
'@lexical/rich-text':
specifier: 0.33.1
version: 0.33.1
'@microflash/remark-callout-directives':
specifier: 4.4.0
version: 4.4.0(patch_hash=6160b1cf0eab6deca36415693dda703746edcd42e0e64b9174294e2210481396)
@ -1558,6 +1579,9 @@ importers:
katex:
specifier: 0.16.22
version: 0.16.22
lexical:
specifier: 0.33.1
version: 0.33.1
masonic:
specifier: 4.1.0
version: 4.1.0(react@19.0.0)