feat(gpt-runner-core): add client
This commit is contained in:
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
max_line_length = 80
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -3,7 +3,8 @@
|
||||
"rules": {
|
||||
"yml/no-empty-document": "off",
|
||||
"react/no-unknown-property": "off",
|
||||
"no-console": "off"
|
||||
"no-console": "off",
|
||||
"no-mixed-operators": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -4,7 +4,12 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"editor.formatOnSave": false,
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.inlayHints.parameterNames.enabled": "all",
|
||||
// "typescript.inlayHints.variableTypes.enabled": true,
|
||||
// "typescript.inlayHints.propertyDeclarationTypes.enabled": true,
|
||||
"typescript.inlayHints.parameterTypes.enabled": true,
|
||||
// "typescript.inlayHints.functionLikeReturnTypes.enabled": true,
|
||||
"cSpell.words": [
|
||||
"Chatgpt",
|
||||
"langchain",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineBuildConfig } from 'unbuild'
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: [
|
||||
'client/src/index',
|
||||
'index',
|
||||
'server/src/index',
|
||||
],
|
||||
clean: true,
|
||||
|
||||
15
packages/gpt-runner-core/client/index.html
Normal file
15
packages/gpt-runner-core/client/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GPT Runner</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
49
packages/gpt-runner-core/client/src/api/chatgpt.ts
Normal file
49
packages/gpt-runner-core/client/src/api/chatgpt.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { EventSourceMessage } from '@microsoft/fetch-event-source'
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import type { ChatStreamReqParams } from '../../../server/src/controllers/chatgpt.controller'
|
||||
import { getConfig } from '../../src/utils/config'
|
||||
|
||||
export interface fetchChatStreamReqParams extends ChatStreamReqParams {
|
||||
namespace?: string
|
||||
onMessage?: (ev: EventSourceMessage) => void
|
||||
onError?: (error: any) => void
|
||||
}
|
||||
|
||||
export async function fetchChatgptStream(
|
||||
params: fetchChatStreamReqParams,
|
||||
) {
|
||||
const {
|
||||
messages,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
temperature,
|
||||
namespace,
|
||||
onMessage = () => {},
|
||||
onError = () => {},
|
||||
} = params
|
||||
|
||||
const ctrl = new AbortController()
|
||||
|
||||
try {
|
||||
fetchEventSource(`${getConfig()}/api/chatgpt/chat-stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'namespace': namespace || 'default-namespace',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
messages,
|
||||
systemPrompt,
|
||||
temperature,
|
||||
}),
|
||||
signal: ctrl.signal,
|
||||
onmessage: onMessage,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
|
||||
return ctrl
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export function useAnimationFrame(callback: (time: number) => void, deps?: any[]) {
|
||||
const requestRef = useRef<number>()
|
||||
const previousTimeRef = useRef<number>()
|
||||
|
||||
const animate = (time: number) => {
|
||||
if (previousTimeRef.current !== undefined) {
|
||||
const deltaTime = time - previousTimeRef.current
|
||||
callback(deltaTime)
|
||||
}
|
||||
previousTimeRef.current = time
|
||||
requestRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
requestRef.current = requestAnimationFrame(animate)
|
||||
return () => cancelAnimationFrame(requestRef.current!)
|
||||
}, deps) // Make sure the effect runs only once
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { EventEmitter } from 'eventemitter3'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ClientEventName } from '../../../index'
|
||||
import type { MessageItemModel } from '../types/chat'
|
||||
|
||||
export interface ClientEventData {
|
||||
[ClientEventName.SyncState]: void
|
||||
[ClientEventName.SetIsReady]: boolean
|
||||
[ClientEventName.SetHasSelection]: boolean
|
||||
[ClientEventName.AddMessageAction]: MessageItemModel
|
||||
[ClientEventName.UpdateMessageAction]: MessageItemModel
|
||||
[ClientEventName.ClearMessageAction]: void
|
||||
[ClientEventName.ConfirmPrompt]: string
|
||||
[ClientEventName.InsertCodeSnippet]: string
|
||||
}
|
||||
|
||||
if (!window.__emitter__)
|
||||
window.__emitter__ = new EventEmitter()
|
||||
|
||||
export const emitter = window.__emitter__
|
||||
|
||||
export function useEventEmitter() {
|
||||
const listenersRef = useRef<{
|
||||
[event in ClientEventName]?: (...args: any[]) => void;
|
||||
}>({})
|
||||
|
||||
const on = <T extends ClientEventName>(
|
||||
event: T,
|
||||
listener: (arg: ClientEventData[T]) => void,
|
||||
) => {
|
||||
listenersRef.current[event] = listener
|
||||
emitter.on(event, listener)
|
||||
}
|
||||
|
||||
const once = <T extends ClientEventName>(
|
||||
event: T,
|
||||
listener: (arg: ClientEventData[T]) => void,
|
||||
) => {
|
||||
listenersRef.current[event] = listener
|
||||
emitter.once(event, listener)
|
||||
}
|
||||
|
||||
const off = <T extends ClientEventName>(
|
||||
event: T,
|
||||
listener: (arg: ClientEventData[T]) => void,
|
||||
) => {
|
||||
delete listenersRef.current[event]
|
||||
emitter.off(event, listener)
|
||||
}
|
||||
|
||||
const emit = <T extends ClientEventName>(event: T, arg: ClientEventData[T] extends void ? null : ClientEventData[T]) => {
|
||||
emitter.emit(event, arg)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.entries(listenersRef.current).forEach(([event, listener]) => {
|
||||
if (listener)
|
||||
emitter.off(event as ClientEventName, listener)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { on, once, off, emit }
|
||||
}
|
||||
17
packages/gpt-runner-core/client/src/main.tsx
Normal file
17
packages/gpt-runner-core/client/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { getConfig } from './utils/config'
|
||||
import { GlobalStyle } from './styles/global.styles'
|
||||
import { ChatPage } from './views/chat'
|
||||
|
||||
function App() {
|
||||
const { pageName } = getConfig()
|
||||
|
||||
return <>
|
||||
<GlobalStyle />
|
||||
{pageName ? <ChatPage /> : null}
|
||||
</>
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
root.render(<App />)
|
||||
19
packages/gpt-runner-core/client/src/styles/global.styles.ts
Normal file
19
packages/gpt-runner-core/client/src/styles/global.styles.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createGlobalStyle } from 'styled-components'
|
||||
|
||||
export const GlobalStyle = createGlobalStyle`
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
background: var(--background);
|
||||
}
|
||||
`
|
||||
161
packages/gpt-runner-core/client/src/styles/vscode.styles.ts
Normal file
161
packages/gpt-runner-core/client/src/styles/vscode.styles.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// looks we can get value like var(--background)
|
||||
// so this file is unused
|
||||
|
||||
function create<T>(defaultVariableName: string, backupVariableName?: string) {
|
||||
return {
|
||||
withDefault: (defaultValue: T) => {
|
||||
const backupVariable = backupVariableName
|
||||
? `var(${backupVariableName}, ${defaultValue})`
|
||||
: defaultValue
|
||||
return `var(--${defaultVariableName}, ${backupVariable});`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// see: https://github.com/microsoft/vscode-webview-ui-toolkit/blob/main/src/design-tokens.ts
|
||||
|
||||
/**
|
||||
* Developer note:
|
||||
*
|
||||
* There are some tokens defined in this file that make use of `--fake-vscode-token`. This is
|
||||
* done when a toolkit token should be added to the tokenMappings map (and subsequently altered
|
||||
* in the applyTheme function) but does not have a corresponding VS Code token that can be used.
|
||||
*
|
||||
* An example is buttonIconHoverBackground token which does not have a corresponding VS Code token
|
||||
* at this time (it's a hardcoded value in VS Code), but needs to be adjusted to be transparent when a
|
||||
* high contrast theme is applied.
|
||||
*
|
||||
* As a rule of thumb, if there are special cases where a token needs to be adjusted based on the
|
||||
* VS Code theme and does not have a corresponding VS Code token, `--fake-vscode-token` can be used
|
||||
* to indicate that it should be added to the tokenMappings map and thus make it accessible to the
|
||||
* applyTheme function where it can be dynamically adjusted.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Global design tokens.
|
||||
*/
|
||||
export const background = create<string>('background', '--vscode-editor-background').withDefault('#1e1e1e')
|
||||
export const borderWidth = create<number>('border-width').withDefault(1)
|
||||
export const contrastActiveBorder = create<string>('contrast-active-border', '--vscode-contrastActiveBorder').withDefault('#f38518')
|
||||
export const contrastBorder = create<string>('contrast-border', '--vscode-contrastBorder').withDefault('#6fc3df')
|
||||
export const cornerRadius = create<number>('corner-radius').withDefault(0)
|
||||
export const designUnit = create<number>('design-unit').withDefault(4)
|
||||
export const disabledOpacity = create<number>('disabled-opacity').withDefault(0.4)
|
||||
export const focusBorder = create<string>('focus-border', '--vscode-focusBorder').withDefault('#007fd4')
|
||||
export const fontFamily = create<string>('font-family', '--vscode-font-family').withDefault(
|
||||
'-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol',
|
||||
)
|
||||
export const fontWeight = create<string>('font-weight', '--vscode-font-weight').withDefault('400')
|
||||
export const foreground = create<string>('foreground', '--vscode-foreground').withDefault('#cccccc')
|
||||
export const inputHeight = create<string>('input-height').withDefault('26')
|
||||
export const inputMinWidth = create<string>('input-min-width').withDefault('100px')
|
||||
export const typeRampBaseFontSize = create<string>('type-ramp-base-font-size', '--vscode-font-size').withDefault('13px')
|
||||
export const typeRampBaseLineHeight = create<string>('type-ramp-base-line-height').withDefault('normal')
|
||||
export const typeRampMinus1FontSize = create<string>('type-ramp-minus1-font-size').withDefault('11px')
|
||||
export const typeRampMinus1LineHeight = create<string>('type-ramp-minus1-line-height').withDefault('16px')
|
||||
export const typeRampMinus2FontSize = create<string>('type-ramp-minus2-font-size').withDefault('9px')
|
||||
export const typeRampMinus2LineHeight = create<string>('type-ramp-minus2-line-height').withDefault('16px')
|
||||
export const typeRampPlus1FontSize = create<string>('type-ramp-plus1-font-size').withDefault('16px')
|
||||
export const typeRampPlus1LineHeight = create<string>('type-ramp-plus1-line-height').withDefault('24px')
|
||||
export const scrollbarWidth = create<string>('scrollbarWidth').withDefault('10px')
|
||||
export const scrollbarHeight = create<string>('scrollbarHeight').withDefault('10px')
|
||||
export const scrollbarSliderBackground = create<string>('scrollbar-slider-background', '--vscode-scrollbarSlider-background').withDefault('#79797966')
|
||||
export const scrollbarSliderHoverBackground = create<string>('scrollbar-slider-hover-background', '--vscode-scrollbarSlider-hoverBackground').withDefault('#646464b3')
|
||||
export const scrollbarSliderActiveBackground = create<string>('scrollbar-slider-active-background', '--vscode-scrollbarSlider-activeBackground').withDefault('#bfbfbf66')
|
||||
|
||||
/**
|
||||
* Badge design tokens.
|
||||
*/
|
||||
|
||||
export const badgeBackground = create<string>('badge-background', '--vscode-badge-background').withDefault('#4d4d4d')
|
||||
export const badgeForeground = create<string>('badge-foreground', '--vscode-badge-foreground').withDefault('#ffffff')
|
||||
|
||||
/**
|
||||
* Button design tokens.
|
||||
*/
|
||||
|
||||
// Note: Button border is used only for high contrast themes and should be left as transparent otherwise.
|
||||
export const buttonBorder = create<string>('button-border', '--vscode-button-border').withDefault('transparent')
|
||||
export const buttonIconBackground = create<string>('button-icon-background').withDefault('transparent')
|
||||
export const buttonIconCornerRadius = create<string>('button-icon-corner-radius').withDefault('5px')
|
||||
export const buttonIconFocusBorderOffset = create<number>('button-icon-outline-offset').withDefault(0)
|
||||
// Note usage of `--fake-vscode-token` (refer to doc comment at top of file for explanation).
|
||||
export const buttonIconHoverBackground = create<string>('button-icon-hover-background', '--fake-vscode-token').withDefault('rgba(90, 93, 94, 0.31)')
|
||||
export const buttonIconPadding = create<string>('button-icon-padding').withDefault('3px')
|
||||
export const buttonPrimaryBackground = create<string>('button-primary-background', '--vscode-button-background').withDefault('#0e639c')
|
||||
export const buttonPrimaryForeground = create<string>('button-primary-foreground', '--vscode-button-foreground').withDefault('#ffffff')
|
||||
export const buttonPrimaryHoverBackground = create<string>('button-primary-hover-background', '--vscode-button-hoverBackground').withDefault('#1177bb')
|
||||
export const buttonSecondaryBackground = create<string>('button-secondary-background', '--vscode-button-secondaryBackground').withDefault('#3a3d41')
|
||||
export const buttonSecondaryForeground = create<string>('button-secondary-foreground', '--vscode-button-secondaryForeground').withDefault('#ffffff')
|
||||
export const buttonSecondaryHoverBackground = create<string>('button-secondary-hover-background', '--vscode-button-secondaryHoverBackground').withDefault('#45494e')
|
||||
export const buttonPaddingHorizontal = create<string>('button-padding-horizontal').withDefault('11px')
|
||||
export const buttonPaddingVertical = create<string>('button-padding-vertical').withDefault('4px')
|
||||
|
||||
/**
|
||||
* Checkbox design tokens.
|
||||
*/
|
||||
|
||||
export const checkboxBackground = create<string>('checkbox-background', '--vscode-checkbox-background').withDefault('#3c3c3c')
|
||||
export const checkboxBorder = create<string>('checkbox-border', '--vscode-checkbox-border').withDefault('#3c3c3c')
|
||||
export const checkboxCornerRadius = create<number>('checkbox-corner-radius').withDefault(3)
|
||||
export const checkboxForeground = create<string>('checkbox-foreground', '--vscode-checkbox-foreground').withDefault('#f0f0f0')
|
||||
|
||||
/**
|
||||
* Data Grid design tokens
|
||||
*/
|
||||
|
||||
export const listActiveSelectionBackground = create<string>('list-active-selection-background', '--vscode-list-activeSelectionBackground').withDefault('#094771')
|
||||
export const listActiveSelectionForeground = create<string>('list-active-selection-foreground', '--vscode-list-activeSelectionForeground').withDefault('#ffffff')
|
||||
export const listHoverBackground = create<string>('list-hover-background', '--vscode-list-hoverBackground').withDefault('#2a2d2e')
|
||||
|
||||
/**
|
||||
* Divider design tokens.
|
||||
*/
|
||||
|
||||
export const dividerBackground = create<string>('divider-background', '--vscode-settings-dropdownListBorder').withDefault('#454545')
|
||||
|
||||
/**
|
||||
* Dropdown design tokens.
|
||||
*/
|
||||
|
||||
export const dropdownBackground = create<string>('dropdown-background', '--vscode-dropdown-background').withDefault('#3c3c3c')
|
||||
export const dropdownBorder = create<string>('dropdown-border', '--vscode-dropdown-border').withDefault('#3c3c3c')
|
||||
export const dropdownForeground = create<string>('dropdown-foreground', '--vscode-dropdown-foreground').withDefault('#f0f0f0')
|
||||
export const dropdownListMaxHeight = create<string>('dropdown-list-max-height').withDefault('200px')
|
||||
|
||||
/**
|
||||
* Text Field & Area design tokens.
|
||||
*/
|
||||
|
||||
export const inputBackground = create<string>('input-background', '--vscode-input-background').withDefault('#3c3c3c')
|
||||
export const inputForeground = create<string>('input-foreground', '--vscode-input-foreground').withDefault('#cccccc')
|
||||
export const inputPlaceholderForeground = create<string>('input-placeholder-foreground', '--vscode-input-placeholderForeground').withDefault('#cccccc')
|
||||
|
||||
/**
|
||||
* Link design tokens.
|
||||
*/
|
||||
|
||||
export const linkActiveForeground = create<string>('link-active-foreground', '--vscode-textLink-activeForeground').withDefault('#3794ff')
|
||||
export const linkForeground = create<string>('link-foreground', '--vscode-textLink-foreground').withDefault('#3794ff')
|
||||
|
||||
/**
|
||||
* Progress ring design tokens.
|
||||
*/
|
||||
|
||||
export const progressBackground = create<string>('progress-background', '--vscode-progressBar-background').withDefault('#0e70c0')
|
||||
|
||||
/**
|
||||
* Panels design tokens.
|
||||
*/
|
||||
|
||||
export const panelTabActiveBorder = create<string>('panel-tab-active-border', '--vscode-panelTitle-activeBorder').withDefault('#e7e7e7')
|
||||
export const panelTabActiveForeground = create<string>('panel-tab-active-foreground', '--vscode-panelTitle-activeForeground').withDefault('#e7e7e7')
|
||||
export const panelTabForeground = create<string>('panel-tab-foreground', '--vscode-panelTitle-inactiveForeground').withDefault('#e7e7e799')
|
||||
export const panelViewBackground = create<string>('panel-view-background', '--vscode-panel-background').withDefault('#1e1e1e')
|
||||
export const panelViewBorder = create<string>('panel-view-border', '--vscode-panel-border').withDefault('#80808059')
|
||||
|
||||
/**
|
||||
* Tag design tokens.
|
||||
*/
|
||||
|
||||
export const tagCornerRadius = create<string>('tag-corner-radius').withDefault('2px')
|
||||
6
packages/gpt-runner-core/client/src/types/chat.ts
Normal file
6
packages/gpt-runner-core/client/src/types/chat.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface MessageItemModel {
|
||||
id: string
|
||||
contents: string
|
||||
isReply?: boolean
|
||||
isFinished?: boolean
|
||||
}
|
||||
9
packages/gpt-runner-core/client/src/types/global.d.ts
vendored
Normal file
9
packages/gpt-runner-core/client/src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ClientConfig } from "../../../types";
|
||||
import { EventEmitter } from 'eventemitter3'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__config__?: ClientConfig
|
||||
__emitter__?: InstanceType<typeof EventEmitter>
|
||||
}
|
||||
}
|
||||
1
packages/gpt-runner-core/client/src/types/vite-env.d.ts
vendored
Normal file
1
packages/gpt-runner-core/client/src/types/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
12
packages/gpt-runner-core/client/src/utils/config.ts
Normal file
12
packages/gpt-runner-core/client/src/utils/config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ClientConfig } from '../../../types'
|
||||
|
||||
export function getConfig() {
|
||||
const defaultConfig: ClientConfig = {
|
||||
pageName: 'GPT Runner',
|
||||
baseServerUrl: 'http://localhost:3003',
|
||||
}
|
||||
return {
|
||||
...defaultConfig,
|
||||
...window.__config__,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { useAnimationFrame } from '../../hooks/use-animation-frame.hook'
|
||||
|
||||
function cubicBezier(t: number) {
|
||||
return 3 * (1 - t) * t ** 2 * 0.7 + t ** 3
|
||||
}
|
||||
|
||||
export function IndeterminateProgressBar() {
|
||||
const indicatorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const shortLength = 20
|
||||
const longLength = 50
|
||||
const lengthRatio = 100 + shortLength + longLength
|
||||
|
||||
const duration = 2.1 * 1e3
|
||||
const elapsedRef = useRef(0)
|
||||
|
||||
useAnimationFrame((deltaTime) => {
|
||||
if (!indicatorRef.current)
|
||||
return
|
||||
|
||||
const progress = cubicBezier(elapsedRef.current / duration)
|
||||
|
||||
const newLeft = (100 + lengthRatio) * progress - lengthRatio
|
||||
if (progress > 1) {
|
||||
indicatorRef.current.style.left = `-${lengthRatio}%`
|
||||
elapsedRef.current = 0
|
||||
}
|
||||
else {
|
||||
indicatorRef.current.style.left = `${newLeft}%`
|
||||
elapsedRef.current += deltaTime
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '2px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={indicatorRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: `${lengthRatio}%`,
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: `-${lengthRatio}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
background: 'var(--vscode-progressBar-background)',
|
||||
width: `${(longLength / lengthRatio) * 100}%`,
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
background: 'var(--vscode-progressBar-background)',
|
||||
width: `${(shortLength / lengthRatio) * 100}%`,
|
||||
height: '100%',
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
packages/gpt-runner-core/client/src/views/chat/index.tsx
Normal file
157
packages/gpt-runner-core/client/src/views/chat/index.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { VSCodeButton, VSCodeTextArea } from '@vscode/webview-ui-toolkit/react'
|
||||
|
||||
import type { MessageItemModel } from '../../types/chat'
|
||||
import { useEventEmitter } from '../../hooks/use-emitter.hook'
|
||||
import { ClientEventName } from '../../../../index'
|
||||
import { MessageItem } from './message-item'
|
||||
|
||||
function messagesWithUpdatedBotMessage(
|
||||
msgs: MessageItemModel[],
|
||||
updatedMsg: MessageItemModel,
|
||||
): MessageItemModel[] {
|
||||
return msgs.map((msg) => {
|
||||
if (updatedMsg.id === msg.id)
|
||||
return updatedMsg
|
||||
|
||||
return msg
|
||||
})
|
||||
}
|
||||
|
||||
interface UseConfirmShortcut {
|
||||
label: string
|
||||
keyDownHandler: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
function useConfirmShortcut(handler: () => void): UseConfirmShortcut {
|
||||
const isMac = useMemo(() => {
|
||||
const userAgentData = (window.navigator as any).userAgentData
|
||||
if (userAgentData)
|
||||
return userAgentData.platform === 'macOS'
|
||||
|
||||
return window.navigator.platform === 'MacIntel'
|
||||
}, [])
|
||||
|
||||
return {
|
||||
label: isMac ? '⌘⏎' : 'Ctrl+Enter',
|
||||
keyDownHandler: useCallback(
|
||||
(e) => {
|
||||
if (e.key !== 'Enter')
|
||||
return
|
||||
|
||||
const expected = isMac ? e.metaKey : e.ctrlKey
|
||||
const unexpected = isMac ? e.ctrlKey : e.metaKey
|
||||
if (!expected || e.altKey || e.shiftKey || unexpected)
|
||||
return
|
||||
|
||||
handler()
|
||||
},
|
||||
[isMac, handler],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_SCROLL_FLAG_NONE = 0
|
||||
const AUTO_SCROLL_FLAG_FORCED = 1
|
||||
const AUTO_SCROLL_FLAG_AUTOMATIC = 2
|
||||
|
||||
export function ChatPage() {
|
||||
const [messages, setMessages] = useState([] as MessageItemModel[])
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [autoScrollFlag, setAutoScrollFlag] = useState(AUTO_SCROLL_FLAG_NONE)
|
||||
const chatListRef = useRef<HTMLDivElement>(null)
|
||||
const { emit, on } = useEventEmitter()
|
||||
|
||||
// Dependent on `setMessages`, which will never change.
|
||||
const addMessageAction = useCallback((msg: MessageItemModel) => {
|
||||
setMessages((prev) => {
|
||||
return [...prev, msg]
|
||||
})
|
||||
setAutoScrollFlag(AUTO_SCROLL_FLAG_FORCED)
|
||||
}, [])
|
||||
|
||||
const updateMessageAction = useCallback((msg: MessageItemModel) => {
|
||||
setMessages((prev) => {
|
||||
return messagesWithUpdatedBotMessage(prev, msg)
|
||||
})
|
||||
setAutoScrollFlag(AUTO_SCROLL_FLAG_AUTOMATIC)
|
||||
}, [])
|
||||
|
||||
const clearMessageAction = useCallback(() => {
|
||||
setMessages([])
|
||||
}, [])
|
||||
|
||||
const handleAskAction = useCallback(async () => {
|
||||
emit(ClientEventName.ConfirmPrompt, prompt)
|
||||
setPrompt('')
|
||||
}, [prompt, setPrompt, setMessages])
|
||||
|
||||
const confirmShortcut = useConfirmShortcut(handleAskAction)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!autoScrollFlag)
|
||||
return
|
||||
|
||||
const chatListEl = chatListRef.current
|
||||
if (!chatListEl)
|
||||
return
|
||||
|
||||
setAutoScrollFlag(AUTO_SCROLL_FLAG_NONE)
|
||||
|
||||
const targetScrollTop
|
||||
= chatListEl.scrollHeight - chatListEl.clientHeight
|
||||
// TODO: implement `AUTO_SCROLL_FLAG_AUTOMATIC` flag.
|
||||
chatListEl.scrollTop = targetScrollTop
|
||||
}, [messages, autoScrollFlag, setAutoScrollFlag, chatListRef])
|
||||
|
||||
useEffect(() => {
|
||||
on(ClientEventName.SetIsReady, setIsReady)
|
||||
on(ClientEventName.SetHasSelection, setHasSelection)
|
||||
on(ClientEventName.AddMessageAction, addMessageAction)
|
||||
on(ClientEventName.UpdateMessageAction, updateMessageAction)
|
||||
on(ClientEventName.ClearMessageAction, clearMessageAction)
|
||||
|
||||
emit(ClientEventName.SyncState, null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="chat-root">
|
||||
<div ref={chatListRef} className="chat-list">
|
||||
{messages.map((m) => {
|
||||
return <MessageItem key={m.id} model={m} />
|
||||
})}
|
||||
</div>
|
||||
<div className="chat-input-area">
|
||||
<VSCodeTextArea
|
||||
style={{ width: '100%' }}
|
||||
rows={3}
|
||||
placeholder={`Talk about the ${hasSelection ? 'selected contents' : 'whole document'
|
||||
}...`}
|
||||
disabled={!isReady}
|
||||
value={prompt}
|
||||
onInput={(_e) => {
|
||||
const e = _e as React.ChangeEvent<HTMLTextAreaElement>
|
||||
setPrompt(e.target.value)
|
||||
}}
|
||||
onKeyDown={confirmShortcut.keyDownHandler}
|
||||
/>
|
||||
<VSCodeButton
|
||||
disabled={!isReady || prompt.length === 0}
|
||||
onClick={handleAskAction}
|
||||
>
|
||||
{`Ask (${confirmShortcut.label})`}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { useEventEmitter } from '../../hooks/use-emitter.hook'
|
||||
import { ClientEventName } from '../../../../index'
|
||||
|
||||
export interface MessageCodeBlockProps {
|
||||
contents: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export function MessageCodeBlock(props: MessageCodeBlockProps) {
|
||||
const { contents, language } = props
|
||||
const { emit } = useEventEmitter()
|
||||
|
||||
const handleCopyAction = useCallback(() => {
|
||||
navigator.clipboard.writeText(contents)
|
||||
}, [contents])
|
||||
|
||||
const handleInsertCodeSnippetAction = useCallback(async () => {
|
||||
emit(ClientEventName.InsertCodeSnippet, contents)
|
||||
}, [contents])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-msg-block-toolbar">
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
ariaLabel="Copy"
|
||||
title="Copy"
|
||||
onClick={handleCopyAction}
|
||||
>
|
||||
<span className="codicon codicon-copy"></span>
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
ariaLabel="Insert or Replace"
|
||||
title="Insert or Replace"
|
||||
onClick={handleInsertCodeSnippetAction}
|
||||
>
|
||||
<span className="codicon codicon-insert"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
useInlineStyles={false}
|
||||
codeTagProps={{ style: {} }}
|
||||
language={language}
|
||||
>
|
||||
{contents}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { MessageItemModel } from '../../types/chat'
|
||||
import { IndeterminateProgressBar } from './indeterminate-progress-bar'
|
||||
import { MessageCodeBlock } from './message-code-block'
|
||||
|
||||
export interface MessageItemProps {
|
||||
model: MessageItemModel
|
||||
}
|
||||
|
||||
export function MessageItem(props: MessageItemProps) {
|
||||
const { model } = props
|
||||
const { contents, isReply, isFinished } = model
|
||||
return (
|
||||
<div className={`chat-msg ${isReply ? 'reply' : ''}`}>
|
||||
<div className="chat-msg-contents">
|
||||
<MessageTextView
|
||||
contents={
|
||||
contents + (isReply && !isFinished ? '\u{258A}' : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{isReply && !isFinished ? <IndeterminateProgressBar /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageTextViewProps {
|
||||
contents: string
|
||||
}
|
||||
|
||||
function MessageTextView(props: MessageTextViewProps) {
|
||||
const { contents } = props
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
pre({ children, ...props }) {
|
||||
if (children.length !== 1) {
|
||||
// Not code block.
|
||||
return <pre {...props}>{children}</pre>
|
||||
}
|
||||
const child = children[0] as React.ReactElement
|
||||
const codeContents = child.props.children[0]
|
||||
const codeClassName = child.props.className
|
||||
const languageMatch
|
||||
= /language-(\w+)/.exec(codeClassName || '') || []
|
||||
return (
|
||||
<MessageCodeBlock
|
||||
contents={codeContents}
|
||||
language={languageMatch[1] || ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{contents}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
12
packages/gpt-runner-core/index.ts
Normal file
12
packages/gpt-runner-core/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const enum ClientEventName {
|
||||
SyncState = 'sync-state',
|
||||
SetIsReady = 'set-is-ready',
|
||||
SetHasSelection = 'set-has-selection',
|
||||
AddMessageAction = 'add-message-action',
|
||||
UpdateMessageAction = 'update-message-action',
|
||||
ClearMessageAction = 'clear-message-action',
|
||||
ConfirmPrompt = 'confirm-prompt',
|
||||
InsertCodeSnippet = 'insert-code-snippet',
|
||||
}
|
||||
|
||||
export * from './types'
|
||||
@@ -35,17 +35,32 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "pnpm dev:server",
|
||||
"dev": "pnpm dev:server & pnpm dev:client",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "esno src/server/src/index.ts",
|
||||
"build": "unbuild",
|
||||
"stub": "unbuild --stub"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@vscode/webview-ui-toolkit": "^1.2.2",
|
||||
"clsx": "^1.2.1",
|
||||
"eventemitter": "^0.3.3",
|
||||
"express": "^4.18.2",
|
||||
"langchain": "^0.0.75"
|
||||
"langchain": "^0.0.75",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"styled-components": "^6.0.0-rc.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
"unconfig": "^0.3.7"
|
||||
"@types/react": "^18.2.6",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.6",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"unconfig": "^0.3.7",
|
||||
"vite": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { ControllerConfig } from './../types'
|
||||
export interface ChatStreamReqParams {
|
||||
messages: BaseChatMessage[]
|
||||
prompt: string
|
||||
systemPrompt: string
|
||||
temperature: number
|
||||
systemPrompt?: string
|
||||
temperature?: number
|
||||
}
|
||||
export const chatgptControllers: ControllerConfig = {
|
||||
namespacePath: '/chatgpt',
|
||||
@@ -24,7 +24,7 @@ export const chatgptControllers: ControllerConfig = {
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
const { messages, prompt, systemPrompt, temperature } = req.body as ChatStreamReqParams
|
||||
const { messages, prompt, systemPrompt, temperature = 0.7 } = req.body as ChatStreamReqParams
|
||||
|
||||
const sendSuccessData = (options: Omit<SuccessResponse, 'type'>) => {
|
||||
return res.write(`data: ${JSON.stringify(buildSuccessResponse(options))}\n\n`)
|
||||
@@ -55,6 +55,9 @@ export const chatgptControllers: ControllerConfig = {
|
||||
console.log('error', error)
|
||||
}
|
||||
finally {
|
||||
sendSuccessData({
|
||||
data: '[DONE]',
|
||||
})
|
||||
res.end()
|
||||
}
|
||||
},
|
||||
|
||||
20
packages/gpt-runner-core/tsconfig.json
Normal file
20
packages/gpt-runner-core/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
4
packages/gpt-runner-core/types.ts
Normal file
4
packages/gpt-runner-core/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ClientConfig {
|
||||
pageName?: string
|
||||
baseServerUrl?: string
|
||||
}
|
||||
13
packages/gpt-runner-core/vite.config.ts
Normal file
13
packages/gpt-runner-core/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import React from '@vitejs/plugin-react'
|
||||
|
||||
const resolvePath = (...paths: string[]) => path.resolve(__dirname, ...paths)
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
root: resolvePath('./client'),
|
||||
plugins: [
|
||||
React(),
|
||||
],
|
||||
})
|
||||
1140
pnpm-lock.yaml
generated
1140
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user