From 2d997d8bdbf07d7d8cb1a008192c5d6309ef2f17 Mon Sep 17 00:00:00 2001 From: Jackson Chen <541898146chen@gmail.com> Date: Mon, 18 Nov 2024 02:22:12 -0600 Subject: [PATCH 01/25] feat: add hasCapability method to ClientApi interface --- clients/tabby-chat-panel/src/index.ts | 5 +++++ clients/vscode/src/chat/chatPanel.ts | 19 +++++++++---------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/clients/tabby-chat-panel/src/index.ts b/clients/tabby-chat-panel/src/index.ts index 95dec07c4f2f..d6a404976aec 100644 --- a/clients/tabby-chat-panel/src/index.ts +++ b/clients/tabby-chat-panel/src/index.ts @@ -44,6 +44,8 @@ export interface NavigateOpts { openInEditor?: boolean } +export type ClientApiMethods = keyof ClientApi + export interface ServerApi { init: (request: InitRequest) => void sendMessage: (message: ChatMessage) => void @@ -69,6 +71,8 @@ export interface ClientApi { onCopy: (content: string) => void onKeyboardEvent: (type: 'keydown' | 'keyup' | 'keypress', event: KeyboardEventInit) => void + + hasCapability?: (capability: ClientApiMethods) => Promise } export interface ChatMessage { @@ -94,6 +98,7 @@ export function createClient(target: HTMLIFrameElement, api: ClientApi): ServerA onLoaded: api.onLoaded, onCopy: api.onCopy, onKeyboardEvent: api.onKeyboardEvent, + hasCapability: api.hasCapability, }, }) } diff --git a/clients/vscode/src/chat/chatPanel.ts b/clients/vscode/src/chat/chatPanel.ts index 0c97df8fa499..9f1167a66cd0 100644 --- a/clients/vscode/src/chat/chatPanel.ts +++ b/clients/vscode/src/chat/chatPanel.ts @@ -1,5 +1,5 @@ import { createThread, type ThreadOptions } from "@quilted/threads"; -import type { ServerApi, ClientApi } from "tabby-chat-panel"; +import type { ServerApi, ClientApi, ClientApiMethods } from "tabby-chat-panel"; import { Webview } from "vscode"; export function createThreadFromWebview, Target = Record>( @@ -23,15 +23,14 @@ export function createThreadFromWebview, Target = R } export function createClient(webview: Webview, api: ClientApi): ServerApi { + const hasCapability = (capability: ClientApiMethods): boolean => { + return capability in exposedApi && typeof exposedApi[capability as keyof typeof exposedApi] === "function"; + }; + const exposedApi = { + ...api, + hasCapability: hasCapability, + }; return createThreadFromWebview(webview, { - expose: { - navigate: api.navigate, - refresh: api.refresh, - onSubmitMessage: api.onSubmitMessage, - onApplyInEditor: api.onApplyInEditor, - onCopy: api.onCopy, - onLoaded: api.onLoaded, - onKeyboardEvent: api.onKeyboardEvent, - }, + expose: exposedApi, }); } From b8eceefb97a45d58c9a008279aeb60d863ce091a Mon Sep 17 00:00:00 2001 From: Jackson Chen <541898146chen@gmail.com> Date: Tue, 19 Nov 2024 11:26:47 -0600 Subject: [PATCH 02/25] feat: adding custom createThread implementation --- clients/tabby-chat-panel/src/createThread.ts | 401 +++++++++++++++++++ clients/vscode/src/chat/chatPanel.ts | 19 +- 2 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 clients/tabby-chat-panel/src/createThread.ts diff --git a/clients/tabby-chat-panel/src/createThread.ts b/clients/tabby-chat-panel/src/createThread.ts new file mode 100644 index 000000000000..7b89b2e7816e --- /dev/null +++ b/clients/tabby-chat-panel/src/createThread.ts @@ -0,0 +1,401 @@ +/* eslint-disable style/yield-star-spacing */ +/* eslint-disable unicorn/error-message */ +/* eslint-disable ts/consistent-type-imports */ +/* eslint-disable style/operator-linebreak */ +/* eslint-disable ts/method-signature-style */ +/* eslint-disable sort-imports */ +/* eslint-disable no-console */ +/* eslint-disable style/brace-style */ +/* eslint-disable antfu/if-newline */ +/* eslint-disable style/comma-dangle */ +/* eslint-disable style/semi */ +/* eslint-disable style/member-delimiter-style */ +/* eslint-disable style/quotes */ +/* eslint-disable no-restricted-globals */ +/* eslint-disable unused-imports/no-unused-vars */ + +import { + AnyFunction, + createBasicEncoder, + isMemoryManageable, + RELEASE_METHOD, + RETAIN_METHOD, + RETAINED_BY, + StackFrame, + Thread, + ThreadEncoder, + ThreadEncoderApi, + ThreadTarget, +} from "@quilted/threads"; + +const CALL = 0; +const RESULT = 1; +const TERMINATE = 2; +const RELEASE = 3; +const FUNCTION_APPLY = 5; +const FUNCTION_RESULT = 6; +export const CHECK_MESSAGE = "quilt.threads.ping"; +export const RESPONSE_MESSAGE = "quilt.threads.pong"; + +interface MessageMap { + [CALL]: [string, string | number, any]; + [RESULT]: [string, Error?, any?]; + [TERMINATE]: []; + [RELEASE]: [string]; + [FUNCTION_APPLY]: [string, string, any]; + [FUNCTION_RESULT]: [string, Error?, any?]; +} + +type MessageData = { + [K in keyof MessageMap]: [K, MessageMap[K]]; +}[keyof MessageMap]; + +export interface ThreadOptions< + Self = Record, + Target = Record, +> { + expose?: Self; + signal?: AbortSignal; + encoder?: ThreadEncoder; + callable?: (keyof Target)[]; + uuid?(): string; + onMethodUnavailable?: (method: string) => void; +} + +export function createThread< + Self = Record, + Target = Record, +>( + target: ThreadTarget, + { + expose, + callable, + signal, + uuid = defaultUuid, + encoder = createBasicEncoder(), + onMethodUnavailable, + }: ThreadOptions = {} +): Thread { + let terminated = false; + const activeApi = new Map(); + const functionsToId = new Map(); + const idsToFunction = new Map(); + const idsToProxy = new Map(); + + if (expose) { + for (const key of Object.keys(expose)) { + const value = expose[key as keyof typeof expose]; + if (typeof value === "function") activeApi.set(key, value); + } + } + + const callIdsToResolver = new Map< + string, + ( + ...args: MessageMap[typeof FUNCTION_RESULT] | MessageMap[typeof RESULT] + ) => void + >(); + + const call = createCallable>(handlerForCall, callable); + + const encoderApi: ThreadEncoderApi = { + functions: { + add(func) { + let id = functionsToId.get(func); + if (id == null) { + id = uuid(); + functionsToId.set(func, id); + idsToFunction.set(id, func); + } + return id; + }, + get(id) { + let proxy = idsToProxy.get(id); + if (proxy) return proxy; + + let retainCount = 0; + let released = false; + + const release = () => { + retainCount -= 1; + if (retainCount === 0) { + released = true; + idsToProxy.delete(id); + send(RELEASE, [id]); + } + }; + + const retain = () => { + retainCount += 1; + }; + + proxy = (...args: any[]) => { + if (released) { + return Promise.resolve(undefined); + } + + if (!idsToProxy.has(id)) { + return Promise.resolve(undefined); + } + + const [encoded, transferable] = encoder.encode(args, encoderApi); + const callId = uuid(); + const done = waitForResult(callId); + send(FUNCTION_APPLY, [callId, id, encoded], transferable); + return done; + }; + + Object.defineProperties(proxy, { + [RELEASE_METHOD]: { value: release, writable: false }, + [RETAIN_METHOD]: { value: retain, writable: false }, + [RETAINED_BY]: { value: new Set(), writable: false }, + }); + + idsToProxy.set(id, proxy); + return proxy; + }, + }, + }; + + function send( + type: Type, + args: MessageMap[Type], + transferables?: Transferable[] + ) { + if (terminated) return; + target.send([type, args], transferables); + } + + async function listener(rawData: unknown) { + const isThreadMessageData = + Array.isArray(rawData) && + typeof rawData[0] === "number" && + (rawData[1] == null || Array.isArray(rawData[1])); + + if (!isThreadMessageData) return; + + const data = rawData as MessageData; + + switch (data[0]) { + case CALL: { + const stackFrame = new StackFrame(); + const [id, property, args] = data[1]; + const func = activeApi.get(property); + + try { + if (func == null) { + throw new Error( + `No '${property}' method is exposed on this endpoint` + ); + } + + const result = await func( + ...(encoder.decode(args, encoderApi, [stackFrame]) as any[]) + ); + const [encoded, transferables] = encoder.encode(result, encoderApi); + send(RESULT, [id, undefined, encoded], transferables); + } catch (error) { + const { name, message, stack } = error as Error; + send(RESULT, [id, { name, message, stack }]); + } finally { + stackFrame.release(); + } + break; + } + case RESULT: { + resolveCall(...data[1]); + break; + } + case TERMINATE: { + terminate(); + break; + } + case RELEASE: { + const [id] = data[1]; + const func = idsToFunction.get(id); + if (func) { + idsToFunction.delete(id); + functionsToId.delete(func); + } + break; + } + case FUNCTION_APPLY: { + const [callId, funcId, args] = data[1]; + const stackFrame = new StackFrame(); + + try { + const func = idsToFunction.get(funcId); + if (func == null) { + const [encoded] = encoder.encode(undefined, encoderApi); + send(FUNCTION_RESULT, [callId, undefined, encoded]); + } else { + const result = await func( + ...(encoder.decode( + args, + encoderApi, + isMemoryManageable(func) + ? [...func[RETAINED_BY], stackFrame] + : [stackFrame] + ) as any[]) + ); + const [encoded, transferables] = encoder.encode(result, encoderApi); + send(FUNCTION_RESULT, [callId, undefined, encoded], transferables); + } + } finally { + stackFrame.release(); + } + break; + } + case FUNCTION_RESULT: { + resolveCall(...data[1]); + break; + } + } + } + + function handlerForCall(property: string | number | symbol) { + return (...args: any[]) => { + if ( + terminated || + (typeof property !== "string" && typeof property !== "number") + ) { + return Promise.resolve(undefined); + } + + if (property === "hasCapability") { + const methodToTest = args[0]; + const testId = uuid(); + const testPromise = waitForResult(testId); + send(CALL, [testId, methodToTest, encoder.encode([], encoderApi)[0]]); + return testPromise.then( + () => { + return true; + }, + (error) => { + if ( + error.message?.includes( + `No '${methodToTest}' method is exposed on this endpoint` + ) + ) { + return false; + } + throw error; + } + ); + } + const id = uuid(); + const done = waitForResult(id); + const [encoded, transferables] = encoder.encode(args, encoderApi); + send(CALL, [id, property, encoded], transferables); + return done; + }; + } + + function waitForResult(id: string) { + const promise = new Promise((resolve, reject) => { + callIdsToResolver.set(id, (_, errorResult, value) => { + if (errorResult == null) { + resolve(encoder.decode(value, encoderApi)); + } else { + const error = new Error(); + Object.assign(error, errorResult); + reject(error); + } + }); + }); + + Object.defineProperty(promise, Symbol.asyncIterator, { + async *value() { + const result = await promise; + Object.defineProperty(result, Symbol.asyncIterator, { + value: () => result, + }); + yield* result; + }, + }); + return promise; + } + + function resolveCall(...args: MessageMap[typeof RESULT]) { + const callId = args[0]; + const resolver = callIdsToResolver.get(callId); + if (resolver) { + resolver(...args); + callIdsToResolver.delete(callId); + } + } + + function terminate() { + if (terminated) return; + for (const id of callIdsToResolver.keys()) { + resolveCall(id, undefined, encoder.encode(undefined, encoderApi)[0]); + } + terminated = true; + activeApi.clear(); + callIdsToResolver.clear(); + functionsToId.clear(); + idsToFunction.clear(); + idsToProxy.clear(); + } + + signal?.addEventListener( + "abort", + () => { + send(TERMINATE, []); + terminate(); + }, + { once: true } + ); + + target.listen(listener, { signal }); + + return call; +} + +function defaultUuid() { + return `${uuidSegment()}-${uuidSegment()}-${uuidSegment()}-${uuidSegment()}`; +} + +function uuidSegment() { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(16); +} + +function createCallable( + handlerForCall: (property: string | number | symbol) => AnyFunction, + callable?: (keyof T)[] +): T { + let call: any; + + if (callable == null) { + if (typeof Proxy !== "function") { + return {} as T; + } + + const cache = new Map(); + call = new Proxy( + {}, + { + get(_target, property) { + if (cache.has(property)) { + return cache.get(property); + } + const handler = handlerForCall(property); + cache.set(property, handler); + return handler; + }, + } + ); + } else { + call = {}; + for (const method of callable) { + Object.defineProperty(call, method, { + value: handlerForCall(method), + writable: false, + configurable: true, + enumerable: true, + }); + } + } + + return call; +} diff --git a/clients/vscode/src/chat/chatPanel.ts b/clients/vscode/src/chat/chatPanel.ts index 9f1167a66cd0..9c757e8e8bec 100644 --- a/clients/vscode/src/chat/chatPanel.ts +++ b/clients/vscode/src/chat/chatPanel.ts @@ -1,5 +1,5 @@ import { createThread, type ThreadOptions } from "@quilted/threads"; -import type { ServerApi, ClientApi, ClientApiMethods } from "tabby-chat-panel"; +import { type ServerApi, type ClientApi } from "tabby-chat-panel"; import { Webview } from "vscode"; export function createThreadFromWebview, Target = Record>( @@ -23,14 +23,15 @@ export function createThreadFromWebview, Target = R } export function createClient(webview: Webview, api: ClientApi): ServerApi { - const hasCapability = (capability: ClientApiMethods): boolean => { - return capability in exposedApi && typeof exposedApi[capability as keyof typeof exposedApi] === "function"; - }; - const exposedApi = { - ...api, - hasCapability: hasCapability, - }; return createThreadFromWebview(webview, { - expose: exposedApi, + expose: { + navigate: api.navigate, + refresh: api.refresh, + onSubmitMessage: api.onSubmitMessage, + onApplyInEditor: api.onApplyInEditor, + onLoaded: api.onLoaded, + onCopy: api.onCopy, + onKeyboardEvent: api.onKeyboardEvent, + }, }); } From 2f341833946852f6634b55a735e1df1715cfc46f Mon Sep 17 00:00:00 2001 From: Jackson Chen <541898146chen@gmail.com> Date: Tue, 19 Nov 2024 11:26:55 -0600 Subject: [PATCH 03/25] feat: add createThreadInsideIframe module and update dependencies --- clients/tabby-chat-panel/build.config.ts | 2 + .../src/createThreadInsideIframe.ts | 105 ++++++++++++++++++ clients/tabby-chat-panel/src/index.ts | 4 +- 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 clients/tabby-chat-panel/src/createThreadInsideIframe.ts diff --git a/clients/tabby-chat-panel/build.config.ts b/clients/tabby-chat-panel/build.config.ts index 8a9eff3b7345..d6ae394d1d9f 100644 --- a/clients/tabby-chat-panel/build.config.ts +++ b/clients/tabby-chat-panel/build.config.ts @@ -4,6 +4,8 @@ export default defineBuildConfig({ entries: [ 'src/index', 'src/react', + 'src/createThread', + 'src/createThreadInsideIframe', ], declaration: true, clean: true, diff --git a/clients/tabby-chat-panel/src/createThreadInsideIframe.ts b/clients/tabby-chat-panel/src/createThreadInsideIframe.ts new file mode 100644 index 000000000000..21903f7957b9 --- /dev/null +++ b/clients/tabby-chat-panel/src/createThreadInsideIframe.ts @@ -0,0 +1,105 @@ +/* eslint-disable no-console */ +/* eslint-disable style/brace-style */ +/* eslint-disable antfu/if-newline */ +/* eslint-disable style/comma-dangle */ +/* eslint-disable style/semi */ +/* eslint-disable style/member-delimiter-style */ +/* eslint-disable style/quotes */ +/* eslint-disable no-restricted-globals */ +/* eslint-disable unused-imports/no-unused-vars */ +import type { ThreadOptions } from "@quilted/threads"; +import { createThread } from "./createThread"; + +const CALL = 0; +const RESULT = 1; +const TERMINATE = 2; +const RELEASE = 3; +const FUNCTION_APPLY = 5; +const FUNCTION_RESULT = 6; +export const CHECK_MESSAGE = "quilt.threads.ping"; +export const RESPONSE_MESSAGE = "quilt.threads.pong"; +const METHOD_UNAVAILABLE = 7; + +export function createThreadFromInsideIframe< + Self = Record, + Target = Record, +>({ + targetOrigin = "*", + onMethodUnavailable, + ...options +}: ThreadOptions & { + targetOrigin?: string; + onMethodUnavailable?: (method: string) => void; +} = {}) { + if (typeof self === "undefined" || self.parent == null) { + throw new Error( + "You are not inside an iframe, because there is no parent window." + ); + } + + const { parent } = self; + const abort = new AbortController(); + const unavailableMethods = new Set(); + + function createCustomListener(originalListener: (data: any) => void) { + return (event: MessageEvent) => { + const data = event.data; + if (Array.isArray(data) && data[0] === METHOD_UNAVAILABLE) { + const [_, methodName] = data[1]; + console.log(`Method ${methodName} is not available in the iframe.`); + if (!unavailableMethods.has(methodName)) { + unavailableMethods.add(methodName); + onMethodUnavailable?.(methodName); + } + return; + } + if (event.data !== CHECK_MESSAGE) { + originalListener(event.data); + } + }; + } + + const thread = createThread( + { + send(message, transfer) { + return parent.postMessage(message, targetOrigin, transfer); + }, + listen(listen, { signal }) { + const customListener = createCustomListener(listen); + self.addEventListener("message", customListener, { signal }); + }, + }, + { + ...options, + } + ); + + const ready = () => { + const respond = () => parent.postMessage(RESPONSE_MESSAGE, targetOrigin); + self.addEventListener( + "message", + ({ data }) => { + if (data === CHECK_MESSAGE) respond(); + }, + { signal: options.signal } + ); + respond(); + }; + + if (document.readyState === "complete") { + ready(); + } else { + document.addEventListener( + "readystatechange", + () => { + if (document.readyState === "complete") { + ready(); + abort.abort(); + } + }, + { signal: abort.signal } + ); + } + + return thread; +} diff --git a/clients/tabby-chat-panel/src/index.ts b/clients/tabby-chat-panel/src/index.ts index d6a404976aec..20128ab592f9 100644 --- a/clients/tabby-chat-panel/src/index.ts +++ b/clients/tabby-chat-panel/src/index.ts @@ -1,5 +1,7 @@ -import { createThreadFromIframe, createThreadFromInsideIframe } from '@quilted/threads' +import { createThreadFromIframe } from '@quilted/threads' import { version } from '../package.json' +import { createThreadFromInsideIframe } from './createThreadInsideIframe' +import { createThread } from './createThread' export const TABBY_CHAT_PANEL_API_VERSION: string = version From ab434d825c08840dbf8e53f234854a1921dcb8c0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:28:25 +0000 Subject: [PATCH 04/25] [autofix.ci] apply automated fixes --- clients/tabby-chat-panel/src/createThread.ts | 4 ++-- clients/tabby-chat-panel/src/index.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/clients/tabby-chat-panel/src/createThread.ts b/clients/tabby-chat-panel/src/createThread.ts index 7b89b2e7816e..8cbabe95a162 100644 --- a/clients/tabby-chat-panel/src/createThread.ts +++ b/clients/tabby-chat-panel/src/createThread.ts @@ -4,14 +4,14 @@ /* eslint-disable style/operator-linebreak */ /* eslint-disable ts/method-signature-style */ /* eslint-disable sort-imports */ -/* eslint-disable no-console */ + /* eslint-disable style/brace-style */ /* eslint-disable antfu/if-newline */ /* eslint-disable style/comma-dangle */ /* eslint-disable style/semi */ /* eslint-disable style/member-delimiter-style */ /* eslint-disable style/quotes */ -/* eslint-disable no-restricted-globals */ + /* eslint-disable unused-imports/no-unused-vars */ import { diff --git a/clients/tabby-chat-panel/src/index.ts b/clients/tabby-chat-panel/src/index.ts index 20128ab592f9..c8582ff9c588 100644 --- a/clients/tabby-chat-panel/src/index.ts +++ b/clients/tabby-chat-panel/src/index.ts @@ -1,7 +1,6 @@ import { createThreadFromIframe } from '@quilted/threads' import { version } from '../package.json' import { createThreadFromInsideIframe } from './createThreadInsideIframe' -import { createThread } from './createThread' export const TABBY_CHAT_PANEL_API_VERSION: string = version From bd4c7443c0df3fde90f6dd485a6249ea9dc3ea77 Mon Sep 17 00:00:00 2001 From: Jackson Chen <541898146chen@gmail.com> Date: Tue, 19 Nov 2024 13:55:14 -0600 Subject: [PATCH 05/25] feat: add CHECK_CAPABILITY method to createThread function --- clients/tabby-chat-panel/src/createThread.ts | 34 +++--- .../src/createThreadInsideIframe.ts | 3 +- clients/tabby-chat-panel/src/index.ts | 107 +++++++++++------- clients/vscode/src/chat/chatPanel.ts | 4 +- 4 files changed, 82 insertions(+), 66 deletions(-) diff --git a/clients/tabby-chat-panel/src/createThread.ts b/clients/tabby-chat-panel/src/createThread.ts index 8cbabe95a162..d4d41e8fac47 100644 --- a/clients/tabby-chat-panel/src/createThread.ts +++ b/clients/tabby-chat-panel/src/createThread.ts @@ -34,6 +34,7 @@ const TERMINATE = 2; const RELEASE = 3; const FUNCTION_APPLY = 5; const FUNCTION_RESULT = 6; +const CHECK_CAPABILITY = 7; export const CHECK_MESSAGE = "quilt.threads.ping"; export const RESPONSE_MESSAGE = "quilt.threads.pong"; @@ -44,6 +45,7 @@ interface MessageMap { [RELEASE]: [string]; [FUNCTION_APPLY]: [string, string, any]; [FUNCTION_RESULT]: [string, Error?, any?]; + [CHECK_CAPABILITY]: [string, string]; } type MessageData = { @@ -62,7 +64,7 @@ export interface ThreadOptions< onMethodUnavailable?: (method: string) => void; } -export function createThread< +export function createCustomThread< Self = Record, Target = Record, >( @@ -250,6 +252,12 @@ export function createThread< resolveCall(...data[1]); break; } + case CHECK_CAPABILITY: { + const [id, methodToCheck] = data[1]; + const hasMethod = activeApi.has(methodToCheck); + send(RESULT, [id, undefined, encoder.encode(hasMethod, encoderApi)[0]]); + break; + } } } @@ -263,25 +271,11 @@ export function createThread< } if (property === "hasCapability") { - const methodToTest = args[0]; - const testId = uuid(); - const testPromise = waitForResult(testId); - send(CALL, [testId, methodToTest, encoder.encode([], encoderApi)[0]]); - return testPromise.then( - () => { - return true; - }, - (error) => { - if ( - error.message?.includes( - `No '${methodToTest}' method is exposed on this endpoint` - ) - ) { - return false; - } - throw error; - } - ); + const methodToCheck = args[0]; + const id = uuid(); + const done = waitForResult(id); + send(CHECK_CAPABILITY, [id, methodToCheck]); + return done; } const id = uuid(); const done = waitForResult(id); diff --git a/clients/tabby-chat-panel/src/createThreadInsideIframe.ts b/clients/tabby-chat-panel/src/createThreadInsideIframe.ts index 21903f7957b9..50ebbeb5f641 100644 --- a/clients/tabby-chat-panel/src/createThreadInsideIframe.ts +++ b/clients/tabby-chat-panel/src/createThreadInsideIframe.ts @@ -8,7 +8,8 @@ /* eslint-disable no-restricted-globals */ /* eslint-disable unused-imports/no-unused-vars */ import type { ThreadOptions } from "@quilted/threads"; -import { createThread } from "./createThread"; +import { createCustomThread } from "./createThread"; +import { createThread } from "."; const CALL = 0; const RESULT = 1; diff --git a/clients/tabby-chat-panel/src/index.ts b/clients/tabby-chat-panel/src/index.ts index c8582ff9c588..c13630d67c95 100644 --- a/clients/tabby-chat-panel/src/index.ts +++ b/clients/tabby-chat-panel/src/index.ts @@ -1,95 +1,106 @@ -import { createThreadFromIframe } from '@quilted/threads' -import { version } from '../package.json' -import { createThreadFromInsideIframe } from './createThreadInsideIframe' +import { createThreadFromIframe } from "@quilted/threads"; +import { version } from "../package.json"; +import { createThreadFromInsideIframe } from "./createThreadInsideIframe"; +import { createCustomThread } from "./createThread"; -export const TABBY_CHAT_PANEL_API_VERSION: string = version +export const TABBY_CHAT_PANEL_API_VERSION: string = version; +export const createThread = createCustomThread; export interface LineRange { - start: number - end: number + start: number; + end: number; } export interface FileContext { - kind: 'file' - range: LineRange - filepath: string - content: string - git_url: string + kind: "file"; + range: LineRange; + filepath: string; + content: string; + git_url: string; } -export type Context = FileContext +export type Context = FileContext; export interface FetcherOptions { - authorization: string - headers?: Record + authorization: string; + headers?: Record; } export interface InitRequest { - fetcherOptions: FetcherOptions + fetcherOptions: FetcherOptions; // Workaround for vscode webview issue: // shortcut (cmd+a, cmd+c, cmd+v, cmd+x) not work in nested iframe in vscode webview // see https://github.com/microsoft/vscode/issues/129178 - useMacOSKeyboardEventHandler?: boolean + useMacOSKeyboardEventHandler?: boolean; } export interface OnLoadedParams { - apiVersion: string + apiVersion: string; } export interface ErrorMessage { - title?: string - content: string + title?: string; + content: string; } export interface NavigateOpts { - openInEditor?: boolean + openInEditor?: boolean; } -export type ClientApiMethods = keyof ClientApi +export type ClientApiMethods = keyof ClientApi; export interface ServerApi { - init: (request: InitRequest) => void - sendMessage: (message: ChatMessage) => void - showError: (error: ErrorMessage) => void - cleanError: () => void - addRelevantContext: (context: Context) => void - updateTheme: (style: string, themeClass: string) => void - updateActiveSelection: (context: Context | null) => void + init: (request: InitRequest) => void; + sendMessage: (message: ChatMessage) => void; + showError: (error: ErrorMessage) => void; + cleanError: () => void; + addRelevantContext: (context: Context) => void; + updateTheme: (style: string, themeClass: string) => void; + updateActiveSelection: (context: Context | null) => void; } export interface ClientApi { - navigate: (context: Context, opts?: NavigateOpts) => void - refresh: () => Promise + navigate: (context: Context, opts?: NavigateOpts) => void; + refresh: () => Promise; - onSubmitMessage: (msg: string, relevantContext?: Context[]) => Promise + onSubmitMessage: (msg: string, relevantContext?: Context[]) => Promise; - onApplyInEditor: (content: string, opts?: { languageId: string, smart: boolean }) => void + onApplyInEditor: ( + content: string, + opts?: { languageId: string; smart: boolean } + ) => void; // On current page is loaded. - onLoaded: (params?: OnLoadedParams | undefined) => void + onLoaded: (params?: OnLoadedParams | undefined) => void; // On user copy content to clipboard. - onCopy: (content: string) => void + onCopy: (content: string) => void; - onKeyboardEvent: (type: 'keydown' | 'keyup' | 'keypress', event: KeyboardEventInit) => void + onKeyboardEvent: ( + type: "keydown" | "keyup" | "keypress", + event: KeyboardEventInit + ) => void; - hasCapability?: (capability: ClientApiMethods) => Promise + hasCapability?: (capability: ClientApiMethods) => Promise; } export interface ChatMessage { - message: string + message: string; // Client side context - displayed in user message - selectContext?: Context + selectContext?: Context; // Client side contexts - displayed in assistant message - relevantContext?: Array + relevantContext?: Array; // Client side active selection context - displayed in assistant message - activeContext?: Context + activeContext?: Context; } -export function createClient(target: HTMLIFrameElement, api: ClientApi): ServerApi { +export function createClient( + target: HTMLIFrameElement, + api: ClientApi +): ServerApi { return createThreadFromIframe(target, { expose: { navigate: api.navigate, @@ -101,7 +112,7 @@ export function createClient(target: HTMLIFrameElement, api: ClientApi): ServerA onKeyboardEvent: api.onKeyboardEvent, hasCapability: api.hasCapability, }, - }) + }); } export function createServer(api: ServerApi): ClientApi { @@ -115,5 +126,15 @@ export function createServer(api: ServerApi): ClientApi { updateTheme: api.updateTheme, updateActiveSelection: api.updateActiveSelection, }, - }) + }); } +// TODO: remove later +export const clientApiKeys: ClientApiMethods[] = [ + "navigate", + "refresh", + "onSubmitMessage", + "onApplyInEditor", + "onLoaded", + "onCopy", + "onKeyboardEvent", +]; diff --git a/clients/vscode/src/chat/chatPanel.ts b/clients/vscode/src/chat/chatPanel.ts index 9c757e8e8bec..d90acbb67ff4 100644 --- a/clients/vscode/src/chat/chatPanel.ts +++ b/clients/vscode/src/chat/chatPanel.ts @@ -1,5 +1,5 @@ -import { createThread, type ThreadOptions } from "@quilted/threads"; -import { type ServerApi, type ClientApi } from "tabby-chat-panel"; +import { type ThreadOptions } from "@quilted/threads"; +import { type ServerApi, type ClientApi, createThread } from "tabby-chat-panel"; import { Webview } from "vscode"; export function createThreadFromWebview, Target = Record>( From 6f460632c9de73de113f807dcdbe409a97377aa6 Mon Sep 17 00:00:00 2001 From: Jackson Chen <541898146chen@gmail.com> Date: Tue, 19 Nov 2024 14:17:05 -0600 Subject: [PATCH 06/25] feat: update server capabilities in ChatPage component --- ee/tabby-ui/app/chat/page.tsx | 18 ++++++++++++++++++ ee/tabby-ui/components/chat/chat.tsx | 8 ++++++-- .../components/chat/question-answer.tsx | 18 ++++++++++++++---- .../components/message-markdown/index.tsx | 19 ++++++++++++++++--- ee/tabby-ui/components/ui/codeblock.tsx | 14 +++++++++++--- 5 files changed, 65 insertions(+), 12 deletions(-) diff --git a/ee/tabby-ui/app/chat/page.tsx b/ee/tabby-ui/app/chat/page.tsx index a5e23dd6fff3..a0dcbc84e617 100644 --- a/ee/tabby-ui/app/chat/page.tsx +++ b/ee/tabby-ui/app/chat/page.tsx @@ -10,6 +10,7 @@ import { ErrorBoundary } from 'react-error-boundary' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { + clientApiKeys, TABBY_CHAT_PANEL_API_VERSION, type ChatMessage, type Context, @@ -222,11 +223,27 @@ export default function ChatPage() { } }, [server, client]) + const [serverCapabilities, setServerCapabilities] = useState( + new Map() + ) + useEffect(() => { if (server) { server?.onLoaded({ apiVersion: TABBY_CHAT_PANEL_API_VERSION }) + + const checkCapabilities = async () => { + const results = await Promise.all( + clientApiKeys.map(async key => { + const hasCapability = await server.hasCapability!(key) + return [key, hasCapability] as [string, boolean] + }) + ) + setServerCapabilities(new Map(results)) + } + + checkCapabilities() } }, [server]) @@ -370,6 +387,7 @@ export default function ChatPage() { onCopyContent={isInEditor && server?.onCopy} onSubmitMessage={isInEditor && server?.onSubmitMessage} onApplyInEditor={isInEditor && server?.onApplyInEditor} + serverCapabilities={serverCapabilities} /> ) diff --git a/ee/tabby-ui/components/chat/chat.tsx b/ee/tabby-ui/components/chat/chat.tsx index de77667c018e..8f62a5b7038d 100644 --- a/ee/tabby-ui/components/chat/chat.tsx +++ b/ee/tabby-ui/components/chat/chat.tsx @@ -50,6 +50,7 @@ type ChatContextValue = { activeSelection: Context | null removeRelevantContext: (index: number) => void chatInputRef: RefObject + serverCapabilities: Map } export const ChatContext = React.createContext( @@ -85,6 +86,7 @@ interface ChatProps extends React.ComponentProps<'div'> { opts?: { languageId: string; smart: boolean } ) => void chatInputRef: RefObject + serverCapabilities: Map } function ChatRenderer( @@ -104,7 +106,8 @@ function ChatRenderer( onCopyContent, onSubmitMessage, onApplyInEditor, - chatInputRef + chatInputRef, + serverCapabilities }: ChatProps, ref: React.ForwardedRef ) { @@ -525,7 +528,8 @@ function ChatRenderer( relevantContext, removeRelevantContext, chatInputRef, - activeSelection + activeSelection, + serverCapabilities }} >
diff --git a/ee/tabby-ui/components/chat/question-answer.tsx b/ee/tabby-ui/components/chat/question-answer.tsx index cc1644e1fa54..f2f89976cfb5 100644 --- a/ee/tabby-ui/components/chat/question-answer.tsx +++ b/ee/tabby-ui/components/chat/question-answer.tsx @@ -108,7 +108,8 @@ function UserMessageCard(props: { message: UserMessage }) { const { message } = props const [{ data }] = useMe() const selectContext = message.selectContext - const { onNavigateToContext } = React.useContext(ChatContext) + const { onNavigateToContext, serverCapabilities } = + React.useContext(ChatContext) const selectCodeSnippet = React.useMemo(() => { if (!selectContext?.content) return '' const language = selectContext?.filepath @@ -162,7 +163,11 @@ function UserMessageCard(props: { message: UserMessage }) {
- +
@@ -252,8 +257,12 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { enableRegenerating, ...rest } = props - const { onNavigateToContext, onApplyInEditor, onCopyContent } = - React.useContext(ChatContext) + const { + onNavigateToContext, + onApplyInEditor, + onCopyContent, + serverCapabilities + } = React.useContext(ChatContext) const [relevantCodeHighlightIndex, setRelevantCodeHighlightIndex] = React.useState(undefined) const serverCode: Array = React.useMemo(() => { @@ -389,6 +398,7 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { onCodeCitationMouseEnter={onCodeCitationMouseEnter} onCodeCitationMouseLeave={onCodeCitationMouseLeave} canWrapLongLines={!isLoading} + serverCapabilities={serverCapabilities} /> {!!message.error && } diff --git a/ee/tabby-ui/components/message-markdown/index.tsx b/ee/tabby-ui/components/message-markdown/index.tsx index 48cfa20be4a9..d1f4327567d7 100644 --- a/ee/tabby-ui/components/message-markdown/index.tsx +++ b/ee/tabby-ui/components/message-markdown/index.tsx @@ -76,6 +76,7 @@ export interface MessageMarkdownProps { className?: string // wrapLongLines for code block canWrapLongLines?: boolean + serverCapabilities: Map } type MessageMarkdownContextValue = { @@ -90,6 +91,7 @@ type MessageMarkdownContextValue = { contextInfo: ContextInfo | undefined fetchingContextInfo: boolean canWrapLongLines: boolean + serverCapabilities: Map } const MessageMarkdownContext = createContext( @@ -107,6 +109,7 @@ export function MessageMarkdown({ fetchingContextInfo, className, canWrapLongLines, + serverCapabilities, ...rest }: MessageMarkdownProps) { const messageAttachments: MessageAttachments = useMemo(() => { @@ -181,7 +184,8 @@ export function MessageMarkdown({ onCodeCitationMouseLeave: rest.onCodeCitationMouseLeave, contextInfo, fetchingContextInfo: !!fetchingContextInfo, - canWrapLongLines: !!canWrapLongLines + canWrapLongLines: !!canWrapLongLines, + serverCapabilities: serverCapabilities }} > ) @@ -298,9 +303,17 @@ export function ErrorMessageBlock({ } function CodeBlockWrapper(props: CodeBlockProps) { - const { canWrapLongLines } = useContext(MessageMarkdownContext) + const { canWrapLongLines, serverCapabilities } = useContext( + MessageMarkdownContext + ) - return + return ( + + ) } function CitationTag({ diff --git a/ee/tabby-ui/components/ui/codeblock.tsx b/ee/tabby-ui/components/ui/codeblock.tsx index 2831fc6f146a..613248d3cb90 100644 --- a/ee/tabby-ui/components/ui/codeblock.tsx +++ b/ee/tabby-ui/components/ui/codeblock.tsx @@ -35,6 +35,7 @@ export interface CodeBlockProps { opts?: { languageId: string; smart: boolean } ) => void canWrapLongLines: boolean | undefined + serverCapabilities: Map } interface languageMap { @@ -78,7 +79,14 @@ export const generateRandomString = (length: number, lowercase = false) => { } const CodeBlock: FC = memo( - ({ language, value, onCopyContent, onApplyInEditor, canWrapLongLines }) => { + ({ + language, + value, + onCopyContent, + onApplyInEditor, + canWrapLongLines, + serverCapabilities + }) => { const [wrapLongLines, setWrapLongLines] = useState(false) const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000, @@ -115,7 +123,7 @@ const CodeBlock: FC = memo( )} - {onApplyInEditor && ( + {serverCapabilities.get('onApplyInEditor') && onApplyInEditor && (