diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e1d3f0b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Matches multiple files with brace expansion notation +# Set default charset +[*.{js,tsx}] +charset = utf-8 +indent_style = space +indent_size = 2 + + +# Matches the exact files either package.json or .travis.yml +[{package.json,.travis.yml}] +indent_style = space +indent_size = 2 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..9d8ea2c --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,28 @@ +{ + "extends": [ + "@antfu", + "plugin:react-hooks/recommended" + ], + "rules": { + "@typescript-eslint/consistent-type-definitions": [ + "error", + "type" + ], + "no-console": "off", + "indent": "off", + "@typescript-eslint/indent": [ + "error", + 2, + { + "SwitchCase": 1, + "flatTernaryExpressions": false, + "ignoredNodes": [ + "PropertyDefinition[decorators]", + "TSUnionType", + "FunctionExpression[params]:has(Identifier[decorators])" + ] + } + ], + "react-hooks/exhaustive-deps": "warn" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c01328 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# npm +package-lock.json + +# yarn +.pnp.cjs +.pnp.loader.mjs +.yarn/ +yarn.lock +.yarnrc.yml + +# pmpm +pnpm-lock.yaml \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..9e36291 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.1.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev", + "serverReadyAction": { + "pattern": "started server on .+, url: (https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e35fdc6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,32 @@ +{ + "typescript.tsdk": ".yarn/cache/typescript-patch-72dc6f164f-ab417a2f39.zip/node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "prettier.enable": false, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "[python]": { + "editor.formatOnType": true + }, + "[html]": { + "editor.defaultFormatter": "vscode.html-language-features" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "i18n-ally.localesPaths": [ + "i18n", + "i18n/lang", + "app/api/messages" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a74ce78 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Text Generator Web App Template +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Config App +Config app in `config/index.ts`.Please config: +- APP_ID +- API_KEY + +More config: +```js +export const APP_INFO: AppInfo = { + "title": 'Chat APP', + "description": '', + "copyright": '', + "privacy_policy": '', + "default_language": 'zh-Hans' +} + +export const isShowPrompt = true +export const promptTemplate = '' +``` + +## Getting Started +First, install dependencies: +```bash +npm install +# or +yarn +# or +pnpm install +``` + +Then, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/app/api/completion-messages/route.ts b/app/api/completion-messages/route.ts new file mode 100644 index 0000000..df3f5d6 --- /dev/null +++ b/app/api/completion-messages/route.ts @@ -0,0 +1,16 @@ +import { type NextRequest } from 'next/server' +import { OpenAIStream } from '@/app/api/utils/stream' +import { getInfo, client } from '@/app/api/utils/common' + +export async function POST(request: NextRequest) { + const body = await request.json() + const { + inputs, + query, + response_mode: responseMode + } = body + const { user } = getInfo(request); + const res = await client.createCompletionMessage(inputs, query, user, responseMode) + const stream = await OpenAIStream(res as any) + return new Response(stream as any) +} \ No newline at end of file diff --git a/app/api/messages/[messageId]/feedbacks/route.ts b/app/api/messages/[messageId]/feedbacks/route.ts new file mode 100644 index 0000000..e97aca0 --- /dev/null +++ b/app/api/messages/[messageId]/feedbacks/route.ts @@ -0,0 +1,20 @@ +import { type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getInfo, client } from '@/app/api/utils/common' + +export async function POST(request: NextRequest, { params }: { + params: { messageId: string } +}) { + const body = await request.json() + const { + rating + } = body + const { messageId } = params + const { user } = getInfo(request); + try { + const { data } = await client.messageFeedback(messageId, rating, user) + return NextResponse.json(data) + } catch (e) { + return NextResponse.json(e) + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 0000000..57a027c --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,13 @@ +import { type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getInfo, setSession, client } from '@/app/api/utils/common' + +export async function GET(request: NextRequest) { + const { sessionId, user } = getInfo(request); + const { searchParams } = new URL(request.url); + const conversationId = searchParams.get('conversation_id') + const { data }: any = await client.getConversationMessages(user, conversationId as string); + return NextResponse.json(data, { + headers: setSession(sessionId) + }) +} \ No newline at end of file diff --git a/app/api/parameters/route.ts b/app/api/parameters/route.ts new file mode 100644 index 0000000..1c1d917 --- /dev/null +++ b/app/api/parameters/route.ts @@ -0,0 +1,11 @@ +import { type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getInfo, setSession, client } from '@/app/api/utils/common' + +export async function GET(request: NextRequest) { + const { sessionId, user } = getInfo(request); + const { data } = await client.getApplicationParameters(user); + return NextResponse.json(data as object, { + headers: setSession(sessionId) + }) +} \ No newline at end of file diff --git a/app/api/utils/common.ts b/app/api/utils/common.ts new file mode 100644 index 0000000..6ddea49 --- /dev/null +++ b/app/api/utils/common.ts @@ -0,0 +1,21 @@ +import { type NextRequest } from 'next/server' +import { APP_ID, API_KEY } from '@/config' +import { CompletionClient } from 'langgenius-client' +import uuid from 'uuid' + +const userPrefix = `user_${APP_ID}:`; + +export const getInfo = (request: NextRequest) => { + const sessionId = request.cookies.get('session_id')?.value || uuid.v4(); + const user = userPrefix + sessionId; + return { + sessionId, + user + } +} + +export const setSession = (sessionId: string) => { + return { 'Set-Cookie': `session_id=${sessionId}` } +} + +export const client = new CompletionClient(API_KEY) \ No newline at end of file diff --git a/app/api/utils/stream.ts b/app/api/utils/stream.ts new file mode 100644 index 0000000..2da1359 --- /dev/null +++ b/app/api/utils/stream.ts @@ -0,0 +1,25 @@ +export async function OpenAIStream(res: { body: any }) { + const reader = res.body.getReader(); + + const stream = new ReadableStream({ + // https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams + // https://github.com/whichlight/chatgpt-api-streaming/blob/master/pages/api/OpenAIStream.ts + start(controller) { + return pump(); + function pump() { + return reader.read().then(({ done, value }: any) => { + // When no more data needs to be consumed, close the stream + if (done) { + controller.close(); + return; + } + // Enqueue the next data chunk into our target stream + controller.enqueue(value); + return pump(); + }); + } + }, + }); + + return stream; +} \ No newline at end of file diff --git a/app/components/app-unavailable.tsx b/app/components/app-unavailable.tsx new file mode 100644 index 0000000..ce4d7c7 --- /dev/null +++ b/app/components/app-unavailable.tsx @@ -0,0 +1,31 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' + +type IAppUnavailableProps = { + isUnknwonReason: boolean + errMessage?: string +} + +const AppUnavailable: FC = ({ + isUnknwonReason, + errMessage, +}) => { + const { t } = useTranslation() + let message = errMessage + if (!errMessage) { + message = (isUnknwonReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string + } + + return ( +
+

{(errMessage || isUnknwonReason) ? 500 : 404}

+
{message}
+
+ ) +} +export default React.memo(AppUnavailable) diff --git a/app/components/base/app-icon/index.tsx b/app/components/base/app-icon/index.tsx new file mode 100644 index 0000000..b70991b --- /dev/null +++ b/app/components/base/app-icon/index.tsx @@ -0,0 +1,36 @@ +import type { FC } from 'react' +import classNames from 'classnames' +import style from './style.module.css' + +export type AppIconProps = { + size?: 'tiny' | 'small' | 'medium' | 'large' + rounded?: boolean + icon?: string + background?: string + className?: string +} + +const AppIcon: FC = ({ + size = 'medium', + rounded = false, + background, + className, +}) => { + return ( + + 🤖 + + ) +} + +export default AppIcon diff --git a/app/components/base/app-icon/style.module.css b/app/components/base/app-icon/style.module.css new file mode 100644 index 0000000..43098fd --- /dev/null +++ b/app/components/base/app-icon/style.module.css @@ -0,0 +1,15 @@ +.appIcon { + @apply flex items-center justify-center relative w-9 h-9 text-lg bg-teal-100 rounded-lg grow-0 shrink-0; +} +.appIcon.large { + @apply w-10 h-10; +} +.appIcon.small { + @apply w-8 h-8; +} +.appIcon.tiny { + @apply w-6 h-6 text-base; +} +.appIcon.rounded { + @apply rounded-full; +} diff --git a/app/components/base/button/index.tsx b/app/components/base/button/index.tsx new file mode 100644 index 0000000..33aebb6 --- /dev/null +++ b/app/components/base/button/index.tsx @@ -0,0 +1,44 @@ +import type { FC, MouseEventHandler } from 'react' +import React from 'react' +import Spinner from '@/app/components/base/spinner' + +export type IButtonProps = { + type?: string + className?: string + disabled?: boolean + loading?: boolean + children: React.ReactNode + onClick?: MouseEventHandler +} + +const Button: FC = ({ + type, + disabled, + children, + className, + onClick, + loading = false, +}) => { + let style = 'cursor-pointer' + switch (type) { + case 'primary': + style = (disabled || loading) ? 'bg-primary-600/75 cursor-not-allowed text-white' : 'bg-primary-600 hover:bg-primary-600/75 hover:shadow-md cursor-pointer text-white hover:shadow-sm' + break + default: + style = disabled ? 'border-solid border border-gray-200 bg-gray-200 cursor-not-allowed text-gray-800' : 'border-solid border border-gray-200 cursor-pointer text-gray-500 hover:bg-white hover:shadow-sm hover:border-gray-300' + break + } + + return ( +
+ {children} + {/* Spinner is hidden when loading is false */} + +
+ ) +} + +export default React.memo(Button) diff --git a/app/components/base/loading/index.tsx b/app/components/base/loading/index.tsx new file mode 100644 index 0000000..c6c4800 --- /dev/null +++ b/app/components/base/loading/index.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import './style.css' + +type ILoadingProps = { + type?: 'area' | 'app' +} +const Loading = ( + { type = 'area' }: ILoadingProps = { type: 'area' }, +) => { + return ( +
+ + + + + + + + + + + + + + +
+ ) +} + +export default Loading diff --git a/app/components/base/loading/style.css b/app/components/base/loading/style.css new file mode 100644 index 0000000..40402e1 --- /dev/null +++ b/app/components/base/loading/style.css @@ -0,0 +1,41 @@ +.spin-animation path { + animation: custom 2s linear infinite; +} + +@keyframes custom { + 0% { + opacity: 0; + } + + 25% { + opacity: 0.1; + } + + 50% { + opacity: 0.2; + } + + 75% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} + +.spin-animation path:nth-child(1) { + animation-delay: 0s; +} + +.spin-animation path:nth-child(2) { + animation-delay: 0.5s; +} + +.spin-animation path:nth-child(3) { + animation-delay: 1s; +} + +.spin-animation path:nth-child(4) { + animation-delay: 1.5s; +} \ No newline at end of file diff --git a/app/components/base/markdown.tsx b/app/components/base/markdown.tsx new file mode 100644 index 0000000..aac5f7b --- /dev/null +++ b/app/components/base/markdown.tsx @@ -0,0 +1,45 @@ +import ReactMarkdown from 'react-markdown' +import 'katex/dist/katex.min.css' +import RemarkMath from 'remark-math' +import RemarkBreaks from 'remark-breaks' +import RehypeKatex from 'rehype-katex' +import RemarkGfm from 'remark-gfm' +import SyntaxHighlighter from 'react-syntax-highlighter' +import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs' + +export function Markdown(props: { content: string }) { + return ( +
+ + ) + : ( + + {children} + + ) + }, + }} + linkTarget={'_blank'} + > + {props.content} + +
+ ) +} diff --git a/app/components/base/select/index.tsx b/app/components/base/select/index.tsx new file mode 100644 index 0000000..6f90516 --- /dev/null +++ b/app/components/base/select/index.tsx @@ -0,0 +1,216 @@ +'use client' +import type { FC } from 'react' +import React, { Fragment, useEffect, useState } from 'react' +import { Combobox, Listbox, Transition } from '@headlessui/react' +import classNames from 'classnames' +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/20/solid' + +const defaultItems = [ + { value: 1, name: 'option1' }, + { value: 2, name: 'option2' }, + { value: 3, name: 'option3' }, + { value: 4, name: 'option4' }, + { value: 5, name: 'option5' }, + { value: 6, name: 'option6' }, + { value: 7, name: 'option7' }, +] + +export type Item = { + value: number | string + name: string +} + +export type ISelectProps = { + className?: string + items?: Item[] + defaultValue?: number | string + disabled?: boolean + onSelect: (value: Item) => void + allowSearch?: boolean + bgClassName?: string +} +const Select: FC = ({ + className, + items = defaultItems, + defaultValue = 1, + disabled = false, + onSelect, + allowSearch = true, + bgClassName = 'bg-gray-100', +}) => { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + + const [selectedItem, setSelectedItem] = useState(null) + useEffect(() => { + let defaultSelect = null + const existed = items.find((item: Item) => item.value === defaultValue) + if (existed) + defaultSelect = existed + + setSelectedItem(defaultSelect) + }, [defaultValue]) + + const filteredItems: Item[] + = query === '' + ? items + : items.filter((item) => { + return item.name.toLowerCase().includes(query.toLowerCase()) + }) + + return ( + { + if (!disabled) { + setSelectedItem(value) + setOpen(false) + onSelect(value) + } + }}> +
+
+ {allowSearch + ? { + if (!disabled) + setQuery(event.target.value) + }} + displayValue={(item: Item) => item?.name} + /> + : { + if (!disabled) + setOpen(!open) + } + } className={`flex items-center h-9 w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200`}> + {selectedItem?.name} + } + { + if (!disabled) + setOpen(!open) + } + }> + {open ? : } + +
+ + {filteredItems.length > 0 && ( + + {filteredItems.map((item: Item) => ( + + classNames( + 'relative cursor-default select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700', + active ? 'bg-gray-100' : '', + ) + } + > + {({ /* active, */ selected }) => ( + <> + {item.name} + {selected && ( + + + )} + + )} + + ))} + + )} +
+
+ ) +} + +const SimpleSelect: FC = ({ + className, + items = defaultItems, + defaultValue = 1, + disabled = false, + onSelect, +}) => { + const [selectedItem, setSelectedItem] = useState(null) + useEffect(() => { + let defaultSelect = null + const existed = items.find((item: Item) => item.value === defaultValue) + if (existed) + defaultSelect = existed + + setSelectedItem(defaultSelect) + }, [defaultValue]) + + return ( + { + if (!disabled) { + setSelectedItem(value) + onSelect(value) + } + }} + > +
+ + {selectedItem?.name} + + + + + + {items.map((item: Item) => ( + + `relative cursor-pointer select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700 ${active ? 'bg-gray-100' : '' + }` + } + value={item} + disabled={disabled} + > + {({ /* active, */ selected }) => ( + <> + {item.name} + {selected && ( + + + )} + + )} + + ))} + + +
+
+ ) +} +export { SimpleSelect } +export default React.memo(Select) diff --git a/app/components/base/spinner/index.tsx b/app/components/base/spinner/index.tsx new file mode 100644 index 0000000..53de4ed --- /dev/null +++ b/app/components/base/spinner/index.tsx @@ -0,0 +1,24 @@ +import type { FC } from 'react' +import React from 'react' + +type Props = { + loading?: boolean + className?: string + children?: React.ReactNode | string +} + +const Spinner: FC = ({ loading = false, children, className }) => { + return ( +
+ Loading... + {children} +
+ ) +} + +export default Spinner diff --git a/app/components/base/toast/index.tsx b/app/components/base/toast/index.tsx new file mode 100644 index 0000000..328f9b4 --- /dev/null +++ b/app/components/base/toast/index.tsx @@ -0,0 +1,131 @@ +'use client' +import classNames from 'classnames' +import type { ReactNode } from 'react' +import React, { useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { + CheckCircleIcon, + ExclamationTriangleIcon, + InformationCircleIcon, + XCircleIcon, +} from '@heroicons/react/20/solid' +import { createContext } from 'use-context-selector' + +export type IToastProps = { + type?: 'success' | 'error' | 'warning' | 'info' + duration?: number + message: string + children?: ReactNode + onClose?: () => void +} +type IToastContext = { + notify: (props: IToastProps) => void +} +const defaultDuring = 3000 + +export const ToastContext = createContext({} as IToastContext) +const Toast = ({ + type = 'info', + duration, + message, + children, +}: IToastProps) => { + // sometimes message is react node array. Not handle it. + if (typeof message !== 'string') + return null + + return
+
+
+ {type === 'success' &&
+
+

{message}

+ {children &&
+ {children} +
+ } +
+
+
+} + +export const ToastProvider = ({ + children, +}: { + children: ReactNode +}) => { + const placeholder: IToastProps = { + type: 'info', + message: 'Toast message', + duration: 3000, + } + const [params, setParams] = React.useState(placeholder) + + const [mounted, setMounted] = useState(false) + + useEffect(() => { + if (mounted) { + setTimeout(() => { + setMounted(false) + }, params.duration || defaultDuring) + } + }, [mounted]) + + return { + setMounted(true) + setParams(props) + }, + }}> + {mounted && } + {children} + +} + +Toast.notify = ({ + type, + message, + duration, +}: Pick) => { + if (typeof window === 'object') { + const holder = document.createElement('div') + const root = createRoot(holder) + + root.render() + document.body.appendChild(holder) + setTimeout(() => { + if (holder) + holder.remove() + }, duration || defaultDuring) + } +} + +export default Toast diff --git a/app/components/base/toast/style.module.css b/app/components/base/toast/style.module.css new file mode 100644 index 0000000..305fde4 --- /dev/null +++ b/app/components/base/toast/style.module.css @@ -0,0 +1,43 @@ +.toast { + display: flex; + justify-content: center; + align-items: center; + position: fixed; + width: 1.84rem; + height: 1.80rem; + left: 50%; + top: 50%; + transform: translateX(-50%) translateY(-50%); + background: #000000; + box-shadow: 0 -.04rem .1rem 1px rgba(255, 255, 255, 0.1); + border-radius: .1rem .1rem .1rem .1rem; +} + +.main { + width: 2rem; +} + +.icon { + margin-bottom: .2rem; + height: .4rem; + background: center center no-repeat; + background-size: contain; +} + +/* .success { + background-image: url('./icons/success.svg'); +} + +.warning { + background-image: url('./icons/warning.svg'); +} + +.error { + background-image: url('./icons/error.svg'); +} */ + +.text { + text-align: center; + font-size: .2rem; + color: rgba(255, 255, 255, 0.86); +} \ No newline at end of file diff --git a/app/components/base/tooltip/index.tsx b/app/components/base/tooltip/index.tsx new file mode 100644 index 0000000..610f17b --- /dev/null +++ b/app/components/base/tooltip/index.tsx @@ -0,0 +1,46 @@ +'use client' +import classNames from 'classnames' +import type { FC } from 'react' +import React from 'react' +import { Tooltip as ReactTooltip } from 'react-tooltip' // fixed version to 5.8.3 https://github.com/ReactTooltip/react-tooltip/issues/972 +import 'react-tooltip/dist/react-tooltip.css' + +type TooltipProps = { + selector: string + content?: string + htmlContent?: React.ReactNode + className?: string // This should use !impornant to override the default styles eg: '!bg-white' + position?: 'top' | 'right' | 'bottom' | 'left' + clickable?: boolean + children: React.ReactNode +} + +const Tooltip: FC = ({ + selector, + content, + position = 'top', + children, + htmlContent, + className, + clickable, +}) => { + return ( +
+ {React.cloneElement(children as React.ReactElement, { + 'data-tooltip-id': selector, + }) + } + + {htmlContent && htmlContent} + +
+ ) +} + +export default Tooltip diff --git a/app/components/config-scence/index.tsx b/app/components/config-scence/index.tsx new file mode 100644 index 0000000..6b92dec --- /dev/null +++ b/app/components/config-scence/index.tsx @@ -0,0 +1,98 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { + PlayIcon, +} from '@heroicons/react/24/solid' +import Select from '@/app/components/base/select' +import type { PromptConfig, AppInfo } from '@/types/app' +import Button from '@/app/components/base/button' +import { DEFAULT_VALUE_MAX_LEN } from '@/config' + +export type IConfigSenceProps = { + appInfo: AppInfo + promptConfig: PromptConfig + inputs: Record + onInputsChange: (inputs: Record) => void + query: string + onQueryChange: (query: string) => void + onSend: () => void +} +const ConfigSence: FC = ({ + appInfo, + promptConfig, + inputs, + onInputsChange, + query, + onQueryChange, + onSend, +}) => { + const { t } = useTranslation() + + return ( +
+
+ {/* title & description */} +
👏 {t('app.common.welcome')} {appInfo.title}
+
{appInfo.description}
+
+
+ {/* input form */} +
+ {promptConfig.prompt_variables.map(item => ( +
+ + {item.type === 'select' ? ( + { onInputsChange({ ...inputs, [item.key]: e.target.value }) }} + maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN} + /> + )} +
+ ))} +
+ +
+
+ +
+
+
+ {query.length} +
+ +
+
+
+
+
+
+ ) +} +export default React.memo(ConfigSence) diff --git a/app/components/header.tsx b/app/components/header.tsx new file mode 100644 index 0000000..2513696 --- /dev/null +++ b/app/components/header.tsx @@ -0,0 +1,48 @@ +import type { FC } from 'react' +import React from 'react' +import { + Bars3Icon, + PencilSquareIcon, +} from '@heroicons/react/24/solid' +import AppIcon from '@/app/components/base/app-icon' +export type IHeaderProps = { + title: string + isMobile?: boolean + onShowSideBar?: () => void + onCreateNewChat?: () => void +} +const Header: FC = ({ + title, + isMobile, + onShowSideBar, + onCreateNewChat, +}) => { + return ( +
+ {isMobile + ? ( +
onShowSideBar?.()} + > + +
+ ) + :
} +
+ +
{title}
+
+ {isMobile + ? ( +
onCreateNewChat?.()} + > + +
) + :
} +
+ ) +} + +export default React.memo(Header) diff --git a/app/components/index.tsx b/app/components/index.tsx new file mode 100644 index 0000000..1ca5182 --- /dev/null +++ b/app/components/index.tsx @@ -0,0 +1,178 @@ +'use client' +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useBoolean } from 'ahooks' +import Header from './header' +import ConfigScence from './config-scence' +import NoData from './no-data' +import Result from './result' +import { fetchAppParams, sendCompletionMessage, updateFeedback } from '@/service' +import Toast from '@/app/components/base/toast' +import { Feedbacktype, PromptConfig } from '@/types/app' +import { changeLanguage } from '@/i18n/i18next-config' +import Loading from '@/app/components/base/loading' +import AppUnavailable from '@/app/components/app-unavailable' +import { APP_ID, API_KEY, APP_INFO } from '@/config' + +const TextGeneration = () => { + const { t } = useTranslation() + + /* + * app info + */ + const hasSetAppConfig = APP_ID && API_KEY + const [appUnavailable, setAppUnavailable] = useState(false) + const [isUnknwonReason, setIsUnknwonReason] = useState(false) + + const [inputs, setInputs] = useState>({}) + const [promptConfig, setPromptConfig] = useState(null) + const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false) + const [query, setQuery] = useState('') + const [completionRes, setCompletionRes] = useState('') + const { notify } = Toast + const isNoData = !completionRes + const [messageId, setMessageId] = useState(null) + const [feedback, setFeedback] = useState({ + rating: null + }) + + const handleFeedback = async (feedback: Feedbacktype) => { + await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }) + setFeedback(feedback) + } + + const logError = (message: string) => { + notify({ type: 'error', message }) + } + + const checkCanSend = () => { + const inputLens = Object.values(inputs).length + const promptVariablesLens = promptConfig?.prompt_variables.length || 0 + + const emytyInput = inputLens < promptVariablesLens || Object.values(inputs).find(v => !v) + if (emytyInput) { + logError(t('app.errorMessage.valueOfVarRequired')) + return false + } + return true + } + + const handleSend = async () => { + if (isResponsing) { + notify({ type: 'info', message: t('app.errorMessage.waitForResponse') }) + return false + } + + if (!checkCanSend()) + return + + if (!query) { + logError(t('app.errorMessage.queryRequired')) + return false + } + + + const data = { + inputs, + query, + } + + setMessageId(null) + setFeedback({ + rating: null + }) + setCompletionRes('') + + const res: string[] = [] + let tempMessageId = '' + + setResponsingTrue() + sendCompletionMessage(data, { + onData: (data: string, _isFirstMessage: boolean, { messageId }: any) => { + tempMessageId = messageId + res.push(data) + setCompletionRes(res.join('')) + }, + onCompleted: () => { + setResponsingFalse() + setMessageId(tempMessageId) + }, + onError() { + setResponsingFalse() + } + }) + } + + useEffect(() => { + if (!hasSetAppConfig) { + setAppUnavailable(true) + return + } + (async () => { + try { + + changeLanguage(APP_INFO.default_language) + + const { variables: prompt_variables }: any = await fetchAppParams() + + setPromptConfig({ + prompt_template: '', + prompt_variables, + } as PromptConfig) + } + catch (e: any) { + if (e.status === 404) { + setAppUnavailable(true) + } + else { + setIsUnknwonReason(true) + setAppUnavailable(true) + } + } + })() + }, []) + + useEffect(() => { + if (APP_INFO?.title) + document.title = `${APP_INFO.title} - Powered by LangGenius` + }, [APP_INFO?.title]) + + if (appUnavailable) + return + + if (!APP_INFO || !promptConfig) + return + + + return ( + <> +
+
+
+ + +
+ {isNoData + ? + : ( +
+ + {/* */} +
) + } +
+
+
+ + ) +} + +export default TextGeneration diff --git a/app/components/no-data/index.tsx b/app/components/no-data/index.tsx new file mode 100644 index 0000000..0800c16 --- /dev/null +++ b/app/components/no-data/index.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { + DocumentTextIcon, +} from '@heroicons/react/24/outline' + +export type INoDataProps = {} +const NoData: FC = () => { + const { t } = useTranslation() + return ( +
+ +
+ {t('app.generation.noData')} +
+
+ ) +} +export default React.memo(NoData) diff --git a/app/components/result/header.tsx b/app/components/result/header.tsx new file mode 100644 index 0000000..94417d6 --- /dev/null +++ b/app/components/result/header.tsx @@ -0,0 +1,116 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { ClipboardDocumentIcon, HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline' +import type { Feedbacktype } from '@/types/app' +import Button from '@/app/components/base/button' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' + +// import useCopyToClipboard from '@/hooks/use-copy-to-clipboard' +import copy from 'copy-to-clipboard' +type IResultHeaderProps = { + result: string + showFeedback: boolean + feedback: Feedbacktype + onFeedback: (feedback: Feedbacktype) => void +} + +const Header: FC = ({ + feedback, + showFeedback, + onFeedback, + result, +}) => { + const { t } = useTranslation() + return ( +
+
{t('app.generation.resultTitle')}
+
+ + + {showFeedback && feedback.rating && feedback.rating === 'like' && ( + +
{ + onFeedback({ + rating: null + }) + }} + className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-primary-600 border border-primary-200 bg-primary-100 hover:border-primary-300 hover:bg-primary-200'> + +
+
+ )} + + {showFeedback && feedback.rating && feedback.rating === 'dislike' && ( + +
{ + onFeedback({ + rating: null + }) + }} + className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-red-600 border border-red-200 bg-red-100 hover:border-red-300 hover:bg-red-200'> + +
+
+ )} + + {showFeedback && !feedback.rating && ( +
+ +
{ + onFeedback({ + rating: 'like' + }) + }} + className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'> + +
+
+ +
{ + onFeedback({ + rating: 'dislike' + }) + }} + className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'> + +
+
+
+ )} +
+ +
+ ) +} + +export default React.memo(Header) diff --git a/app/components/result/index.tsx b/app/components/result/index.tsx new file mode 100644 index 0000000..204940b --- /dev/null +++ b/app/components/result/index.tsx @@ -0,0 +1,33 @@ +import type { FC } from 'react' +import React from 'react' +import Header from './header' +import type { Feedbacktype } from '@/types/app' +import { Markdown } from '@/app/components/base/markdown' + +export type IResultProps = { + content: string + showFeedback: boolean + feedback: Feedbacktype + onFeedback: (feedback: Feedbacktype) => void +} +const Result: FC = ({ + content, + showFeedback, + feedback, + onFeedback +}) => { + return ( +
+
+
+ +
+
+ ) +} +export default React.memo(Result) diff --git a/app/global.d.ts b/app/global.d.ts new file mode 100644 index 0000000..337e84b --- /dev/null +++ b/app/global.d.ts @@ -0,0 +1,2 @@ +declare module 'langgenius-client'; +declare module 'uuid'; diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..7092434 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,25 @@ +import { getLocaleOnServer } from '@/i18n/server' + +import './styles/globals.css' +import './styles/markdown.scss' + +const LocaleLayout = ({ + children, +}: { + children: React.ReactNode +}) => { + const locale = getLocaleOnServer() + return ( + + +
+
+ {children} +
+
+ + + ) +} + +export default LocaleLayout diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..490bdcd --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +import Main from '@/app/components' + +const App = () => { + return ( +
+ ) +} + +export default React.memo(App) diff --git a/app/styles/globals.css b/app/styles/globals.css new file mode 100644 index 0000000..f48bf01 --- /dev/null +++ b/app/styles/globals.css @@ -0,0 +1,128 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --max-width: 1100px; + --border-radius: 12px; + --font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", + "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", + "Fira Mono", "Droid Sans Mono", "Courier New", monospace; + + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; + + --primary-glow: conic-gradient(from 180deg at 50% 50%, + #16abff33 0deg, + #0885ff33 55deg, + #54d6ff33 120deg, + #0071ff33 160deg, + transparent 360deg); + --secondary-glow: radial-gradient(rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0)); + + --tile-start-rgb: 239, 245, 249; + --tile-end-rgb: 228, 232, 233; + --tile-border: conic-gradient(#00000080, + #00000040, + #00000030, + #00000020, + #00000010, + #00000010, + #00000080); + + --callout-rgb: 238, 240, 241; + --callout-border-rgb: 172, 175, 176; + --card-rgb: 180, 185, 188; + --card-border-rgb: 131, 134, 135; +} + +/* @media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + + --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); + --secondary-glow: linear-gradient(to bottom right, + rgba(1, 65, 255, 0), + rgba(1, 65, 255, 0), + rgba(1, 65, 255, 0.3)); + + --tile-start-rgb: 2, 13, 46; + --tile-end-rgb: 2, 5, 19; + --tile-border: conic-gradient(#ffffff80, + #ffffff40, + #ffffff30, + #ffffff20, + #ffffff10, + #ffffff10, + #ffffff80); + + --callout-rgb: 20, 20, 20; + --callout-border-rgb: 108, 108, 108; + --card-rgb: 100, 100, 100; + --card-border-rgb: 200, 200, 200; + } +} */ + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +body { + color: rgb(var(--foreground-rgb)); + /* background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); */ +} + +a { + color: inherit; + text-decoration: none; +} + +/* @media (prefers-color-scheme: dark) { + html { + color-scheme: dark; + } +} */ + +/* CSS Utils */ +.h1 { + padding-bottom: 1.5rem; + line-height: 1.5; + font-size: 1.125rem; + color: #111928; +} + +.h2 { + font-size: 14px; + font-weight: 500; + color: #111928; + line-height: 1.5; +} + +.link { + @apply text-blue-600 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out; +} + +.text-gradient { + background: linear-gradient(91.58deg, #2250F2 -29.55%, #0EBCF3 75.22%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-fill-color: transparent; +} diff --git a/app/styles/markdown.scss b/app/styles/markdown.scss new file mode 100644 index 0000000..229c005 --- /dev/null +++ b/app/styles/markdown.scss @@ -0,0 +1,1041 @@ +@mixin light { + color-scheme: light; + --color-prettylights-syntax-comment: #6e7781; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-entity: #8250df; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #116329; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-fg-default: #24292f; + --color-fg-muted: #57606a; + --color-fg-subtle: #6e7781; + --color-canvas-default: transparent; + --color-canvas-subtle: #f6f8fa; + --color-border-default: #d0d7de; + --color-border-muted: hsla(210, 18%, 87%, 1); + --color-neutral-muted: rgba(175, 184, 193, 0.2); + --color-accent-fg: #0969da; + --color-accent-emphasis: #0969da; + --color-attention-subtle: #fff8c5; + --color-danger-fg: #cf222e; +} + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: #101828; + background-color: var(--color-canvas-default); + font-size: 14px; + font-weight: 400; + line-height: 1.5; + word-wrap: break-word; +} + +.light { + @include light; +} + +:root { + @include light; +} + +@media (prefers-color-scheme: light) { + :root { + @include light; + } +} + +.markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: " "; + display: inline-block; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); +} + +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + color: var(--color-accent-fg); + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body mark { + background-color: var(--color-attention-subtle); + color: var(--color-fg-default); +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; + background-color: var(--color-canvas-default); +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em 40px; +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: 1px solid var(--color-border-muted); + height: 0.25em; + padding: 0; + margin: 24px 0; + background-color: var(--color-border-default); + border: 0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body [type="button"], +.markdown-body [type="reset"], +.markdown-body [type="submit"] { + -webkit-appearance: button; +} + +.markdown-body [type="checkbox"], +.markdown-body [type="radio"] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type="number"]::-webkit-inner-spin-button, +.markdown-body [type="number"]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type="search"]::-webkit-search-cancel-button, +.markdown-body [type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: 0.54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: var(--color-fg-subtle); + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body details:not([open])>*:not(summary) { + display: none !important; +} + +.markdown-body a:focus, +.markdown-body [role="button"]:focus, +.markdown-body input[type="radio"]:focus, +.markdown-body input[type="checkbox"]:focus { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role="button"]:focus:not(:focus-visible), +.markdown-body input[type="radio"]:focus:not(:focus-visible), +.markdown-body input[type="checkbox"]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role="button"]:focus-visible, +.markdown-body input[type="radio"]:focus-visible, +.markdown-body input[type="checkbox"]:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type="radio"]:focus, +.markdown-body input[type="radio"]:focus-visible, +.markdown-body input[type="checkbox"]:focus, +.markdown-body input[type="checkbox"]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, + Liberation Mono, monospace; + line-height: 10px; + color: var(--color-fg-default); + vertical-align: middle; + background-color: var(--color-canvas-subtle); + border: solid 1px var(--color-neutral-muted); + border-bottom-color: var(--color-neutral-muted); + border-radius: 6px; + box-shadow: inset 0 -1px 0 var(--color-neutral-muted); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: var(--base-text-weight-semibold, 600); + line-height: 1.25; +} + + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 8px; + border-left: 2px solid #2970FF; +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol { + list-style: decimal; +} + +.markdown-body ul { + list-style: disc; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, + Liberation Mono, monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, + Liberation Mono, monospace; + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body>*:first-child { + margin-top: 0 !important; +} + +.markdown-body>*:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: var(--color-danger-fg); +} + +.markdown-body .anchor { + float: left; + padding-right: 4px; + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body blockquote> :first-child { + margin-top: 0; +} + +.markdown-body blockquote> :last-child { + margin-bottom: 0; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: var(--color-fg-default); + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 0.2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div>ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li>p { + margin-top: 16px; +} + +.markdown-body li+li { + margin-top: 0.25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body table th { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--color-border-default); +} + +.markdown-body table tr { + background-color: var(--color-canvas-default); + border-top: 1px solid var(--color-border-muted); +} + +.markdown-body table tr:nth-child(2n) { + background-color: var(--color-canvas-subtle); +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align="right"] { + padding-left: 20px; +} + +.markdown-body img[align="left"] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame>span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid var(--color-border-default); +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: var(--color-fg-default); +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right>span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--color-neutral-muted); + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + background: #fff; + overflow: auto; + font-size: 85%; + line-height: 1.45; + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline-block; + max-width: 100%; + padding: 0; + margin: 0; + overflow-x: scroll; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px 8px 9px; + text-align: right; + background: var(--color-canvas-default); + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: var(--base-text-weight-semibold, 600); + background: var(--color-canvas-subtle); + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: var(--color-fg-muted); + border-top: 1px solid var(--color-border-default); +} + +.markdown-body .footnotes ol { + padding-left: 16px; +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: 16px; + margin-top: 16px; +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: -8px; + right: -8px; + bottom: -8px; + left: -24px; + pointer-events: none; + content: ""; + border: 2px solid var(--color-accent-emphasis); + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: var(--color-fg-default); +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body .pl-c { + color: var(--color-prettylights-syntax-comment); +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: var(--color-prettylights-syntax-entity); +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); +} + +.markdown-body .pl-ent { + color: var(--color-prettylights-syntax-entity-tag); +} + +.markdown-body .pl-k { + color: var(--color-prettylights-syntax-keyword); +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: var(--color-prettylights-syntax-variable); +} + +.markdown-body .pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); +} + +.markdown-body .pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); +} + +.markdown-body .pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); +} + +.markdown-body .pl-ml { + color: var(--color-prettylights-syntax-markup-list); +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); +} + +.markdown-body .pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); +} + +.markdown-body .pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); +} + +.markdown-body .pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); +} + +.markdown-body .pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); +} + +.markdown-body .pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); +} + +.markdown-body .pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); +} + +.markdown-body .pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); +} + +.markdown-body .pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: var(--base-text-weight-normal, 400); + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: var(--base-text-weight-normal, 400); +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: 4px; +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 0.2em 0.25em -1.4em; + vertical-align: middle; +} + +.markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em 0.25em 0.2em; +} + +.markdown-body .contains-task-list { + position: relative; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} \ No newline at end of file diff --git a/config/index.ts b/config/index.ts new file mode 100644 index 0000000..2258ab5 --- /dev/null +++ b/config/index.ts @@ -0,0 +1,17 @@ +import { AppInfo } from "@/types/app" +export const APP_ID = '' +export const API_KEY = '' + +export const APP_INFO: AppInfo = { + "title": 'Text Generator APP', + "description": 'App description', + "copyright": '', + "privacy_policy": '', + "default_language": 'zh-Hans' +} + +export const API_PREFIX = '/api'; + +export const LOCALE_COOKIE_NAME = 'locale' + +export const DEFAULT_VALUE_MAX_LEN = 48 diff --git a/i18n/client.ts b/i18n/client.ts new file mode 100644 index 0000000..98424d2 --- /dev/null +++ b/i18n/client.ts @@ -0,0 +1,18 @@ +import Cookies from 'js-cookie' +import type { Locale } from '.' +import { i18n } from '.' +import { LOCALE_COOKIE_NAME } from '@/config' +import { changeLanguage } from '@/i18n/i18next-config' + +// same logic as server +export const getLocaleOnClient = (): Locale => { + return Cookies.get(LOCALE_COOKIE_NAME) as Locale || i18n.defaultLocale +} + +export const setLocaleOnClient = (locale: Locale, notReload?: boolean) => { + Cookies.set(LOCALE_COOKIE_NAME, locale) + changeLanguage(locale) + if (!notReload) { + location.reload() + } +} diff --git a/i18n/i18next-config.ts b/i18n/i18next-config.ts new file mode 100644 index 0000000..9f949ae --- /dev/null +++ b/i18n/i18next-config.ts @@ -0,0 +1,38 @@ +'use client' +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import commonEn from './lang/common.en' +import commonZh from './lang/common.zh' +import appEn from './lang/app.en' +import appZh from './lang/app.zh' +import { Locale } from '.' + +const resources = { + 'en': { + translation: { + common: commonEn, + app: appEn, + }, + }, + 'zh-Hans': { + translation: { + common: commonZh, + app: appZh, + }, + }, +} + +i18n.use(initReactI18next) + // init i18next + // for all options read: https://www.i18next.com/overview/configuration-options + .init({ + lng: 'en', + fallbackLng: 'en', + // debug: true, + resources, + }) + +export const changeLanguage = (lan: Locale) => { + i18n.changeLanguage(lan) +} +export default i18n diff --git a/i18n/i18next-serverside-config.ts b/i18n/i18next-serverside-config.ts new file mode 100644 index 0000000..fe89475 --- /dev/null +++ b/i18n/i18next-serverside-config.ts @@ -0,0 +1,26 @@ +import { createInstance } from 'i18next' +import resourcesToBackend from 'i18next-resources-to-backend' +import { initReactI18next } from 'react-i18next/initReactI18next' +import { Locale } from '.' + +// https://locize.com/blog/next-13-app-dir-i18n/ +const initI18next = async (lng: Locale, ns: string) => { + const i18nInstance = createInstance() + await i18nInstance + .use(initReactI18next) + .use(resourcesToBackend((language: string, namespace: string) => import(`./lang/${namespace}.${language}.ts`))) + .init({ + lng: lng === 'zh-Hans' ? 'zh' : lng, + ns, + fallbackLng: 'en', + }) + return i18nInstance +} + +export async function useTranslation(lng: Locale, ns = '', options: Record = {}) { + const i18nextInstance = await initI18next(lng, ns) + return { + t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix), + i18n: i18nextInstance + } +} \ No newline at end of file diff --git a/i18n/index.ts b/i18n/index.ts new file mode 100644 index 0000000..914b8ae --- /dev/null +++ b/i18n/index.ts @@ -0,0 +1,6 @@ +export const i18n = { + defaultLocale: 'en', + locales: ['en', 'zh-Hans'], +} as const + +export type Locale = typeof i18n['locales'][number] diff --git a/i18n/lang/app.en.ts b/i18n/lang/app.en.ts new file mode 100644 index 0000000..b25dcc8 --- /dev/null +++ b/i18n/lang/app.en.ts @@ -0,0 +1,22 @@ +const translation = { + common: { + welcome: "Welcome to use", + appUnavailable: "App is unavailable", + appUnkonwError: "App is unavailable" + }, + generation: { + queryTitle: "Query content", + queryPlaceholder: "Write your query content...", + run: "RUN", + copy: "Copy", + resultTitle: "AI Completion", + noData: "AI will give you what you want here.", + }, + errorMessage: { + valueOfVarRequired: "Variables value can not be empty", + queryRequired: "Request text is required.", + waitForResponse: "Please wait for the response to the previous message to complete.", + }, +}; + +export default translation; diff --git a/i18n/lang/app.zh.ts b/i18n/lang/app.zh.ts new file mode 100644 index 0000000..6382568 --- /dev/null +++ b/i18n/lang/app.zh.ts @@ -0,0 +1,22 @@ +const translation = { + common: { + welcome: "欢迎使用", + appUnavailable: "应用不可用", + appUnkonwError: "应用不可用", + }, + generation: { + queryTitle: "查询内容", + queryPlaceholder: "请输入文本内容", + run: "运行", + copy: "拷贝", + resultTitle: "AI 智能书写", + noData: "AI 会在这里给你惊喜。", + }, + errorMessage: { + valueOfVarRequired: "变量值必填", + queryRequired: "主要文本必填", + waitForResponse: "请等待上条信息响应完成", + }, +}; + +export default translation; diff --git a/i18n/lang/common.en.ts b/i18n/lang/common.en.ts new file mode 100644 index 0000000..94bba75 --- /dev/null +++ b/i18n/lang/common.en.ts @@ -0,0 +1,22 @@ +const translation = { + api: { + success: 'Success', + saved: 'Saved', + create: 'Created', + }, + operation: { + confirm: 'Confirm', + cancel: 'Cancel', + clear: 'Clear', + save: 'Save', + edit: 'Edit', + refresh: 'Restart', + search: 'Search', + send: 'Send', + lineBreak: 'Line break', + like: 'like', + dislike: 'dislike', + } +} + +export default translation diff --git a/i18n/lang/common.zh.ts b/i18n/lang/common.zh.ts new file mode 100644 index 0000000..3a16a31 --- /dev/null +++ b/i18n/lang/common.zh.ts @@ -0,0 +1,22 @@ +const translation = { + api: { + success: '成功', + saved: '已保存', + create: '已创建', + }, + operation: { + confirm: '确认', + cancel: '取消', + clear: '清空', + save: '保存', + edit: '编辑', + refresh: '重新开始', + search: '搜索', + send: '发送', + lineBreak: '换行', + like: '赞同', + dislike: '反对', + } +} + +export default translation diff --git a/i18n/server.ts b/i18n/server.ts new file mode 100644 index 0000000..5b97004 --- /dev/null +++ b/i18n/server.ts @@ -0,0 +1,29 @@ +import 'server-only' + +import { cookies, headers } from 'next/headers' +import Negotiator from 'negotiator' +import { match } from '@formatjs/intl-localematcher' +import type { Locale } from '.' +import { i18n } from '.' + +export const getLocaleOnServer = (): Locale => { + // @ts-expect-error locales are readonly + const locales: string[] = i18n.locales + + let languages: string[] | undefined + // get locale from cookie + const localeCookie = cookies().get('locale') + languages = localeCookie?.value ? [localeCookie.value] : [] + + if (!languages.length) { + // Negotiator expects plain object so we need to transform headers + const negotiatorHeaders: Record = {} + headers().forEach((value, key) => (negotiatorHeaders[key] = value)) + // Use negotiator and intl-localematcher to get best locale + languages = new Negotiator({ headers: negotiatorHeaders }).languages() + } + + // match locale + const matchedLocale = match(languages, locales, i18n.defaultLocale) as Locale + return matchedLocale +} diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..161c84c --- /dev/null +++ b/next.config.js @@ -0,0 +1,21 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + productionBrowserSourceMaps: false, // enable browser source map generation during the production build + // Configure pageExtensions to include md and mdx + pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'], + experimental: { + appDir: true, + }, + // fix all before production. Now it slow the develop speed. + eslint: { + // Warning: This allows production builds to successfully complete even if + // your project has ESLint errors. + ignoreDuringBuilds: true, + }, + typescript: { + // https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors + ignoreBuildErrors: true, + } +} + +module.exports = nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..61789b7 --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "webapp-text-generator", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "fix": "next lint --fix" + }, + "dependencies": { + "@formatjs/intl-localematcher": "^0.2.32", + "@headlessui/react": "^1.7.13", + "@heroicons/react": "^2.0.16", + "@mdx-js/loader": "^2.3.0", + "@mdx-js/react": "^2.3.0", + "@tailwindcss/line-clamp": "^0.4.2", + "@types/node": "18.15.0", + "@types/react": "18.0.28", + "@types/react-dom": "18.0.11", + "@types/react-syntax-highlighter": "^15.5.6", + "ahooks": "^3.7.5", + "axios": "^1.3.5", + "classnames": "^2.3.2", + "copy-to-clipboard": "^3.3.3", + "eslint": "8.36.0", + "eslint-config-next": "13.2.4", + "eventsource-parser": "^1.0.0", + "i18next": "^22.4.13", + "i18next-resources-to-backend": "^1.1.3", + "immer": "^9.0.19", + "js-cookie": "^3.0.1", + "langgenius-client": "^1.0.1", + "negotiator": "^0.6.3", + "next": "13.2.4", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-error-boundary": "^4.0.2", + "react-headless-pagination": "^1.1.4", + "react-i18next": "^12.2.0", + "react-markdown": "^8.0.6", + "react-syntax-highlighter": "^15.5.0", + "react-tooltip": "5.8.3", + "rehype-katex": "^6.0.2", + "remark-breaks": "^3.0.2", + "remark-gfm": "^3.0.1", + "remark-math": "^5.1.1", + "sass": "^1.61.0", + "scheduler": "^0.23.0", + "server-only": "^0.0.1", + "swr": "^2.1.0", + "typescript": "4.9.5", + "use-context-selector": "^1.4.1", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@antfu/eslint-config": "^0.36.0", + "@faker-js/faker": "^7.6.0", + "@tailwindcss/typography": "^0.5.9", + "@types/js-cookie": "^3.0.3", + "@types/negotiator": "^0.6.1", + "autoprefixer": "^10.4.14", + "eslint-plugin-react-hooks": "^4.6.0", + "miragejs": "^0.1.47", + "postcss": "^8.4.21", + "tailwindcss": "^3.2.7" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..898cc1a Binary files /dev/null and b/public/favicon.ico differ diff --git a/service/base.ts b/service/base.ts new file mode 100644 index 0000000..df9f110 --- /dev/null +++ b/service/base.ts @@ -0,0 +1,224 @@ +import { API_PREFIX } from '@/config' +import Toast from '@/app/components/base/toast' + +const TIME_OUT = 100000 + +const ContentType = { + json: 'application/json', + stream: 'text/event-stream', + form: 'application/x-www-form-urlencoded; charset=UTF-8', + download: 'application/octet-stream', // for download +} + +const baseOptions = { + method: 'GET', + mode: 'cors', + credentials: 'include', // always send cookies、HTTP Basic authentication. + headers: new Headers({ + 'Content-Type': ContentType.json, + }), + redirect: 'follow', +} + +export type IOnDataMoreInfo = { + conversationId: string | undefined + messageId: string + errorMessage?: string +} + +export type IOnData = (message: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => void +export type IOnCompleted = () => void +export type IOnError = (msg: string) => void + +type IOtherOptions = { + needAllResponseContent?: boolean + onData?: IOnData // for stream + onError?: IOnError + onCompleted?: IOnCompleted // for stream +} + +function unicodeToChar(text: string) { + return text.replace(/\\u[0-9a-f]{4}/g, (_match, p1) => { + return String.fromCharCode(parseInt(p1, 16)) + }) +} + +const handleStream = (response: any, onData: IOnData, onCompleted?: IOnCompleted) => { + if (!response.ok) + throw new Error('Network response was not ok') + + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + let bufferObj: any + let isFirstMessage = true + function read() { + reader.read().then((result: any) => { + if (result.done) { + onCompleted && onCompleted() + return + } + buffer += decoder.decode(result.value, { stream: true }) + const lines = buffer.split('\n') + try { + lines.forEach((message) => { + if (!message) return + bufferObj = JSON.parse(message) // remove data: and parse as json + onData(unicodeToChar(bufferObj.answer), isFirstMessage, { + conversationId: bufferObj.conversation_id, + messageId: bufferObj.id, + }) + isFirstMessage = false + }) + buffer = lines[lines.length - 1] + } catch (e) { + onData('', false, { + conversationId: undefined, + messageId: '', + errorMessage: e + '' + }) + return + } + + read() + }) + } + read() +} + +const baseFetch = (url: string, fetchOptions: any, { needAllResponseContent }: IOtherOptions) => { + const options = Object.assign({}, baseOptions, fetchOptions) + + let urlPrefix = API_PREFIX + + let urlWithPrefix = `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}` + + const { method, params, body } = options + // handle query + if (method === 'GET' && params) { + const paramsArray: string[] = [] + Object.keys(params).forEach(key => + paramsArray.push(`${key}=${encodeURIComponent(params[key])}`), + ) + if (urlWithPrefix.search(/\?/) === -1) + urlWithPrefix += `?${paramsArray.join('&')}` + + else + urlWithPrefix += `&${paramsArray.join('&')}` + + delete options.params + } + + if (body) + options.body = JSON.stringify(body) + + // Handle timeout + return Promise.race([ + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('request timeout')) + }, TIME_OUT) + }), + new Promise((resolve, reject) => { + globalThis.fetch(urlWithPrefix, options) + .then((res: any) => { + const resClone = res.clone() + // Error handler + if (!/^(2|3)\d{2}$/.test(res.status)) { + const bodyJson = res.json() + switch (res.status) { + case 401: { + Toast.notify({ type: 'error', message: 'Invalid token' }) + return + + } + default: + // eslint-disable-next-line no-new + new Promise(() => { + bodyJson.then((data: any) => { + Toast.notify({ type: 'error', message: data.message }) + }) + }) + } + return Promise.reject(resClone) + } + + // handle delete api. Delete api not return content. + if (res.status === 204) { + resolve({ result: "success" }) + return + } + + // return data + const data = options.headers.get('Content-type') === ContentType.download ? res.blob() : res.json() + + resolve(needAllResponseContent ? resClone : data) + }) + .catch((err) => { + Toast.notify({ type: 'error', message: err }) + reject(err) + }) + }), + ]) +} + +export const ssePost = (url: string, fetchOptions: any, { + onData, onCompleted, onError }: IOtherOptions) => { + const options = Object.assign({}, baseOptions, { + method: 'POST', + }, fetchOptions) + + const urlPrefix = API_PREFIX + const urlWithPrefix = `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}` + + const { body } = options + if (body) + options.body = JSON.stringify(body) + + globalThis.fetch(urlWithPrefix, options) + .then((res: any) => { + if (!/^(2|3)\d{2}$/.test(res.status)) { + // eslint-disable-next-line no-new + new Promise(() => { + debugger + res.json().then((data: any) => { + Toast.notify({ type: 'error', message: data.message || 'Server Error' }) + }) + }) + onError?.('Server Error') + return + } + return handleStream(res, (str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => { + if (moreInfo.errorMessage) { + Toast.notify({ type: 'error', message: moreInfo.errorMessage }) + return + } + onData?.(str, isFirstMessage, moreInfo) + }, () => { + onCompleted?.() + }) + }).catch((e) => { + Toast.notify({ type: 'error', message: e }) + onError?.(e) + }) +} + +export const request = (url: string, options = {}, otherOptions?: IOtherOptions) => { + return baseFetch(url, options, otherOptions || {}) +} + +export const get = (url: string, options = {}, otherOptions?: IOtherOptions) => { + return request(url, Object.assign({}, options, { method: 'GET' }), otherOptions) +} + +export const post = (url: string, options = {}, otherOptions?: IOtherOptions) => { + return request(url, Object.assign({}, options, { method: 'POST' }), otherOptions) +} + +export const put = (url: string, options = {}, otherOptions?: IOtherOptions) => { + return request(url, Object.assign({}, options, { method: 'PUT' }), otherOptions) +} + +export const del = (url: string, options = {}, otherOptions?: IOtherOptions) => { + return request(url, Object.assign({}, options, { method: 'DELETE' }), otherOptions) +} diff --git a/service/index.ts b/service/index.ts new file mode 100644 index 0000000..a5f7845 --- /dev/null +++ b/service/index.ts @@ -0,0 +1,24 @@ +import type { IOnCompleted, IOnData, IOnError } from './base' +import { get, post, ssePost } from './base' +import type { Feedbacktype } from '@/types/app' + +export const sendCompletionMessage = async (body: Record, { onData, onCompleted, onError }: { + onData: IOnData + onCompleted: IOnCompleted + onError: IOnError +}) => { + return ssePost('completion-messages', { + body: { + ...body, + response_mode: 'streaming', + }, + }, { onData, onCompleted, onError }) +} + +export const fetchAppParams = async () => { + return get('parameters') +} + +export const updateFeedback = async ({ url, body }: { url: string; body: Feedbacktype }) => { + return post(url, { body }) +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..9b7b3ac --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,66 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './app/**/*.{js,ts,jsx,tsx}', + './components/**/*.{js,ts,jsx,tsx}', + ], + theme: { + typography: require('./typography'), + extend: { + colors: { + gray: { + 50: '#F9FAFB', + 100: '#F3F4F6', + 200: '#E5E7EB', + 300: '#D1D5DB', + 400: '#9CA3AF', + 500: '#6B7280', + 700: '#374151', + 800: '#1F2A37', + 900: '#111928', + }, + primary: { + 50: '#EBF5FF', + 100: '#E1EFFE', + 200: '#C3DDFD', + 300: '#A4CAFE', + 600: '#1C64F2', + 700: '#1A56DB', + }, + blue: { + 500: '#E1EFFE', + }, + green: { + 50: '#F3FAF7', + 100: '#DEF7EC', + 800: '#03543F', + + }, + yellow: { + 100: '#FDF6B2', + 800: '#723B13', + }, + purple: { + 50: '#F6F5FF', + }, + indigo: { + 25: '#F5F8FF', + 100: '#E0EAFF', + 600: '#444CE7' + } + }, + screens: { + 'mobile': '100px', + // => @media (min-width: 100px) { ... } + 'tablet': '640px', // 391 + // => @media (min-width: 600px) { ... } + 'pc': '769px', + // => @media (min-width: 769px) { ... } + }, + }, + }, + plugins: [ + require('@tailwindcss/typography'), + require('@tailwindcss/line-clamp'), + ], +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..668a47e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "target": "es2015", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "global.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "app/components/develop/Prose.jsx" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/types/app.ts b/types/app.ts new file mode 100644 index 0000000..a4ccdb1 --- /dev/null +++ b/types/app.ts @@ -0,0 +1,31 @@ +import { Locale } from '@/i18n' + +export type AppInfo = { + title: string + description: string + default_language: Locale + copyright?: string + privacy_policy?: string +} + +export type PromptVariable = { + key: string, + name: string, + type: "string" | "number" | "select", + default?: string | number, + options?: string[] + max_length: number +} + +export type PromptConfig = { + prompt_template: string, + prompt_variables: PromptVariable[], +} + +export const MessageRatings = ['like', 'dislike', null] as const +export type MessageRating = typeof MessageRatings[number] + +export type Feedbacktype = { + rating: MessageRating + content?: string | null +} diff --git a/typography.js b/typography.js new file mode 100644 index 0000000..706e456 --- /dev/null +++ b/typography.js @@ -0,0 +1,357 @@ +module.exports = ({ theme }) => ({ + DEFAULT: { + css: { + '--tw-prose-body': theme('colors.zinc.700'), + '--tw-prose-headings': theme('colors.zinc.900'), + '--tw-prose-links': theme('colors.emerald.500'), + '--tw-prose-links-hover': theme('colors.emerald.600'), + '--tw-prose-links-underline': theme('colors.emerald.500 / 0.3'), + '--tw-prose-bold': theme('colors.zinc.900'), + '--tw-prose-counters': theme('colors.zinc.500'), + '--tw-prose-bullets': theme('colors.zinc.300'), + '--tw-prose-hr': theme('colors.zinc.900 / 0.05'), + '--tw-prose-quotes': theme('colors.zinc.900'), + '--tw-prose-quote-borders': theme('colors.zinc.200'), + '--tw-prose-captions': theme('colors.zinc.500'), + '--tw-prose-code': theme('colors.zinc.900'), + '--tw-prose-code-bg': theme('colors.zinc.100'), + '--tw-prose-code-ring': theme('colors.zinc.300'), + '--tw-prose-th-borders': theme('colors.zinc.300'), + '--tw-prose-td-borders': theme('colors.zinc.200'), + + '--tw-prose-invert-body': theme('colors.zinc.400'), + '--tw-prose-invert-headings': theme('colors.white'), + '--tw-prose-invert-links': theme('colors.emerald.400'), + '--tw-prose-invert-links-hover': theme('colors.emerald.500'), + '--tw-prose-invert-links-underline': theme('colors.emerald.500 / 0.3'), + '--tw-prose-invert-bold': theme('colors.white'), + '--tw-prose-invert-counters': theme('colors.zinc.400'), + '--tw-prose-invert-bullets': theme('colors.zinc.600'), + '--tw-prose-invert-hr': theme('colors.white / 0.05'), + '--tw-prose-invert-quotes': theme('colors.zinc.100'), + '--tw-prose-invert-quote-borders': theme('colors.zinc.700'), + '--tw-prose-invert-captions': theme('colors.zinc.400'), + '--tw-prose-invert-code': theme('colors.white'), + '--tw-prose-invert-code-bg': theme('colors.zinc.700 / 0.15'), + '--tw-prose-invert-code-ring': theme('colors.white / 0.1'), + '--tw-prose-invert-th-borders': theme('colors.zinc.600'), + '--tw-prose-invert-td-borders': theme('colors.zinc.700'), + + // Base + color: 'var(--tw-prose-body)', + fontSize: theme('fontSize.sm')[0], + lineHeight: theme('lineHeight.7'), + + // Layout + '> *': { + maxWidth: theme('maxWidth.2xl'), + marginLeft: 'auto', + marginRight: 'auto', + '@screen lg': { + maxWidth: theme('maxWidth.3xl'), + marginLeft: `calc(50% - min(50%, ${theme('maxWidth.lg')}))`, + marginRight: `calc(50% - min(50%, ${theme('maxWidth.lg')}))`, + }, + }, + + // Text + p: { + marginTop: theme('spacing.6'), + marginBottom: theme('spacing.6'), + }, + '[class~="lead"]': { + fontSize: theme('fontSize.base')[0], + ...theme('fontSize.base')[1], + }, + + // Lists + ol: { + listStyleType: 'decimal', + marginTop: theme('spacing.5'), + marginBottom: theme('spacing.5'), + paddingLeft: '1.625rem', + }, + 'ol[type="A"]': { + listStyleType: 'upper-alpha', + }, + 'ol[type="a"]': { + listStyleType: 'lower-alpha', + }, + 'ol[type="A" s]': { + listStyleType: 'upper-alpha', + }, + 'ol[type="a" s]': { + listStyleType: 'lower-alpha', + }, + 'ol[type="I"]': { + listStyleType: 'upper-roman', + }, + 'ol[type="i"]': { + listStyleType: 'lower-roman', + }, + 'ol[type="I" s]': { + listStyleType: 'upper-roman', + }, + 'ol[type="i" s]': { + listStyleType: 'lower-roman', + }, + 'ol[type="1"]': { + listStyleType: 'decimal', + }, + ul: { + listStyleType: 'disc', + marginTop: theme('spacing.5'), + marginBottom: theme('spacing.5'), + paddingLeft: '1.625rem', + }, + li: { + marginTop: theme('spacing.2'), + marginBottom: theme('spacing.2'), + }, + ':is(ol, ul) > li': { + paddingLeft: theme('spacing[1.5]'), + }, + 'ol > li::marker': { + fontWeight: '400', + color: 'var(--tw-prose-counters)', + }, + 'ul > li::marker': { + color: 'var(--tw-prose-bullets)', + }, + '> ul > li p': { + marginTop: theme('spacing.3'), + marginBottom: theme('spacing.3'), + }, + '> ul > li > *:first-child': { + marginTop: theme('spacing.5'), + }, + '> ul > li > *:last-child': { + marginBottom: theme('spacing.5'), + }, + '> ol > li > *:first-child': { + marginTop: theme('spacing.5'), + }, + '> ol > li > *:last-child': { + marginBottom: theme('spacing.5'), + }, + 'ul ul, ul ol, ol ul, ol ol': { + marginTop: theme('spacing.3'), + marginBottom: theme('spacing.3'), + }, + + // Horizontal rules + hr: { + borderColor: 'var(--tw-prose-hr)', + borderTopWidth: 1, + marginTop: theme('spacing.16'), + marginBottom: theme('spacing.16'), + maxWidth: 'none', + marginLeft: `calc(-1 * ${theme('spacing.4')})`, + marginRight: `calc(-1 * ${theme('spacing.4')})`, + '@screen sm': { + marginLeft: `calc(-1 * ${theme('spacing.6')})`, + marginRight: `calc(-1 * ${theme('spacing.6')})`, + }, + '@screen lg': { + marginLeft: `calc(-1 * ${theme('spacing.8')})`, + marginRight: `calc(-1 * ${theme('spacing.8')})`, + }, + }, + + // Quotes + blockquote: { + fontWeight: '500', + fontStyle: 'italic', + color: 'var(--tw-prose-quotes)', + borderLeftWidth: '0.25rem', + borderLeftColor: 'var(--tw-prose-quote-borders)', + quotes: '"\\201C""\\201D""\\2018""\\2019"', + marginTop: theme('spacing.8'), + marginBottom: theme('spacing.8'), + paddingLeft: theme('spacing.5'), + }, + 'blockquote p:first-of-type::before': { + content: 'open-quote', + }, + 'blockquote p:last-of-type::after': { + content: 'close-quote', + }, + + // Headings + h1: { + color: 'var(--tw-prose-headings)', + fontWeight: '700', + fontSize: theme('fontSize.2xl')[0], + ...theme('fontSize.2xl')[1], + marginBottom: theme('spacing.2'), + }, + h2: { + color: 'var(--tw-prose-headings)', + fontWeight: '600', + fontSize: theme('fontSize.lg')[0], + ...theme('fontSize.lg')[1], + marginTop: theme('spacing.16'), + marginBottom: theme('spacing.2'), + }, + h3: { + color: 'var(--tw-prose-headings)', + fontSize: theme('fontSize.base')[0], + ...theme('fontSize.base')[1], + fontWeight: '600', + marginTop: theme('spacing.10'), + marginBottom: theme('spacing.2'), + }, + + // Media + 'img, video, figure': { + marginTop: theme('spacing.8'), + marginBottom: theme('spacing.8'), + }, + 'figure > *': { + marginTop: '0', + marginBottom: '0', + }, + figcaption: { + color: 'var(--tw-prose-captions)', + fontSize: theme('fontSize.xs')[0], + ...theme('fontSize.xs')[1], + marginTop: theme('spacing.2'), + }, + + // Tables + table: { + width: '100%', + tableLayout: 'auto', + textAlign: 'left', + marginTop: theme('spacing.8'), + marginBottom: theme('spacing.8'), + lineHeight: theme('lineHeight.6'), + }, + thead: { + borderBottomWidth: '1px', + borderBottomColor: 'var(--tw-prose-th-borders)', + }, + 'thead th': { + color: 'var(--tw-prose-headings)', + fontWeight: '600', + verticalAlign: 'bottom', + paddingRight: theme('spacing.2'), + paddingBottom: theme('spacing.2'), + paddingLeft: theme('spacing.2'), + }, + 'thead th:first-child': { + paddingLeft: '0', + }, + 'thead th:last-child': { + paddingRight: '0', + }, + 'tbody tr': { + borderBottomWidth: '1px', + borderBottomColor: 'var(--tw-prose-td-borders)', + }, + 'tbody tr:last-child': { + borderBottomWidth: '0', + }, + 'tbody td': { + verticalAlign: 'baseline', + }, + tfoot: { + borderTopWidth: '1px', + borderTopColor: 'var(--tw-prose-th-borders)', + }, + 'tfoot td': { + verticalAlign: 'top', + }, + ':is(tbody, tfoot) td': { + paddingTop: theme('spacing.2'), + paddingRight: theme('spacing.2'), + paddingBottom: theme('spacing.2'), + paddingLeft: theme('spacing.2'), + }, + ':is(tbody, tfoot) td:first-child': { + paddingLeft: '0', + }, + ':is(tbody, tfoot) td:last-child': { + paddingRight: '0', + }, + + // Inline elements + a: { + color: 'var(--tw-prose-links)', + textDecoration: 'underline transparent', + fontWeight: '500', + transitionProperty: 'color, text-decoration-color', + transitionDuration: theme('transitionDuration.DEFAULT'), + transitionTimingFunction: theme('transitionTimingFunction.DEFAULT'), + '&:hover': { + color: 'var(--tw-prose-links-hover)', + textDecorationColor: 'var(--tw-prose-links-underline)', + }, + }, + ':is(h1, h2, h3) a': { + fontWeight: 'inherit', + }, + strong: { + color: 'var(--tw-prose-bold)', + fontWeight: '600', + }, + ':is(a, blockquote, thead th) strong': { + color: 'inherit', + }, + code: { + color: 'var(--tw-prose-code)', + borderRadius: theme('borderRadius.lg'), + paddingTop: theme('padding.1'), + paddingRight: theme('padding[1.5]'), + paddingBottom: theme('padding.1'), + paddingLeft: theme('padding[1.5]'), + boxShadow: 'inset 0 0 0 1px var(--tw-prose-code-ring)', + backgroundColor: 'var(--tw-prose-code-bg)', + fontSize: theme('fontSize.2xs'), + }, + ':is(a, h1, h2, h3, blockquote, thead th) code': { + color: 'inherit', + }, + 'h2 code': { + fontSize: theme('fontSize.base')[0], + fontWeight: 'inherit', + }, + 'h3 code': { + fontSize: theme('fontSize.sm')[0], + fontWeight: 'inherit', + }, + + // Overrides + ':is(h1, h2, h3) + *': { + marginTop: '0', + }, + '> :first-child': { + marginTop: '0 !important', + }, + '> :last-child': { + marginBottom: '0 !important', + }, + }, + }, + invert: { + css: { + '--tw-prose-body': 'var(--tw-prose-invert-body)', + '--tw-prose-headings': 'var(--tw-prose-invert-headings)', + '--tw-prose-links': 'var(--tw-prose-invert-links)', + '--tw-prose-links-hover': 'var(--tw-prose-invert-links-hover)', + '--tw-prose-links-underline': 'var(--tw-prose-invert-links-underline)', + '--tw-prose-bold': 'var(--tw-prose-invert-bold)', + '--tw-prose-counters': 'var(--tw-prose-invert-counters)', + '--tw-prose-bullets': 'var(--tw-prose-invert-bullets)', + '--tw-prose-hr': 'var(--tw-prose-invert-hr)', + '--tw-prose-quotes': 'var(--tw-prose-invert-quotes)', + '--tw-prose-quote-borders': 'var(--tw-prose-invert-quote-borders)', + '--tw-prose-captions': 'var(--tw-prose-invert-captions)', + '--tw-prose-code': 'var(--tw-prose-invert-code)', + '--tw-prose-code-bg': 'var(--tw-prose-invert-code-bg)', + '--tw-prose-code-ring': 'var(--tw-prose-invert-code-ring)', + '--tw-prose-th-borders': 'var(--tw-prose-invert-th-borders)', + '--tw-prose-td-borders': 'var(--tw-prose-invert-td-borders)', + }, + }, +}) diff --git a/utils/prompt.ts b/utils/prompt.ts new file mode 100644 index 0000000..e2f5d44 --- /dev/null +++ b/utils/prompt.ts @@ -0,0 +1,12 @@ +import { PromptVariable } from '@/types/app' + +export function replaceVarWithValues(str: string, promptVariables: PromptVariable[], inputs: Record) { + return str.replace(/\{\{([^}]+)\}\}/g, (match, key) => { + const name = inputs[key] + if (name) + return name + + const valueObj: PromptVariable | undefined = promptVariables.find(v => v.key === key) + return valueObj ? `{{${valueObj.key}}}` : match + }) +} \ No newline at end of file diff --git a/utils/string.ts b/utils/string.ts new file mode 100644 index 0000000..0d9e8eb --- /dev/null +++ b/utils/string.ts @@ -0,0 +1,6 @@ +const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_' +export function randomString(length: number) { + let result = '' + for (let i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)] + return result +}