fix(gpt-runner-web): state not share different host

This commit is contained in:
JinmingYang
2023-06-12 22:31:34 +08:00
parent 1fbd594a7d
commit e84ddec08c
35 changed files with 767 additions and 90 deletions

View File

@@ -62,6 +62,8 @@
"find-free-ports": "*",
"ip": "*",
"minimatch": "*",
"socket.io": "*",
"socket.io-client": "*",
"zod": "*"
},
"dependencies": {
@@ -72,6 +74,8 @@
"find-free-ports": "^3.1.1",
"ip": "^1.1.8",
"minimatch": "^9.0.1",
"socket.io": "^4.6.2",
"socket.io-client": "^4.6.2",
"zod": "^3.21.4"
},
"devDependencies": {
@@ -79,4 +83,4 @@
"@types/ip": "^1.1.0",
"express": "^4.18.2"
}
}
}

View File

@@ -86,10 +86,21 @@ export function tryParseJson(str: string) {
return JSON.parse(str)
}
catch (e) {
console.error('tryParseJson error: ', e)
return {}
}
}
export function tryStringifyJson(obj: any) {
try {
return JSON.stringify(obj)
}
catch (e) {
console.error('tryStringifyJson error: ', e)
return ''
}
}
export function debounce<T extends (...args: any[]) => any>(callback: T, wait: number) {
let timeout: ReturnType<typeof setTimeout> | undefined

View File

@@ -15,7 +15,7 @@ interface EnvVarConfig {
/**
* if true, this env var will only be available on server side
* window.__config__ will not have this env var
* window.__env__ will not have this env var
*
* @default false
*/
@@ -26,7 +26,9 @@ const config: Record<EnvName, EnvVarConfig> = {
NODE_ENV: {
defaultValue: 'production',
},
OPENAI_KEY: {},
OPENAI_KEY: {
serverSideOnly: true,
},
GPTR_BASE_SERVER_URL: {
defaultValue: 'http://localhost:3003',
},
@@ -42,7 +44,7 @@ export class EnvConfig {
// client side
if (typeof window !== 'undefined' && !serverSideOnly)
return window?.__config__?.[key] ?? defaultValue ?? ''
return window?.__env__?.[key] ?? defaultValue ?? ''
// server side
return process.env[key] ?? defaultValue ?? ''
@@ -51,7 +53,7 @@ export class EnvConfig {
/**
* get all env vars on server or client side
* @param type server or client, get all allowed env vars on that scope
* @param getWays all or process, get env vars both on process and window.__config__ or only process.env
* @param getWays all or process, get env vars both on process and window.__env__ or only process.env
* @returns env vars key value map
*/
static getAllEnvVarsOnScopes(
@@ -93,7 +95,7 @@ export class EnvConfig {
/**
* for /api/config
* @returns env vars key value map for window.__config__
* @returns env vars key value map for window.__env__
*/
static getClientEnvVarsInServerSide(): Partial<Record<EnvName, string>> {
return EnvConfig.getAllEnvVarsOnScopes('client', 'process')
@@ -106,6 +108,6 @@ declare global {
}
interface Window {
__config__?: Partial<Env>
__env__?: Partial<Env>
}
}

View File

@@ -4,4 +4,6 @@ export * from './create-filter-pattern'
export * from './debug'
export * from './env-config'
export * from './is'
export * from './request'
export * from './socket'
export * from './verify-zod'

View File

@@ -0,0 +1,17 @@
import type { FailResponse, SuccessResponse } from '../types'
export function buildSuccessResponse<T>(options: Omit<SuccessResponse<T>, 'type'>): SuccessResponse<T> {
return {
type: 'Success',
status: options.status || 200,
...options,
}
}
export function buildFailResponse<T>(options: Omit<FailResponse<T>, 'type'>): FailResponse<T> {
return {
type: 'Fail',
status: options.status || 400,
...options,
}
}

View File

@@ -0,0 +1,178 @@
import * as uuid from 'uuid'
import type { BrowserSocket, MaybePromise, NodeServerSocket, Socket, WssActionName, WssActionNameRequestMap } from '../types'
import { EnvConfig } from './env-config'
type SocketQueueFn = (socket: Socket) => void
export class WssUtils {
static _instance: WssUtils | undefined
static defaultWssUrl = `http://${new URL(EnvConfig.get('GPTR_BASE_SERVER_URL')).host}`
#wssUrl: string
#socketQueue: SocketQueueFn[] = []
#hasConnected = false
static get instance() {
if (!this._instance)
this._instance = new WssUtils()
return this._instance
}
constructor(wssUrl?: string) {
this.#wssUrl = wssUrl ?? WssUtils.defaultWssUrl
}
static get isBrowser() {
return typeof window !== 'undefined'
}
static isNodeServerSocket(socket: Socket | undefined): socket is NodeServerSocket {
return typeof window === 'undefined' && Boolean(socket)
}
static isBrowserSocket(socket: Socket | undefined): socket is BrowserSocket {
return WssUtils.isBrowser && Boolean(socket)
}
get wsUrl() {
return this.#wssUrl
}
#wss: Socket | undefined
#setWss = (socket: Socket) => {
this.#wss = socket
this.#socketQueue.forEach(fn => fn(socket))
this.#socketQueue = []
}
get wss() {
return this.#wss
}
connect = async (params?: {
server: any // http.createServer(expressApp);
}) => {
if (this.wss || this.#hasConnected)
return this.wss
console.log('Connecting to WS...')
const { server } = params || {}
try {
if (WssUtils.isBrowser)
await this.#connectBrowserSocket()
else
server && await this.#connectNodeSocket(server)
this.#hasConnected = true
return this.wss
}
catch (error) {
console.error('Error connecting to WS', error)
throw error
}
}
// for nodejs
#connectNodeSocket = async (server: any) => {
if (WssUtils.isBrowser || this.wss)
return
const { Server } = await import('socket.io')
const serverSocket = new Server(server, {
cors: {
origin: '*',
},
})
serverSocket.on('connection', (socket) => {
this.#setWss(socket)
this.#handleConnection()
})
}
// for browser
#connectBrowserSocket = async () => {
if (!WssUtils.isBrowser || this.wss)
return
const { io } = await import('socket.io-client')
const socket = io(this.wsUrl)
this.#setWss(socket)
// if (!WssUtils.isBrowserSocket(this.wss))
// return
// socket.on('connect', () => {
// this.#handleConnection()
// })
}
#handleConnection = async () => {
console.log('Connected to Socket server')
}
on = <T extends WssActionName>(eventName: T, callback: (message: WssActionNameRequestMap[T]) => MaybePromise<void>) => {
if (!this.wss) {
this.#socketQueue.push((socket) => {
socket.on(eventName, callback as any)
})
return
}
(this.wss as NodeServerSocket).on(eventName, callback as any)
}
emit = <T extends WssActionName>(eventName: T, message: WssActionNameRequestMap[T]) => {
if (!this.wss) {
this.#socketQueue.push((socket) => {
socket.emit(eventName, message)
})
return
}
this.wss.emit(eventName, message)
}
off = <T extends WssActionName>(eventName: T, callback: (message: WssActionNameRequestMap[T]) => MaybePromise<void>) => {
if (!this.wss) {
this.#socketQueue.push((socket) => {
socket.off(eventName, callback as any)
})
return
}
this.wss.off(eventName, callback as any)
}
emitAndWaitForRes = async <T extends WssActionName>(eventName: T, message: WssActionNameRequestMap[T]) => {
return new Promise<WssActionNameRequestMap[T]>((resolve, reject) => {
let destroyFn: () => void
const timeout = setTimeout(() => {
destroyFn?.()
reject(new Error(`WS timeout, actionName: ${eventName}`))
}, 10000)
const __id__ = uuid.v4()
this.emit(eventName, { ...message, __id__ })
const handler = (message: WssActionNameRequestMap[T]) => {
if (message.__id__ !== __id__)
return
destroyFn?.()
resolve(message)
}
this.on(eventName, handler)
destroyFn = () => {
clearTimeout(timeout)
this.off(eventName, handler)
}
})
}
}

View File

@@ -26,3 +26,11 @@ export enum ServerStorageName {
FrontendState = 'frontend-state',
WebPreset = 'web-preset',
}
export enum WssActionName {
Error = 'error',
StorageGetItem = 'storageGetItem',
StorageSetItem = 'storageSetItem',
StorageRemoveItem = 'storageRemoveItem',
StorageClear = 'storageClear',
}

View File

@@ -4,3 +4,4 @@ export * from './config'
export * from './enum'
export * from './eventemitter'
export * from './server'
export * from './socket'

View File

@@ -36,22 +36,35 @@ export interface GetUserConfigResData {
userConfig: UserConfig
}
export interface GetStorageReqParams {
export interface StorageGetItemReqParams {
storageName: ServerStorageName
key: string
}
export type ServerStorageValue = Record<string, any> | null | undefined
export interface GetStorageResData {
export interface StorageGetItemResData {
value: ServerStorageValue
cacheDir: string
}
export interface SaveStorageReqParams {
export interface StorageSetItemReqParams {
storageName: ServerStorageName
key: string
value?: ServerStorageValue
}
export type SaveStorageResData = null
export type StorageSetItemResData = null
export interface StorageRemoveItemReqParams {
storageName: ServerStorageName
key: string
}
export type StorageRemoveItemResData = null
export interface StorageClearReqParams {
storageName: ServerStorageName
}
export type StorageClearResData = null

View File

@@ -0,0 +1,67 @@
import type { Socket as BrowserSocket } from 'socket.io-client'
import type { Socket as NodeServerSocket } from 'socket.io'
import type { MaybePromise } from './common'
import type { WssActionName } from './enum'
import type {
BaseResponse,
StorageClearReqParams,
StorageClearResData,
StorageGetItemReqParams,
StorageGetItemResData,
StorageRemoveItemReqParams,
StorageRemoveItemResData,
StorageSetItemReqParams,
StorageSetItemResData,
} from './server'
export interface IWssActionNameRequestMap extends Record<WssActionName, {
reqParams?: Record<string, any>
resData?: any
}> {
[WssActionName.Error]: {
reqParams?: {
error: Error
}
resData?: Error
}
[WssActionName.StorageGetItem]: {
reqParams?: StorageGetItemReqParams
resData?: StorageGetItemResData
}
[WssActionName.StorageSetItem]: {
reqParams?: StorageSetItemReqParams
resData?: StorageSetItemResData
}
[WssActionName.StorageRemoveItem]: {
reqParams?: StorageRemoveItemReqParams
resData?: StorageRemoveItemResData
}
[WssActionName.StorageClear]: {
reqParams?: StorageClearReqParams
resData?: StorageClearResData
}
}
export type WssActionNameRequestMap = {
[K in keyof IWssActionNameRequestMap]: {
__id__?: string
reqParams?: IWssActionNameRequestMap[K]['reqParams']
res?: BaseResponse<IWssActionNameRequestMap[K]['resData']>
}
}
export type WssEventsMap = {
[K in keyof WssActionNameRequestMap]: (message: WssActionNameRequestMap[K]) => MaybePromise<void>;
}
// export type NodeServerSocket = InstanceType<typeof Server<WssEventsMap>>
// export type BrowserSocket = InstanceType<typeof ClientSocket<WssEventsMap>>
// export type Socket = NodeServerSocket | BrowserSocket
export type { BrowserSocket, NodeServerSocket }
export type Socket = BrowserSocket | NodeServerSocket

View File

@@ -1,5 +1,5 @@
import { z } from 'zod'
import type { ChatStreamReqParams, GetGptFilesReqParams, GetStorageReqParams, GetUserConfigReqParams, SaveStorageReqParams } from '../types'
import type { ChatStreamReqParams, GetGptFilesReqParams, GetUserConfigReqParams, StorageClearReqParams, StorageGetItemReqParams, StorageRemoveItemReqParams, StorageSetItemReqParams } from '../types'
import { SingleChatMessageSchema, SingleFileConfigSchema } from './config.zod'
import { ServerStorageNameSchema } from './enum.zod'
@@ -19,13 +19,22 @@ export const GetUserConfigReqParamsSchema = z.object({
rootPath: z.string(),
}) satisfies z.ZodType<GetUserConfigReqParams>
export const GetStorageReqParamsSchema = z.object({
export const StorageGetItemReqParamsSchema = z.object({
storageName: ServerStorageNameSchema,
key: z.string(),
}) satisfies z.ZodType<GetStorageReqParams>
}) satisfies z.ZodType<StorageGetItemReqParams>
export const SaveStorageReqParamsSchema = z.object({
export const StorageSetItemReqParamsSchema = z.object({
storageName: ServerStorageNameSchema,
key: z.string(),
value: z.record(z.any()).nullable().optional(),
}) satisfies z.ZodType<SaveStorageReqParams>
}) satisfies z.ZodType<StorageSetItemReqParams>
export const StorageRemoveItemReqParamsSchema = z.object({
storageName: ServerStorageNameSchema,
key: z.string(),
}) satisfies z.ZodType<StorageRemoveItemReqParams>
export const StorageClearReqParamsSchema = z.object({
storageName: ServerStorageNameSchema,
}) satisfies z.ZodType<StorageClearReqParams>

View File

@@ -1,23 +1,7 @@
import type { Response } from 'express'
import type { z } from 'zod'
import type { FailResponse, SuccessResponse } from '../../common'
import { verifyZod } from '../../common'
export function buildSuccessResponse<T>(options: Omit<SuccessResponse<T>, 'type'>): SuccessResponse<T> {
return {
type: 'Success',
status: options.status || 200,
...options,
}
}
export function buildFailResponse<T>(options: Omit<FailResponse<T>, 'type'>): FailResponse<T> {
return {
type: 'Fail',
status: options.status || 400,
...options,
}
}
import { buildFailResponse, buildSuccessResponse, verifyZod } from '../../common'
export function sendSuccessResponse<T>(res: Response, options: Omit<SuccessResponse<T>, 'type'>): Response {
return res.status(options.status || 200).json(buildSuccessResponse(options))

View File

@@ -50,7 +50,13 @@
},
{
"command": "gpt-runner.restartServer",
"title": "GPT Runner Restart Server",
"title": "Restart GPT Runner Server",
"category": "GPT Runner"
},
{
"command": "gpt-runner.openChat",
"title": "Open GPT Runner Chat",
"icon": "res/logo.svg",
"category": "GPT Runner"
}
],
@@ -64,6 +70,15 @@
"description": "Disable the GPT Runner extension"
}
}
},
"menus": {
"editor/title": [
{
"command": "gpt-runner.openChat",
"group": "navigation",
"icon": "res/logo.svg"
}
]
}
},
"scripts": {
@@ -84,4 +99,4 @@
"execa": "^7.1.1",
"fs-extra": "^11.1.1"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="117.75479999999999"
y="0" viewBox="7.552000045776367 18.47899627685547 87.46800231933594 64.28099822998047"
enable-background="new 0 0 100 100" xml:space="preserve" height="580.8952" width="800.13947561559701" class="icon-s-0"
data-fill-palette-color="accent" id="s-0">
<path
d="M31.805 41.953c0-5.497 2.443-10.57 6.546-14.639-0.238-0.004-0.474-0.013-0.712-0.013-16.615 0-30.087 9.948-30.087 22.221 0 9.36 7.837 17.36 18.926 20.634l-0.585 12.604 8.693-11.016c0 0 1.642 0 3.053 0 7.925 0 15.129-2.267 20.502-5.965C43.185 63.725 31.805 53.826 31.805 41.953z"
fill="#19c37d" data-fill-palette-color="accent" />
<g fill="#19c37d" data-fill-palette-color="accent">
<path
d="M76.966 82.062l-11.95-15.139h-2.081c-17.693 0-32.089-10.865-32.089-24.223 0-13.355 14.396-24.221 32.089-24.221 17.691 0 32.085 10.866 32.085 24.221 0 9.637-7.348 18.172-18.857 22.078L76.966 82.062zM62.935 22.48c-15.487 0-28.089 9.071-28.089 20.221 0 11.15 12.602 20.223 28.089 20.223h4.021l5.437 6.889-0.369-7.949 1.504-0.443C84.153 58.281 91.02 50.934 91.02 42.701 91.02 31.551 78.421 22.48 62.935 22.48z"
fill="#19c37d" data-fill-palette-color="accent" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -4,6 +4,7 @@ export const EXT_DISPLAY_NAME = 'GPT Runner'
export enum Commands {
Reload = `${EXT_NAME}.reload`,
RestartServer = `${EXT_NAME}.restartServer`,
OpenChat = `${EXT_NAME}.openChat`,
OpenInBrowser = `${EXT_NAME}.openInBrowser`,
InsertCodes = `${EXT_NAME}.insertCodes`,
DiffCodes = `${EXT_NAME}.diffCodes`,

View File

@@ -2,8 +2,9 @@ import fs from 'fs'
import path from 'path'
import type { ExtensionContext } from 'vscode'
import * as vscode from 'vscode'
import * as uuid from 'uuid'
import type { ContextLoader } from '../contextLoader'
import { EXT_NAME } from '../constant'
import { Commands, EXT_DISPLAY_NAME, EXT_NAME } from '../constant'
import { createHash, getServerBaseUrl } from '../utils'
import { state } from '../state'
import { EventType, emitter } from '../emitter'
@@ -29,28 +30,50 @@ class ChatViewProvider implements vscode.WebviewViewProvider {
_context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
) {
const { extensionUri } = this.#extContext
this.#view = webviewView
state.sidebarWebviewView = webviewView
webviewView.webview.onDidReceiveMessage(({ eventName, eventData }) => {
ChatViewProvider.updateWebview(webviewView.webview, this.#extContext, this.#projectPath)
}
static createWebviewPanel(extContext: ExtensionContext, projectPath: string): vscode.WebviewPanel {
const panel = vscode.window.createWebviewPanel(
uuid.v4(),
EXT_DISPLAY_NAME,
{
viewColumn: vscode.ViewColumn.Two,
},
{ retainContextWhenHidden: true },
)
state.webviewPanel = panel
ChatViewProvider.updateWebview(panel.webview, extContext, projectPath)
return panel
}
static updateWebview(webview: vscode.Webview, extContext: ExtensionContext, projectPath: string) {
const { extensionUri } = extContext
webview.onDidReceiveMessage(({ eventName, eventData }) => {
emitter.emit(eventName, eventData, EventType.ReceiveMessage)
})
const baseUri = vscode.Uri.joinPath(extensionUri, './node_modules/@nicepkg/gpt-runner-web/dist/browser')
webviewView.webview.options = {
webview.options = {
// Allow scripts in the webview
enableScripts: true,
localResourceRoots: [baseUri],
}
webviewView.webview.html = this.#getHtmlForWebview(webviewView.webview)
webview.html = ChatViewProvider.getHtmlForWebview(webview, extContext, projectPath)
}
#getHtmlForWebview(webview: vscode.Webview) {
const { extensionUri } = this.#extContext
static getHtmlForWebview(webview: vscode.Webview, extContext: ExtensionContext, projectPath: string) {
const { extensionUri } = extContext
const baseUri = vscode.Uri.joinPath(extensionUri, './node_modules/@nicepkg/gpt-runner-web/dist/browser')
@@ -69,7 +92,7 @@ class ChatViewProvider implements vscode.WebviewViewProvider {
window.vscode = acquireVsCodeApi()
window.__GLOBAL_CONFIG__ = {
rootPath: '${this.#projectPath}',
rootPath: '${projectPath}',
serverBaseUrl: '${getServerBaseUrl()}',
initialRoutePath: '/chat',
showDiffCodesBtn: true,
@@ -106,16 +129,25 @@ export async function registerWebview(
ext: ExtensionContext,
) {
const provider = new ChatViewProvider(ext, cwd)
let webviewDisposer: vscode.Disposable | undefined
let sidebarWebviewDisposer: vscode.Disposable | undefined
let webviewPanelDisposer: vscode.Disposable | undefined
const dispose = () => {
webviewDisposer?.dispose?.()
sidebarWebviewDisposer?.dispose?.()
webviewPanelDisposer?.dispose?.()
}
const registerProvider = () => {
dispose()
webviewDisposer = vscode.window.registerWebviewViewProvider(ChatViewProvider.viewType, provider)
return webviewDisposer
sidebarWebviewDisposer = vscode.window.registerWebviewViewProvider(ChatViewProvider.viewType, provider)
webviewPanelDisposer = vscode.commands.registerCommand(Commands.OpenChat, () => {
ChatViewProvider.createWebviewPanel(ext, cwd)
})
return vscode.Disposable.from({
dispose,
})
}
ext.subscriptions.push(registerProvider())

View File

@@ -4,6 +4,7 @@ export interface State {
serverPort: number | null
statusBarItem: vscode.StatusBarItem | null
sidebarWebviewView: vscode.WebviewView | null
webviewPanel: vscode.WebviewPanel | null
insertCodes: string
diffCodes: string
}
@@ -12,6 +13,7 @@ export const state: State = {
serverPort: null,
statusBarItem: null,
sidebarWebviewView: null,
webviewPanel: null,
insertCodes: '',
diffCodes: 'aaa',
}

View File

@@ -23,6 +23,22 @@
<script>
// before-script
</script>
<script>
function createEl(tag, attrs = {}) {
const el = document.createElement(tag)
Object.keys(attrs).forEach((key) => {
el.setAttribute(key, attrs[key])
})
return el
}
const script = createEl('script', {
src: `${window.getGlobalConfig().serverBaseUrl || ''}/api/config/env.js`,
})
document.head.appendChild(script)
</script>
<base href="/">
<link href="/codicon/codicon.css" rel="stylesheet" />
</head>
@@ -35,4 +51,4 @@
</script>
</body>
</html>
</html>

View File

@@ -1,4 +1,5 @@
import { getSearchParams } from '@nicepkg/gpt-runner-shared/browser'
import { EnvConfig } from '@nicepkg/gpt-runner-shared/common'
export interface GlobalConfig {
rootPath: string
@@ -11,7 +12,7 @@ export interface GlobalConfig {
window.__DEFAULT_GLOBAL_CONFIG__ = {
rootPath: getSearchParams('rootPath') || '/Users/yangxiaoming/Documents/codes/gpt-runner',
initialRoutePath: '/',
serverBaseUrl: '',
serverBaseUrl: EnvConfig.get('GPTR_BASE_SERVER_URL'),
showDiffCodesBtn: false,
showInsertCodesBtn: false,
}

View File

@@ -1,10 +1,10 @@
import type { BaseResponse, GetStorageReqParams, GetStorageResData, SaveStorageReqParams, SaveStorageResData } from '@nicepkg/gpt-runner-shared/common'
import type { BaseResponse, StorageGetItemReqParams, StorageGetItemResData, StorageSetItemReqParams, StorageSetItemResData } from '@nicepkg/gpt-runner-shared/common'
import { getGlobalConfig } from '../helpers/global-config'
export interface GetServerStorageParams extends GetStorageReqParams {
export interface GetServerStorageParams extends StorageGetItemReqParams {
}
export type GetServerStorageRes = BaseResponse<GetStorageResData>
export type GetServerStorageRes = BaseResponse<StorageGetItemResData>
export async function getServerStorage(params: GetServerStorageParams): Promise<GetServerStorageRes> {
const { storageName, key } = params
@@ -19,10 +19,10 @@ export async function getServerStorage(params: GetServerStorageParams): Promise<
return data
}
export interface SaveServerStorageParams extends SaveStorageReqParams {
export interface SaveServerStorageParams extends StorageSetItemReqParams {
}
export type SaveServerStorageRes = BaseResponse<SaveStorageResData>
export type SaveServerStorageRes = BaseResponse<StorageSetItemResData>
export async function saveServerStorage(params: SaveServerStorageParams): Promise<SaveServerStorageRes> {
const { storageName, key, value } = params

View File

@@ -141,7 +141,9 @@ export const createSidebarTreeSlice: StateCreator<
if (item.type === GptFileTreeItemType.File) {
gptFileIds.push(item.id)
const chatIds = currentGptFileIdChatIdsMap.get(item.id) || []
result.children = chatIds.map((chatId) => {
const chatInfo = state.getChatInfo(chatId)
chatInfo.parentId = item.id

View File

@@ -20,7 +20,7 @@ async function getStateFromServerOnce(key: string) {
}
// will save each action state to server
const debounceSaveStateToServer = debounce(async (key: string, value: ServerStorageValue) => {
const debounceSaveStateToServerFn = debounce(async (key: string, value: ServerStorageValue) => {
if (hasUpdateStateFromRemote !== 'finish')
return
@@ -31,6 +31,13 @@ const debounceSaveStateToServer = debounce(async (key: string, value: ServerStor
})
}, 1000)
function debounceSaveStateToServer(key: string, value: ServerStorageValue) {
if (hasUpdateStateFromRemote !== 'finish')
return
debounceSaveStateToServerFn(key, value)
}
export class CustomStorage implements StateStorage {
#storage: Storage

View File

@@ -18,7 +18,6 @@ export function resetAllState() {
export function createStore(devtoolsName: string) {
const newCreate = (store: any) => {
const defaultState = create(store).getState()
let result: any
// https://github.com/pmndrs/zustand/issues/852#issuecomment-1059783350
@@ -30,11 +29,13 @@ export function createStore(devtoolsName: string) {
}),
)
}
result = create(store)
else {
result = create(store)
}
// reset state of this store
result.resetState = () => {
const defaultState = create(store).getState()
result.setState(cloneDeep(defaultState), true)
}

View File

@@ -57,7 +57,7 @@
"build:server": "unbuild",
"dev": "pnpm dev:server & pnpm dev:client",
"dev:client": "vite --config ./client/vite.config.ts",
"dev:server": "cross-env NODE_OPTIONS='--experimental-fetch' NODE_NO_WARNINGS='1' DEBUG='enabled' pnpm esno server/start-server.ts --auto-free-port",
"dev:server": "cross-env NODE_ENV=development NODE_OPTIONS='--experimental-fetch' NODE_NO_WARNINGS='1' DEBUG='enabled' pnpm esno server/start-server.ts --auto-free-port",
"start": "cross-env NODE_OPTIONS='--experimental-fetch' NODE_NO_WARNINGS='1' DEBUG='enabled' node dist/start-server.cjs --auto-free-port",
"stub": "unbuild --stub"
},
@@ -104,4 +104,4 @@
"@vitejs/plugin-react": "^4.0.0",
"vite": "^4.3.9"
}
}
}

View File

@@ -1,5 +1,6 @@
import './src/proxy'
import path from 'node:path'
import http from 'node:http'
import type { Express } from 'express'
import express from 'express'
import cors from 'cors'
@@ -49,7 +50,11 @@ export async function startServer(props: StartServerProps): Promise<Express> {
app.use(errorHandlerMiddleware)
app.listen(finalPort, () => console.log(`Server is running on port ${finalPort}`))
const server = http.createServer(app)
// await processWsControllers(server)
server.listen(finalPort, () => console.log(`Server is running on port ${finalPort}`))
return app
}

View File

@@ -1,7 +1,7 @@
import type { Request, Response } from 'express'
import type { ChatStreamReqParams, FailResponse, SuccessResponse } from '@nicepkg/gpt-runner-shared/common'
import { ChatStreamReqParamsSchema, EnvConfig } from '@nicepkg/gpt-runner-shared/common'
import { PathUtils, buildFailResponse, buildSuccessResponse, sendFailResponse, sendSuccessResponse, verifyParamsByZod } from '@nicepkg/gpt-runner-shared/node'
import { ChatStreamReqParamsSchema, EnvConfig, buildFailResponse, buildSuccessResponse } from '@nicepkg/gpt-runner-shared/common'
import { PathUtils, sendFailResponse, sendSuccessResponse, verifyParamsByZod } from '@nicepkg/gpt-runner-shared/node'
import { loadUserConfig } from '@nicepkg/gpt-runner-core'
import { chatgptChain } from '../services'
import type { ControllerConfig } from './../types'

View File

@@ -1,6 +1,6 @@
import { PathUtils, sendFailResponse, sendSuccessResponse, verifyParamsByZod } from '@nicepkg/gpt-runner-shared/node'
import type { GetUserConfigReqParams, GetUserConfigResData } from '@nicepkg/gpt-runner-shared/common'
import { GetUserConfigReqParamsSchema, resetUserConfigUnsafeKey } from '@nicepkg/gpt-runner-shared/common'
import { EnvConfig, GetUserConfigReqParamsSchema, resetUserConfigUnsafeKey } from '@nicepkg/gpt-runner-shared/common'
import { loadUserConfig } from '@nicepkg/gpt-runner-core'
import pkg from '../../../package.json'
import type { ControllerConfig } from '../types'
@@ -19,6 +19,17 @@ export const configControllers: ControllerConfig = {
})
},
},
{
url: '/env.js',
method: 'get',
handler: async (req, res) => {
const envMap = EnvConfig.getClientEnvVarsInServerSide()
// response a javascript file
res.setHeader('Content-Type', 'application/javascript')
res.send(`window.__env__ = ${JSON.stringify(envMap)}`)
},
},
{
url: '/user-config',
method: 'get',

View File

@@ -1,9 +1,11 @@
import type { NextFunction, Router } from 'express'
import { WssActionName, WssUtils, buildFailResponse } from '@nicepkg/gpt-runner-shared/common'
import type { Controller, ControllerConfig } from '../types'
import { chatgptControllers } from './chatgpt.controller'
import { configControllers } from './config.controller'
import { gptFilesControllers } from './gpt-files.controller'
import { storageControllers } from './storage.controller'
import { allWsControllersConfig } from './ws'
export function processControllers(router: Router) {
const allControllersConfig: ControllerConfig[] = [
@@ -33,3 +35,34 @@ export function processControllers(router: Router) {
})
})
}
export async function processWsControllers(server: any) {
await WssUtils.instance.connect({ server })
allWsControllersConfig.forEach((controllerConfig) => {
const { controllers } = controllerConfig
controllers.forEach((controller) => {
const { actionName, handler } = controller
WssUtils.instance.on(actionName, async (params: Record<string, any>) => {
console.log(`[WS] ${actionName} params:`, params)
try {
return await handler(params as any)
}
catch (error: any) {
const errRes = buildFailResponse({ data: error, message: error?.message || String(error) })
WssUtils.instance.emit(actionName, {
reqParams: params as any,
res: errRes,
})
WssUtils.instance.emit(WssActionName.Error, {
res: errRes,
})
}
})
})
})
}

View File

@@ -1,6 +1,6 @@
import { getStorage, sendSuccessResponse, verifyParamsByZod } from '@nicepkg/gpt-runner-shared/node'
import type { GetStorageReqParams, GetStorageResData, SaveStorageReqParams, SaveStorageResData } from '@nicepkg/gpt-runner-shared/common'
import { GetStorageReqParamsSchema, SaveStorageReqParamsSchema } from '@nicepkg/gpt-runner-shared/common'
import type { StorageClearReqParams, StorageClearResData, StorageGetItemReqParams, StorageGetItemResData, StorageRemoveItemReqParams, StorageRemoveItemResData, StorageSetItemReqParams, StorageSetItemResData } from '@nicepkg/gpt-runner-shared/common'
import { StorageClearReqParamsSchema, StorageGetItemReqParamsSchema, StorageRemoveItemReqParamsSchema, StorageSetItemReqParamsSchema } from '@nicepkg/gpt-runner-shared/common'
import type { ControllerConfig } from '../types'
export const storageControllers: ControllerConfig = {
@@ -10,9 +10,9 @@ export const storageControllers: ControllerConfig = {
url: '/',
method: 'get',
handler: async (req, res) => {
const query = req.query as GetStorageReqParams
const query = req.query as StorageGetItemReqParams
verifyParamsByZod(query, GetStorageReqParamsSchema)
verifyParamsByZod(query, StorageGetItemReqParamsSchema)
const { key, storageName } = query
@@ -23,7 +23,7 @@ export const storageControllers: ControllerConfig = {
data: {
value,
cacheDir,
} satisfies GetStorageResData,
} satisfies StorageGetItemResData,
})
},
},
@@ -31,27 +31,53 @@ export const storageControllers: ControllerConfig = {
url: '/',
method: 'post',
handler: async (req, res) => {
const body = req.body as SaveStorageReqParams
const body = req.body as StorageSetItemReqParams
verifyParamsByZod(body, SaveStorageReqParamsSchema)
verifyParamsByZod(body, StorageSetItemReqParamsSchema)
const { storageName, key, value } = body
const { storage } = await getStorage(storageName)
switch (value) {
case undefined:
// remove
await storage.delete(key)
break
default:
// set
await storage.set(key, value)
break
}
await storage.set(key, value)
sendSuccessResponse(res, {
data: null satisfies SaveStorageResData,
data: null satisfies StorageSetItemResData,
})
},
},
{
url: '/',
method: 'delete',
handler: async (req, res) => {
const body = req.body as StorageRemoveItemReqParams
verifyParamsByZod(body, StorageRemoveItemReqParamsSchema)
const { key, storageName } = body
const { storage } = await getStorage(storageName)
await storage.delete(key)
sendSuccessResponse(res, {
data: null satisfies StorageRemoveItemResData,
})
},
},
{
url: '/clear',
method: 'post',
handler: async (req, res) => {
const body = req.body as StorageClearReqParams
verifyParamsByZod(body, StorageClearReqParamsSchema)
const { storageName } = body
const { storage } = await getStorage(storageName)
await storage.clear()
sendSuccessResponse(res, {
data: null satisfies StorageClearResData,
})
},
},

View File

@@ -0,0 +1,4 @@
import type { WssControllerConfig } from '../../types'
import { storageControllers } from './storage.ws-controller'
export const allWsControllersConfig: WssControllerConfig[] = [storageControllers]

View File

@@ -0,0 +1,84 @@
import type { StorageClearResData, StorageGetItemReqParams, StorageGetItemResData, StorageRemoveItemReqParams, StorageRemoveItemResData, StorageSetItemReqParams, StorageSetItemResData } from '@nicepkg/gpt-runner-shared/common'
import { StorageClearReqParamsSchema, StorageGetItemReqParamsSchema, StorageRemoveItemReqParamsSchema, StorageSetItemReqParamsSchema, WssActionName, WssUtils, buildSuccessResponse } from '@nicepkg/gpt-runner-shared/common'
import { getStorage, verifyParamsByZod } from '@nicepkg/gpt-runner-shared/node'
import type { WssControllerConfig } from '../../types'
export const storageControllers: WssControllerConfig = {
controllers: [
{
actionName: WssActionName.StorageGetItem,
handler: async (params: StorageGetItemReqParams) => {
verifyParamsByZod(params, StorageGetItemReqParamsSchema)
const { key, storageName } = params
const { storage, cacheDir } = await getStorage(storageName)
const value = await storage.get(key)
WssUtils.instance.emit(WssActionName.StorageGetItem, {
reqParams: params,
res: buildSuccessResponse({
data: {
value,
cacheDir,
} satisfies StorageGetItemResData,
}),
})
},
},
{
actionName: WssActionName.StorageSetItem,
handler: async (params: StorageSetItemReqParams) => {
verifyParamsByZod(params, StorageSetItemReqParamsSchema)
const { storageName, key, value } = params
const { storage } = await getStorage(storageName)
await storage.set(key, value)
WssUtils.instance.emit(WssActionName.StorageSetItem, {
reqParams: params,
res: buildSuccessResponse({
data: null satisfies StorageSetItemResData,
}),
})
},
},
{
actionName: WssActionName.StorageRemoveItem,
handler: async (params: StorageRemoveItemReqParams) => {
verifyParamsByZod(params, StorageRemoveItemReqParamsSchema)
const { key, storageName } = params
const { storage } = await getStorage(storageName)
await storage.delete(key)
WssUtils.instance.emit(WssActionName.StorageRemoveItem, {
reqParams: params,
res: buildSuccessResponse({
data: null satisfies StorageRemoveItemResData,
}),
})
},
},
{
actionName: WssActionName.StorageClear,
handler: async (params) => {
verifyParamsByZod(params, StorageClearReqParamsSchema)
const { storageName } = params
const { storage } = await getStorage(storageName)
await storage.clear()
WssUtils.instance.emit(WssActionName.StorageClear, {
reqParams: params,
res: buildSuccessResponse({
data: null satisfies StorageClearResData,
}),
})
},
},
],
}

View File

@@ -1,11 +1,21 @@
import type { WssActionName } from '@nicepkg/gpt-runner-shared/common'
import type { NextFunction, Request, Response } from 'express'
export interface ControllerConfig {
namespacePath: string
controllers: Controller[]
}
export interface Controller {
url: string
method: 'all' | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head'
handler: (req: Request<Record<string, any>, any, any, Record<string, any>>, res: Response, next: NextFunction) => Promise<void>
}
export interface ControllerConfig {
namespacePath: string
controllers: Controller[]
}
export interface WssController {
actionName: WssActionName
handler: (params: any) => Promise<void>
}
export interface WssControllerConfig {
controllers: WssController[]
}

124
pnpm-lock.yaml generated
View File

@@ -335,6 +335,12 @@ importers:
minimatch:
specifier: ^9.0.1
version: 9.0.1
socket.io:
specifier: ^4.6.2
version: 4.6.2
socket.io-client:
specifier: ^4.6.2
version: 4.6.2
zod:
specifier: ^3.21.4
version: 3.21.4
@@ -3968,6 +3974,10 @@ packages:
webpack-sources: 3.2.3
dev: false
/@socket.io/component-emitter@3.1.0:
resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
dev: false
/@surma/rollup-plugin-off-main-thread@2.2.3:
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
dependencies:
@@ -4354,13 +4364,11 @@ packages:
/@types/cookie@0.4.1:
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
dev: true
/@types/cors@2.8.13:
resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==}
dependencies:
'@types/node': 18.16.9
dev: true
/@types/debug@4.1.7:
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
@@ -5524,6 +5532,11 @@ packages:
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
/base64id@2.0.0:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
dev: false
/batch@0.6.1:
resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==}
dev: false
@@ -6295,7 +6308,6 @@ packages:
/cookie@0.4.2:
resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
engines: {node: '>= 0.6'}
dev: true
/cookie@0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
@@ -7368,6 +7380,45 @@ packages:
once: 1.4.0
dev: false
/engine.io-client@6.4.0:
resolution: {integrity: sha512-GyKPDyoEha+XZ7iEqam49vz6auPnNJ9ZBfy89f+rMMas8AuiMWOZ9PVzu8xb9ZC6rafUqiGHSCfu22ih66E+1g==}
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4
engine.io-parser: 5.0.7
ws: 8.11.0
xmlhttprequest-ssl: 2.0.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/engine.io-parser@5.0.7:
resolution: {integrity: sha512-P+jDFbvK6lE3n1OL+q9KuzdOFWkkZ/cMV9gol/SbVfpyqfvrfrFTOFJ6fQm2VC3PZHlU3QPhVwmbsCnauHF2MQ==}
engines: {node: '>=10.0.0'}
dev: false
/engine.io@6.4.2:
resolution: {integrity: sha512-FKn/3oMiJjrOEOeUub2WCox6JhxBXq/Zn3fZOMCBxKnNYtsdKjxhl7yR3fZhM9PV+rdE75SU5SYMc+2PGzo+Tg==}
engines: {node: '>=10.0.0'}
dependencies:
'@types/cookie': 0.4.1
'@types/cors': 2.8.13
'@types/node': 18.16.9
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.4.2
cors: 2.8.5
debug: 4.3.4
engine.io-parser: 5.0.7
ws: 8.11.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/enhanced-resolve@5.14.1:
resolution: {integrity: sha512-Vklwq2vDKtl0y/vtwjSesgJ5MYS7Etuk5txS8VdKL4AOS1aUlD96zqIfsOSLQsdv3xgMRbtkWM8eG9XDfKUPow==}
engines: {node: '>=10.13.0'}
@@ -14459,6 +14510,55 @@ packages:
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
dev: true
/socket.io-adapter@2.5.2:
resolution: {integrity: sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==}
dependencies:
ws: 8.11.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
dev: false
/socket.io-client@4.6.2:
resolution: {integrity: sha512-OwWrMbbA8wSqhBAR0yoPK6EdQLERQAYjXb3A0zLpgxfM1ZGLKoxHx8gVmCHA6pcclRX5oA/zvQf7bghAS11jRA==}
engines: {node: '>=10.0.0'}
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4
engine.io-client: 6.4.0
socket.io-parser: 4.2.4
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/socket.io-parser@4.2.4:
resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
engines: {node: '>=10.0.0'}
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
/socket.io@4.6.2:
resolution: {integrity: sha512-Vp+lSks5k0dewYTfwgPT9UeGGd+ht7sCpB7p0e83VgO4X/AHYWhXITMrNk/pg8syY2bpx23ptClCQuHhqi2BgQ==}
engines: {node: '>=10.0.0'}
dependencies:
accepts: 1.3.8
base64id: 2.0.0
debug: 4.3.4
engine.io: 6.4.2
socket.io-adapter: 2.5.2
socket.io-parser: 4.2.4
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/sockjs@0.3.24:
resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==}
dependencies:
@@ -16827,6 +16927,19 @@ packages:
optional: true
dev: false
/ws@8.11.0:
resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
dev: false
/ws@8.13.0:
resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==}
engines: {node: '>=10.0.0'}
@@ -16860,6 +16973,11 @@ packages:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
dev: true
/xmlhttprequest-ssl@2.0.0:
resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==}
engines: {node: '>=0.4.0'}
dev: false
/xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}