From fd91d9e6ca579dcd1a936a40c7881c79f2add722 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 7 Oct 2024 16:37:46 -0400 Subject: [PATCH 1/8] wip --- packages/cli/src/cmds/index/rpc.ts | 10 + packages/cli/src/cmds/index/rpcServer.ts | 2 + .../src/rpc/explain/navie/historyWindows.ts | 36 +- .../cli/src/rpc/explain/navie/navie-remote.ts | 2 - packages/cli/src/rpc/navie/register.ts | 3 + packages/cli/src/rpc/navie/suggest.ts | 2 +- packages/cli/src/rpc/navie/thread/index.ts | 568 ++++++++++++++++++ packages/cli/src/rpc/navie/thread/pinItem.ts | 18 + packages/cli/src/rpc/navie/thread/query.ts | 15 + .../cli/src/rpc/navie/thread/sendMessage.ts | 31 + .../cli/src/rpc/navie/thread/subscribe.ts | 93 +++ .../components/chat-search/PinnedItems.vue | 28 +- .../chat-search/StreamingMessageContent.ts | 6 +- .../components/src/components/chat/Chat.vue | 90 +-- .../src/components/chat/CodeFencedContent.vue | 37 ++ .../src/components/chat/ContextContainer.vue | 31 +- .../components/src/components/chat/File.vue | 1 - .../components/chat/MarkdownCodeSnippet.vue | 6 +- .../src/components/chat/MermaidDiagram.vue | 11 +- .../src/components/chat/UserMessage.vue | 149 ++--- .../src/components/mixins/contextItem.ts | 6 +- packages/components/src/lib/AppMapRPC.ts | 176 +++++- packages/components/src/lib/pinnedItems.ts | 45 ++ packages/components/src/pages/ChatSearch.vue | 352 ++++++----- .../stories/chat-search/ChatSearch.stories.js | 8 +- packages/rpc/src/navie.ts | 59 ++ 26 files changed, 1441 insertions(+), 344 deletions(-) create mode 100644 packages/cli/src/rpc/navie/thread/index.ts create mode 100644 packages/cli/src/rpc/navie/thread/pinItem.ts create mode 100644 packages/cli/src/rpc/navie/thread/query.ts create mode 100644 packages/cli/src/rpc/navie/thread/sendMessage.ts create mode 100644 packages/cli/src/rpc/navie/thread/subscribe.ts create mode 100644 packages/components/src/components/chat/CodeFencedContent.vue create mode 100644 packages/components/src/lib/pinnedItems.ts diff --git a/packages/cli/src/cmds/index/rpc.ts b/packages/cli/src/cmds/index/rpc.ts index 5a71f7c6d0..4dd9056042 100644 --- a/packages/cli/src/cmds/index/rpc.ts +++ b/packages/cli/src/cmds/index/rpc.ts @@ -33,6 +33,10 @@ import { join } from 'path'; import { homedir } from 'os'; import { navieWelcomeV2 } from '../../rpc/navie/welcome'; import { navieRegisterV1 } from '../../rpc/navie/register'; +import { navieThreadSendMessageHandler } from '../../rpc/navie/thread/sendMessage'; +import { navieThreadPinItemHandler } from '../../rpc/navie/thread/pinItem'; +import { registerNavieProvider } from '../../rpc/navie/thread'; +import { navieThreadQueryHandler } from '../../rpc/navie/thread/query'; export const command = 'rpc'; export const describe = 'Run AppMap JSON-RPC server'; @@ -76,6 +80,10 @@ export function rpcMethods(navie: INavieProvider, codeEditor?: string): RpcHandl navieSuggestHandlerV1(navie), navieWelcomeV2(navie), navieRegisterV1(codeEditor), + navieThreadSendMessageHandler(), + navieThreadPinItemHandler(), + navieThreadPinItemHandler(), + navieThreadQueryHandler(), ]; } @@ -83,6 +91,8 @@ export const handler = async (argv: HandlerArguments) => { verbose(argv.verbose); const navie = buildNavieProvider(argv); + registerNavieProvider(navie); + let codeEditor: string | undefined = argv.codeEditor; if (!codeEditor) { codeEditor = detectCodeEditor(); diff --git a/packages/cli/src/cmds/index/rpcServer.ts b/packages/cli/src/cmds/index/rpcServer.ts index f3ec1bb2cf..935857445f 100644 --- a/packages/cli/src/cmds/index/rpcServer.ts +++ b/packages/cli/src/cmds/index/rpcServer.ts @@ -11,6 +11,7 @@ import { Server } from 'http'; import shadowLocalhost from '../../lib/shadowLocalhost'; import { RpcCallback, RpcHandler, toJaysonRpcError } from '../../rpc/rpc'; +import { sseMiddleware } from '../../rpc/navie/thread/subscribe'; const debug = makeDebug('appmap:rpcServer'); @@ -63,6 +64,7 @@ export default class RPCServer { const app = connect(); app.use(cors({ methods: ['POST'] })); app.use(jsonParser()); + app.use(sseMiddleware); app.use(server.middleware()); const listener = app.listen(this.bindPort, 'localhost'); diff --git a/packages/cli/src/rpc/explain/navie/historyWindows.ts b/packages/cli/src/rpc/explain/navie/historyWindows.ts index a507bdf627..82f0089ffe 100644 --- a/packages/cli/src/rpc/explain/navie/historyWindows.ts +++ b/packages/cli/src/rpc/explain/navie/historyWindows.ts @@ -1,17 +1,47 @@ -import { ThreadAccessError } from './ihistory'; +import { join } from 'path'; +import { QuestionField, ResponseField, ThreadAccessError } from './ihistory'; import IHistory from './ihistory'; import Thread from './thread'; +import { mkdir, writeFile } from 'fs/promises'; export default class HistoryWindows implements IHistory { constructor(public readonly directory: string) { console.warn('History is currently disabled. It is not yet implemented on Windows.'); } - token(): void { + private async appendItem(threadId: string, item: Record): Promise { + const threadDir = join(this.directory, 'threads', threadId); + await mkdir(threadDir, { recursive: true }); + + const historyFile = join(threadDir, 'history.jsonl'); + await writeFile(historyFile, `${JSON.stringify(item)}\n`, { flag: 'a' }); + } + + token( + threadId: string, + userMessageId: string, + assistantMessageId: string, + token: string, + extensions: Record = { + answer: 'md', + assistantMessageId: 'txt', + } + ): void { // Do nothing } - question(): void { + question( + threadId: string, + userMessageId: string, + question: string, + codeSelection: string | undefined, + prompt: string | undefined, + extensions: Record = { + question: 'txt', + codeSelection: 'txt', + prompt: 'md', + } + ): void { // Do nothing } diff --git a/packages/cli/src/rpc/explain/navie/navie-remote.ts b/packages/cli/src/rpc/explain/navie/navie-remote.ts index 31a2a2f30d..301aad32b5 100644 --- a/packages/cli/src/rpc/explain/navie/navie-remote.ts +++ b/packages/cli/src/rpc/explain/navie/navie-remote.ts @@ -151,8 +151,6 @@ export default class RemoteNavie extends EventEmitter implements INavie { ); const onAck = async (userMessageId: string, threadId: string) => { - await callbackHandler.onAck(userMessageId, threadId, question, codeSelection, prompt); - this.emit('ack', userMessageId, threadId); }; diff --git a/packages/cli/src/rpc/navie/register.ts b/packages/cli/src/rpc/navie/register.ts index 53e3f34a05..38e0ab26c6 100644 --- a/packages/cli/src/rpc/navie/register.ts +++ b/packages/cli/src/rpc/navie/register.ts @@ -12,6 +12,8 @@ import { RpcHandler } from '../rpc'; import { getLLMConfiguration } from '../llmConfiguration'; import detectAIEnvVar from '../../cmds/index/aiEnvVar'; import configuration from '../configuration'; +import { INavieProvider } from '../explain/navie/inavie'; +import { registerThread } from './thread'; export async function register( codeEditor: string | undefined @@ -40,6 +42,7 @@ export async function register( if (codeEditor) projectParameters.codeEditor = codeEditor; const thread = await AI.createConversationThread({ modelParameters, projectParameters }); + registerThread(thread); return { thread }; } diff --git a/packages/cli/src/rpc/navie/suggest.ts b/packages/cli/src/rpc/navie/suggest.ts index 93880446ba..56d28c2852 100644 --- a/packages/cli/src/rpc/navie/suggest.ts +++ b/packages/cli/src/rpc/navie/suggest.ts @@ -5,7 +5,7 @@ import { INavieProvider } from '../explain/navie/inavie'; // We don't want to support context lookups const NOP = () => Promise.resolve([]); -function getSuggestions( +export function getSuggestions( navieProvider: INavieProvider, threadId: string ): Promise { diff --git a/packages/cli/src/rpc/navie/thread/index.ts b/packages/cli/src/rpc/navie/thread/index.ts new file mode 100644 index 0000000000..9493ca2480 --- /dev/null +++ b/packages/cli/src/rpc/navie/thread/index.ts @@ -0,0 +1,568 @@ +import { ContextV2, Help, ProjectInfo, UserContext } from '@appland/navie'; +import configuration from '../../configuration'; +import INavie, { INavieProvider } from '../../explain/navie/inavie'; +import collectProjectInfos from '../../../cmds/navie/projectInfo'; +import collectHelp from '../../../cmds/navie/help'; +import { basename, dirname, join } from 'path'; +import collectContext, { buildContextRequest } from '../../explain/collect-context'; +import detectCodeEditor from '../../../lib/detectCodeEditor'; +import { EventEmitter } from 'stream'; +import { randomUUID } from 'crypto'; +import { ConversationThread } from '@appland/client'; +import { getSuggestions } from '../suggest'; +import { NavieRpc } from '@appland/rpc'; +import { homedir } from 'os'; +import { mkdir, readFile, writeFile } from 'fs/promises'; +import sqlite3 from 'better-sqlite3'; + +type NavieThreadInitEvent = { + type: 'thread-init'; + conversationThread: ConversationThread; +}; + +type NavieTokenMetadataEvent = { + type: 'token-metadata'; + codeBlockId: string; + metadata: Record; +}; + +type NavieTokenEvent = { + type: 'token'; + messageId: string; + token: string; + codeBlockId?: string; +}; + +type NavieMessageEvent = { + type: 'message'; + role: 'system' | 'assistant' | 'user'; + messageId: string; + content: string; +}; + +type NaviePromptSuggestionsEvent = { + type: 'prompt-suggestions'; + suggestions: NavieRpc.V1.Suggest.Response; + messageId: string; +}; + +type NavieMessageCompleteEvent = { + type: 'message-complete'; + messageId: string; +}; + +type NaviePinItemEvent = PinnedItem & { + type: 'pin-item'; +}; + +type NavieErrorEvent = { + type: 'error'; + error: unknown; +}; + +type NavieBeginContextSearchEvent = { + type: 'begin-context-search'; + contextType: 'help' | 'project-info' | 'context'; + id: string; +}; + +type NavieCompleteContextSearchEvent = { + type: 'complete-context-search'; + id: string; + result: Help.HelpResponse | ProjectInfo.ProjectInfoResponse | ContextV2.ContextResponse; +}; + +type Timestamp = { + time: number; +}; + +type NavieEvent = + | NavieBeginContextSearchEvent + | NavieCompleteContextSearchEvent + | NavieErrorEvent + | NavieMessageEvent + | NavieMessageCompleteEvent + | NaviePinItemEvent + | NaviePromptSuggestionsEvent + | NavieThreadInitEvent + | NavieTokenEvent + | NavieTokenMetadataEvent; + +type TimestampNavieEvent = Timestamp & NavieEvent; + +interface ContextEvents { + on( + event: 'event', + listener: (data: NavieBeginContextSearchEvent | NavieCompleteContextSearchEvent) => void + ): this; +} +class Navie { + private readonly codeEditor?: string; + + constructor(private readonly navieProvider: INavieProvider) { + this.codeEditor = detectCodeEditor(); + } + + getNavie(): [INavie, ContextEvents] { + const emitter = new EventEmitter(); + const navie = this.navieProvider( + async (data: ContextV2.ContextRequest) => { + const id = randomUUID(); + emitter.emit('event', { type: 'begin-context-search', contextType: 'context', id }); + const result = await this.searchContext(data); + emitter.emit('event', { type: 'complete-context-search', id, result }); + return result; + }, + async () => { + const id = randomUUID(); + emitter.emit('event', { type: 'begin-context-search', contextType: 'project-info', id }); + const result = await this.projectInfoContext(); + emitter.emit('event', { type: 'complete-context-search', id, result }); + return result; + }, + async (data: Help.HelpRequest) => { + const id = randomUUID(); + emitter.emit('event', { type: 'begin-context-search', contextType: 'help', id }); + const result = await this.helpContext(data); + emitter.emit('event', { type: 'complete-context-search', id, result }); + return result; + } + ); + return [navie, emitter as unknown as ContextEvents]; + } + + async searchContext(data: ContextV2.ContextRequest): Promise { + const { vectorTerms } = data; + const { tokenCount } = data; + const config = configuration(); + const { projectDirectories } = configuration(); + const appmapDirectories = await config.appmapDirectories(); + const labels = data.labels ?? []; + const keywords = vectorTerms || []; + if (keywords.length > 0) { + if ( + labels.find( + (label) => + label.name === ContextV2.ContextLabelName.Architecture && + label.weight === ContextV2.ContextLabelWeight.High + ) ?? + labels.find( + (label) => + label.name === ContextV2.ContextLabelName.Overview && + label.weight === ContextV2.ContextLabelWeight.High + ) + ) { + keywords.push('architecture'); + keywords.push('design'); + keywords.push('readme'); + keywords.push('about'); + keywords.push('overview'); + for (const dir of projectDirectories) { + keywords.push(basename(dir)); + } + } + } + + // TODO: More accurate char limit? Probably doesn't matter because they will be + // pruned by the client AI anyway. + // The meaning of tokenCount is "try and get at least this many tokens" + const charLimit = tokenCount * 3; + + const contextRequest = buildContextRequest( + appmapDirectories.map((dir) => dir.directory), + projectDirectories, + undefined, + keywords, + charLimit, + data + ); + + const searchResult = await collectContext( + appmapDirectories.map((dir) => dir.directory), + projectDirectories, + charLimit, + contextRequest.vectorTerms, + contextRequest.request + ); + + return searchResult.context; + } + + async projectInfoContext(): Promise { + return await collectProjectInfos(this.codeEditor); + } + + helpContext(data: Help.HelpRequest): Promise { + return collectHelp(data); + } +} + +let navieProvider: INavieProvider | undefined; +export function registerNavieProvider(_navieProvider: INavieProvider): void { + navieProvider = _navieProvider; +} + +export function getNavieProvider(): INavieProvider { + if (!navieProvider) { + throw new Error('No navie provider available'); + } + return navieProvider; +} + +function getNavie(_navieProvider?: INavieProvider): [INavie, ContextEvents] { + const provider = _navieProvider ?? getNavieProvider(); + const navie = new Navie(provider); + return navie.getNavie(); +} + +type PinnedItem = { + operation: 'pin' | 'unpin'; + uri?: string; + handle?: number; +}; + +type EventListener = (...args: any[]) => void; + +export class Thread { + private eventEmitter = new EventEmitter(); + private listeners = new Map(); + private log: TimestampNavieEvent[] = []; + private codeBlockId: string | undefined; + private codeBlockLength: number | undefined; + private lastEventWritten: number | undefined; + private lastTokenBeganCodeBlock = false; + private static readonly HISTORY_DIRECTORY = join(homedir(), '.appmap', 'navie', 'history'); + + constructor(public readonly conversationThread: ConversationThread) {} + + initialize() { + this.logEvent({ type: 'thread-init', conversationThread: this.conversationThread }); + } + + private logEvent(event: NavieEvent) { + const timeStamped = { ...event, time: Date.now() }; + this.log.push(timeStamped); + this.eventEmitter.emit('event', timeStamped); + } + + private async emitSuggestions(messageId: string) { + const suggestions = await getSuggestions(getNavieProvider(), this.conversationThread.id); + this.logEvent({ type: 'prompt-suggestions', suggestions, messageId }); + } + + static async load(threadId: string): Promise { + const historyFilePath = Thread.getHistoryFilePath(threadId); + let initEvent: NavieThreadInitEvent | undefined; + const eventLog: TimestampNavieEvent[] = []; + + try { + const jsonLines = await readFile(historyFilePath, 'utf-8').then((data) => data.split('\n')); + for (const json of jsonLines) { + if (json.length === 0) continue; + try { + const event = JSON.parse(json) as TimestampNavieEvent; + if (!initEvent && event.type === 'thread-init') { + initEvent = event; + } + eventLog.push(event); + } catch (e) { + console.error('Failed to parse event', json, e); + } + } + } catch (e) { + throw new Error(`Failed to load history file ${historyFilePath}: ${String(e)}`); + } + + if (!initEvent) throw new Error('Thread init event not found'); + + const thread = new Thread(initEvent.conversationThread); + thread.log = eventLog; + thread.lastEventWritten = eventLog.length; + + return thread; + } + + static getHistoryFilePath(threadId: string) { + return join(Thread.HISTORY_DIRECTORY, `${threadId}.navie.jsonl`); + } + + on(event: 'event', clientId: string, listener: (event: NavieEvent) => void): this; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, clientId: string, listener: (...args: any[]) => void): this { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + this.eventEmitter.on(event, listener); + + // Keep track of listeners for each client + // When the client disconnects, we can remove all listeners for that client + let listeners = this.listeners.get(clientId); + if (!listeners) { + listeners = [listener]; + this.listeners.set(clientId, listeners); + } + listeners.push(listener); + + return this; + } + + removeAllListeners(clientId: string) { + const listeners = this.listeners.get(clientId); + if (!listeners) return; + + for (const listener of listeners) { + this.eventEmitter.removeListener('event', listener); + } + } + + private async flush() { + if (this.log.length === this.lastEventWritten) return; + + const historyFilePath = Thread.getHistoryFilePath(this.conversationThread.id); + const serialized = this.log.slice(this.lastEventWritten ?? 0).map((e) => JSON.stringify(e)); + + try { + await mkdir(dirname(historyFilePath), { recursive: true }); + await writeFile(historyFilePath, serialized.join('\n') + '\n', { flag: 'a' }); + this.lastEventWritten = this.log.length; + } catch (e) { + console.error('Failed to write to history file', e); + } + + try { + let lastUserMessage: NavieMessageEvent | undefined; + for (let i = this.log.length - 1; i >= 0; i--) { + const e = this.log[i]; + if (e.type === 'message' && e.role === 'user') { + lastUserMessage = e; + break; + } + } + const title = lastUserMessage?.content.slice(0, 100); + ThreadIndex.getInstance().index(this.conversationThread.id, historyFilePath, title); + } catch (e) { + console.error('Failed to update thread index', e); + } + } + + pinItem(item: PinnedItem) { + this.logEvent({ type: 'pin-item', ...item }); + } + + sendMessage(message: string, codeSelection?: UserContext.Context) { + const [navie, contextEvents] = getNavie(); + let responseId: string | undefined; + contextEvents.on('event', (event) => { + this.logEvent(event); + }); + return new Promise((resolve, reject) => { + navie + .on('ack', (userMessageId: string) => { + this.logEvent({ + type: 'message', + role: 'user', + messageId: userMessageId, + content: message, + }); + resolve(); + }) + .on('token', (token: string, messageId: string) => { + if (!responseId) responseId = messageId; + + const subTokens = token.split(/^(`{3,})\n?/gm); + for (const subToken of subTokens) { + if (subToken.length === 0) continue; + + const fileMatch = subToken.match(/^/); + if (fileMatch) { + this.codeBlockId = this.codeBlockId ?? randomUUID(); + this.logEvent({ + type: 'token-metadata', + codeBlockId: this.codeBlockId, + metadata: { + location: fileMatch[1], + }, + }); + // Don't emit this token + continue; + } + const language = this.lastTokenBeganCodeBlock ? subToken.match(/^[^\s]+\n/) : null; + if (language && this.codeBlockId) { + this.logEvent({ + type: 'token-metadata', + codeBlockId: this.codeBlockId, + metadata: { + language: language[0].trim(), + }, + }); + } + + this.lastTokenBeganCodeBlock = false; + + let clearCodeBlock = false; + if (subToken.match(/^`{3,}/)) { + // Code block fences + if (this.codeBlockLength === undefined) { + this.codeBlockId = this.codeBlockId ?? randomUUID(); + this.codeBlockLength = subToken.length; + this.lastTokenBeganCodeBlock = true; + } else if (subToken.length === this.codeBlockLength) { + clearCodeBlock = true; + } + } + this.logEvent({ + type: 'token', + token: subToken, + messageId, + codeBlockId: this.codeBlockId, + }); + if (clearCodeBlock) { + this.codeBlockId = undefined; + this.codeBlockLength = undefined; + } + } + }) + .on('error', (err: Error) => { + this.logEvent({ type: 'error', error: err }); + reject(err); + }) + .on('complete', () => { + if (!responseId) throw new Error('recieved complete without messageId'); + this.logEvent({ type: 'message-complete', messageId: responseId }); + this.flush() + .then(() => this.emitSuggestions(responseId!)) + .then(() => this.flush()) + .catch(console.error); + }) + .ask(this.conversationThread.id, message, codeSelection, undefined) + .catch(reject); + }); + } + + getEvents(sinceNonce?: number): readonly TimestampNavieEvent[] { + return this.log.slice(sinceNonce ?? 0); + } +} + +const threads = new Map(); +export function registerThread(conversationThread: ConversationThread) { + const thread = new Thread(conversationThread); + thread.initialize(); + console.log(`Registered thread ${conversationThread.id}`); + threads.set(conversationThread.id, thread); +} + +/** + * Returns a thread from memory or loads it from disk if it's not yet in memory. If the thread is + * not found, or the load fails, an error will be thrown. + * @param threadId the thread identifier to retrieve + * @returns the thread + */ +export async function getThread(threadId: string): Promise { + let thread = threads.get(threadId); + if (!thread) { + try { + thread = await Thread.load(threadId); + } catch (e) { + // misbehaving threads will be deleted from the index + // they probably no longer exist + ThreadIndex.getInstance().delete(threadId); + throw e; + } + threads.set(thread.conversationThread.id, thread); + } + return thread; +} + +const INITIALIZE_SQL = `CREATE TABLE IF NOT EXISTS threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + title TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uuid_format CHECK (length(uuid) = 36) +); + +CREATE INDEX IF NOT EXISTS idx_created_at ON threads (created_at); +CREATE INDEX IF NOT EXISTS idx_uuid ON threads (uuid); +`; + +const QUERY_INSERT_SQL = `INSERT INTO threads (uuid, path, title) VALUES (?, ?, ?) +ON CONFLICT (uuid) DO UPDATE SET updated_at = CURRENT_TIMESTAMP, title = ?`; +const QUERY_DELETE_SQL = `DELETE FROM threads WHERE uuid = ?`; +interface QueryOptions { + uuid?: string; + maxCreatedAt?: Date; + orderBy?: 'created_at' | 'updated_at'; + limit?: number; + offset?: number; +} + +interface ThreadIndexItem { + id: string; + path: string; + title: string; + createdAt: Date; + updatedAt: Date; +} + +export class ThreadIndex { + private readonly db: sqlite3.Database; + private queryInsert: sqlite3.Statement; + private queryDelete: sqlite3.Statement; + + private static readonly DATABASE_PATH = join(homedir(), '.appmap', 'navie', 'thread-index.db'); + private static instance: ThreadIndex; + + private constructor() { + this.db = new sqlite3(ThreadIndex.DATABASE_PATH); + this.db.exec(INITIALIZE_SQL); + + this.queryInsert = this.db.prepare(QUERY_INSERT_SQL); + this.queryDelete = this.db.prepare(QUERY_DELETE_SQL); + } + + static getInstance() { + if (!ThreadIndex.instance) { + ThreadIndex.instance = new ThreadIndex(); + } + return ThreadIndex.instance; + } + + index(threadId: string, path: string, title?: string) { + return this.queryInsert.run(threadId, path, title, title); + } + + delete(threadId: string) { + return this.queryDelete.run(threadId); + } + + query(options: QueryOptions): ThreadIndexItem[] { + let queryString = `SELECT uuid as id, path, title, created_at, updated_at FROM threads`; + const params: unknown[] = []; + if (options.uuid) { + queryString += ` WHERE uuid = ?`; + params.push(options.uuid); + } + if (options.maxCreatedAt) { + queryString += ` AND created_at < ?`; + params.push(options.maxCreatedAt); + } + if (options.orderBy) { + queryString += ` ORDER BY ? DESC`; + params.push(options.orderBy); + } + if (options.limit) { + queryString += ` LIMIT ?`; + params.push(options.limit); + } + if (options.offset) { + queryString += ` OFFSET ?`; + params.push(options.offset); + } + const query = this.db.prepare(queryString); + return query.all(...params) as ThreadIndexItem[]; + } +} + +ThreadIndex.getInstance(); diff --git a/packages/cli/src/rpc/navie/thread/pinItem.ts b/packages/cli/src/rpc/navie/thread/pinItem.ts new file mode 100644 index 0000000000..7872d2288b --- /dev/null +++ b/packages/cli/src/rpc/navie/thread/pinItem.ts @@ -0,0 +1,18 @@ +import { NavieRpc } from '@appland/rpc'; +import { RpcHandler } from '../../rpc'; +import { getThread } from '.'; + +export function navieThreadPinItemHandler(): RpcHandler< + NavieRpc.V1.Thread.PinItem.Params, + NavieRpc.V1.Thread.PinItem.Response +> { + return { + name: NavieRpc.V1.Thread.PinItem.Method, + async handler({ threadId, pinnedItem, operation }) { + const thread = await getThread(threadId); + if (!thread) return; + + thread.pinItem({ operation, ...pinnedItem }); + }, + }; +} diff --git a/packages/cli/src/rpc/navie/thread/query.ts b/packages/cli/src/rpc/navie/thread/query.ts new file mode 100644 index 0000000000..6934d8d3b2 --- /dev/null +++ b/packages/cli/src/rpc/navie/thread/query.ts @@ -0,0 +1,15 @@ +import { NavieRpc } from '@appland/rpc'; +import { RpcHandler } from '../../rpc'; +import { ThreadIndex } from '.'; + +export function navieThreadQueryHandler(): RpcHandler< + NavieRpc.V1.Thread.Query.Params, + NavieRpc.V1.Thread.Query.Response +> { + return { + name: NavieRpc.V1.Thread.Query.Method, + handler: ({ threadId, maxCreatedAt, orderBy, limit, offset }) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + ThreadIndex.getInstance().query({ uuid: threadId, maxCreatedAt, orderBy, limit, offset }), + }; +} diff --git a/packages/cli/src/rpc/navie/thread/sendMessage.ts b/packages/cli/src/rpc/navie/thread/sendMessage.ts new file mode 100644 index 0000000000..632b5141ee --- /dev/null +++ b/packages/cli/src/rpc/navie/thread/sendMessage.ts @@ -0,0 +1,31 @@ +import { NavieRpc } from '@appland/rpc'; +import { RpcHandler } from '../../rpc'; +import { getThread } from '.'; + +export function navieThreadSendMessageHandler(): RpcHandler< + NavieRpc.V1.Thread.SendMessage.Params, + NavieRpc.V1.Thread.SendMessage.Response +> { + return { + name: NavieRpc.V1.Thread.SendMessage.Method, + async handler({ + threadId, + content, + codeSelection, + }): Promise { + const thread = await getThread(threadId); + if (!thread) { + const errorMessage = `Thread ${threadId} not found`; + console.warn(errorMessage); + return { ok: false, error: errorMessage }; + } + + try { + await thread.sendMessage(content, codeSelection); + return { ok: true }; + } catch (err) { + return { ok: false, error: err }; + } + }, + }; +} diff --git a/packages/cli/src/rpc/navie/thread/subscribe.ts b/packages/cli/src/rpc/navie/thread/subscribe.ts new file mode 100644 index 0000000000..232025381d --- /dev/null +++ b/packages/cli/src/rpc/navie/thread/subscribe.ts @@ -0,0 +1,93 @@ +import { NavieRpc } from '@appland/rpc'; +import { IncomingMessage, NextFunction } from 'connect'; +import { warn } from 'console'; +import { ServerResponse } from 'http'; +import { getThread } from '.'; +import type { Thread } from '.'; +import { randomUUID } from 'crypto'; + +class EventStream { + constructor(private readonly res: ServerResponse) { + this.prepareResponse(); + } + + private prepareResponse() { + this.res + .setHeader('Content-Type', 'text/event-stream; charset=utf-8') + .setHeader('Cache-Control', 'no-cache') + .setHeader('Connection', 'keep-alive'); + } + + on(event: 'close', listener: () => void): this; + on(event: string, listener: (...args: unknown[]) => void): this { + this.res.on(event, listener); + return this; + } + + send(event: Record) { + this.res.write(`data: ${JSON.stringify(event)}\n\n`, 'utf-8'); + } + + end() { + this.res.end(); + } +} + +export async function handler( + eventStream: EventStream, + threadId: string, + nonce?: number, + replay?: boolean +) { + let thread: Thread; + try { + thread = await getThread(threadId); + } catch (e) { + warn(`Failed to load thread ${threadId}: ${e}`); + eventStream.send({ type: 'error', error: e, code: 'missing-thread' }); + return; + } + + const events = thread.getEvents(nonce); + if (replay) { + let lastEventTime = events[0]?.time ?? 0; + for (const event of events) { + await new Promise((resolve) => setTimeout(resolve, event.time - lastEventTime)); + eventStream.send(event); + lastEventTime = event.time; + } + } else { + events.forEach((e) => eventStream.send(e)); + } + + const clientId = randomUUID(); + thread.on('event', clientId, (event) => { + eventStream.send(event); + }); + + eventStream.on('close', () => { + thread.removeAllListeners(clientId); + }); +} + +export function sseMiddleware( + req: IncomingMessage & { + body?: { method?: string; params?: NavieRpc.V1.Thread.Subscribe.Params }; + }, + res: ServerResponse, + next: NextFunction +) { + if (req.body?.method === NavieRpc.V1.Thread.Subscribe.Method) { + const { params } = req.body; + if (!params?.threadId) { + res.writeHead(400); + res.end(); + return; + } + + const eventStream = new EventStream(res); + handler(eventStream, params.threadId, params?.nonce, params?.replay).catch(next); + } else { + next(); + } +} diff --git a/packages/components/src/components/chat-search/PinnedItems.vue b/packages/components/src/components/chat-search/PinnedItems.vue index 08a70fdff3..d93ddbe4db 100644 --- a/packages/components/src/components/chat-search/PinnedItems.vue +++ b/packages/components/src/components/chat-search/PinnedItems.vue @@ -12,13 +12,14 @@
{{ pin.content }}{{ getPinnedContent(pin.handle).content }}
@@ -33,18 +34,13 @@ import VMarkdownCodeSnippet from '@/components/chat/MarkdownCodeSnippet.vue'; import VMermaidDiagram from '@/components/chat/MermaidDiagram.vue'; import VVSCodeNotice from '@/components/chat-search/VSCodeNotice.vue'; import VIntelliJNotice from '@/components/chat-search/IntelliJNotice.vue'; +import { pinnedItemRegistry } from '@/lib/pinnedItems'; const EditorNoticeComponents = { vscode: VVSCodeNotice, intellij: VIntelliJNotice, }; -const PinnedContextComponents = { - 'code-snippet': VMarkdownCodeSnippet, - mermaid: VMermaidDiagram, - file: VFile, -}; - export default { name: 'v-pinned-items', @@ -77,8 +73,18 @@ export default { getNoticeComponent(): Vue.Component { return EditorNoticeComponents[this.editorType]; }, - getPinnedComponent({ type }: any): Vue.Component | undefined { - return PinnedContextComponents[type]; + getPinnedComponent(handle: number): Vue.Component | undefined { + const pinnedItem = pinnedItemRegistry.get(handle); + + // If it's not registered, it's an external file. + if (!pinnedItem) return VFile; + + const language = pinnedItem.metadata?.language; + if (language === 'mermaid') return VMermaidDiagram; + return VMarkdownCodeSnippet; + }, + getPinnedContent(handle: number): ObservableContent { + return pinnedItemRegistry.get(handle); }, unpin(handle: number) { this.$emit('pin', { handle, pinned: false }); diff --git a/packages/components/src/components/chat-search/StreamingMessageContent.ts b/packages/components/src/components/chat-search/StreamingMessageContent.ts index 3fd37b8cd8..915f44b289 100644 --- a/packages/components/src/components/chat-search/StreamingMessageContent.ts +++ b/packages/components/src/components/chat-search/StreamingMessageContent.ts @@ -1,6 +1,5 @@ import Vue from 'vue'; -import VMarkdownCodeSnippet from '@/components/chat/MarkdownCodeSnippet.vue'; -import VMermaidDiagram from '@/components/chat/MermaidDiagram.vue'; +import VCodeFencedContent from '@/components/chat/CodeFencedContent.vue'; import VNextPromptButton from '@/components/chat/NextPromptButton.vue'; function findCursorNode(node: Node): Node | undefined { @@ -92,8 +91,7 @@ export default Vue.extend({ active: Boolean, }, components: { - VMarkdownCodeSnippet, - VMermaidDiagram, + VCodeFencedContent, VNextPromptButton, }, data() { diff --git a/packages/components/src/components/chat/Chat.vue b/packages/components/src/components/chat/Chat.vue index 261226044d..48c867888b 100644 --- a/packages/components/src/components/chat/Chat.vue +++ b/packages/components/src/components/chat/Chat.vue @@ -29,7 +29,7 @@ @@ -94,13 +95,14 @@ export type CodeSelection = { }; export interface ITool { + id?: string; title: string; status?: string; complete?: boolean; } interface IMessage { - message: string; + tokens: (string | CodeBlockReference)[]; isUser: boolean; isError: boolean; complete?: boolean; @@ -111,6 +113,7 @@ interface IMessage { } class UserMessage implements IMessage { + public tokens: string[] = []; public readonly messageId = undefined; public readonly sentiment = undefined; public readonly isUser = true; @@ -119,22 +122,51 @@ class UserMessage implements IMessage { public readonly complete = true; public readonly codeSelections = []; - constructor(public content: string) {} + constructor(content: string) { + this.tokens.push(content); + } +} + +interface CodeBlockReference { + type: 'code-block'; + id: string; } +interface HiddenToken { + type: 'hidden'; + content: string; +} + +type Token = string | CodeBlockReference | HiddenToken; + class AssistantMessage implements IMessage { - public content = ''; + public tokens: Token[] = []; public sentiment = undefined; public complete = false; public readonly isUser = false; public readonly isError = false; public readonly tools = []; public readonly codeSelections = undefined; + public readonly promptSuggestions: undefined | NavieRpc.V1.Suggest.NextStep[] = undefined; + private readonly codeBlocks: CodeBlockReference[] = []; constructor(public messageId?: string) {} - append(token: string) { - Vue.set(this, 'content', [this.content, token].join('')); + append(token: Token) { + if (typeof token === 'object' && 'type' in token) { + if (token.type === 'code-block') { + if (this.codeBlocks.some((b) => b.id === token.id)) { + return; + } + this.codeBlocks.push(token); + } + } + + this.tokens.push(token); + } + + setPromptSuggestions(suggestions: NavieRpc.V1.Suggest.NextStep[]) { + Vue.set(this, 'promptSuggestions', suggestions); } } @@ -196,11 +228,13 @@ export default { email: { type: String, }, + threadId: { + type: String, + }, }, data() { return { messages: [] as IMessage[], - threadId: undefined as string | undefined, authorized: true, autoScrollTop: 0, enableScrollLog: false, // Auto-scroll can be tricky, so there is special logging to help debug it. @@ -226,44 +260,18 @@ export default { }, }, methods: { - restoreThread(threadId: string, thread: ExplainRpc.Thread) { - // In hindsight, the thread should have an id property. - this.threadId = threadId; - let populatedCodeSelection = false; - for (const exchange of thread.exchanges) { - if (exchange.question) { - // TODO: User message provides prompt, but the UI does not have a place for it. - const { content, codeSelection } = exchange.question; - const userMessage = this.addUserMessage(content); - if (codeSelection && !populatedCodeSelection) { - populatedCodeSelection = true; - // TODO: There's some mismatch here between what the UI shows & what's in the thread data. - userMessage.codeSelections = [codeSelection]; - } - } - if (exchange.answer) { - const { content } = exchange.answer; - const systemMessage = this.addSystemMessage(); - systemMessage.content = content; - systemMessage.complete = true; - } - } - }, getMessage(query: Partial): IMessage | undefined { return this.messages.find((m) => { return Object.keys(query).every((key) => m[key] === query[key]); }); }, // Creates-or-appends a message. - addToken(token: string, threadId: string, messageId: string) { - if (threadId !== this.threadId) return; - + addToken(token: string, messageId: string) { if (!messageId) console.warn('messageId is undefined'); - if (!threadId) console.warn('threadId is undefined'); let assistantMessage = this.getMessage({ messageId }); if (!assistantMessage) { - assistantMessage = new AssistantMessage(messageId); + assistantMessage = Vue.observable(new AssistantMessage(messageId)); this.messages.push(assistantMessage); } @@ -286,7 +294,7 @@ export default { return userMessage; }, addSystemMessage() { - const message = new AssistantMessage(); + const message = Vue.observable(new AssistantMessage()); this.messages.push(message); return message; }, @@ -307,9 +315,6 @@ export default { } }, async onSend(message: string) { - const userMessage = this.addUserMessage(message); - userMessage.codeSelections = this.codeSelections; - this.sendMessage( message, this.codeSelections.map((s) => s.code), @@ -323,10 +328,6 @@ export default { }, onAck(_messageId: string, threadId: string) { this.setAuthorized(true); - if (threadId !== this.threadId) { - this.threadId = threadId; - this.$root.$emit('thread-id', threadId); - } }, scrollToBottom() { // Allow one tick to progress to allow any DOM changes to be applied @@ -392,6 +393,9 @@ export default { this.$refs.input.setInput(input); this.$refs.input.moveCursorToEnd(); }, + clear() { + this.$set(this, 'messages', []); + }, }, watch: { isChatting() { diff --git a/packages/components/src/components/chat/CodeFencedContent.vue b/packages/components/src/components/chat/CodeFencedContent.vue new file mode 100644 index 0000000000..8880a44fbd --- /dev/null +++ b/packages/components/src/components/chat/CodeFencedContent.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/components/src/components/chat/ContextContainer.vue b/packages/components/src/components/chat/ContextContainer.vue index 7e553035e2..df076ccb09 100644 --- a/packages/components/src/components/chat/ContextContainer.vue +++ b/packages/components/src/components/chat/ContextContainer.vue @@ -7,6 +7,7 @@ data-cy="context-container" :data-handle="valueHandle" :data-reference="isReference" + :data-collapsed="collapsed" >
diff --git a/packages/components/src/components/chat/MarkdownCodeSnippet.vue b/packages/components/src/components/chat/MarkdownCodeSnippet.vue index c5d6e5f256..c194e4a547 100644 --- a/packages/components/src/components/chat/MarkdownCodeSnippet.vue +++ b/packages/components/src/components/chat/MarkdownCodeSnippet.vue @@ -5,6 +5,7 @@ :location="decodedLocation" :directory="directory" :is-pinnable="isPinnable" + :is-reference="isReference" content-type="text" data-cy="code-snippet" class="code-snippet" @@ -121,7 +122,10 @@ export default Vue.extend({ navigator.clipboard.writeText(code); }, onPin({ pinned, handle }: { pinned: boolean; handle: number }): void { - const eventData: PinEvent & Partial = { pinned, handle }; + const eventData: PinEvent & Partial = { + pinned, + handle, + }; if (pinned) { eventData.type = 'code-snippet'; eventData.language = this.language; diff --git a/packages/components/src/components/chat/MermaidDiagram.vue b/packages/components/src/components/chat/MermaidDiagram.vue index 231167bbe8..8df125ee00 100644 --- a/packages/components/src/components/chat/MermaidDiagram.vue +++ b/packages/components/src/components/chat/MermaidDiagram.vue @@ -3,6 +3,7 @@ :title="title" :menu-items="menuItems" :handle="handle" + :is-reference="isReference" content-type="image" @expand="showModal" @pin="onPin" @@ -38,6 +39,7 @@ import { fromUint8Array } from 'js-base64'; import type ContextContainerMenuItem from './ContextContainerMenuItem'; import type { PinEvent, PinMermaid } from './PinEvent'; +import { pinnedItemRegistry } from '@/lib/pinnedItems'; mermaid.initialize({ startOnLoad: false, @@ -76,10 +78,11 @@ export default Vue.extend({ }, }, data() { + const definition = this.$slots.default?.[0].text ?? ''; return { + definition, id: `mermaid-${diagramId++}`, svg: undefined as string | undefined, - definition: this.$slots.default?.[0].text ?? '', modalVisible: false, }; }, @@ -179,12 +182,6 @@ export default Vue.extend({ +