@ -0,0 +1,29 @@
|
||||
|
||||
# Your openai api key. (required)
|
||||
OPENAI_API_KEY=sk-xxxx
|
||||
|
||||
# Access passsword, separated by comma. (optional)
|
||||
CODE=your-password
|
||||
|
||||
# You can start service behind a proxy
|
||||
PROXY_URL=http://localhost:7890
|
||||
|
||||
# Override openai api request base url. (optional)
|
||||
# Default: https://api.openai.com
|
||||
# Examples: http://your-openai-proxy.com
|
||||
BASE_URL=
|
||||
|
||||
# Specify OpenAI organization ID.(optional)
|
||||
# Default: Empty
|
||||
# If you do not want users to input their own API key, set this value to 1.
|
||||
OPENAI_ORG_ID=
|
||||
|
||||
# (optional)
|
||||
# Default: Empty
|
||||
# If you do not want users to input their own API key, set this value to 1.
|
||||
HIDE_USER_API_KEY=
|
||||
|
||||
# (optional)
|
||||
# Default: Empty
|
||||
# If you do not want users to use GPT-4, set this value to 1.
|
||||
DISABLE_GPT4=
|
@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
@ -0,0 +1,88 @@
|
||||
name: Release App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-20.04
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- name: get version
|
||||
run: echo "PACKAGE_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').package.version")" >> $GITHUB_ENV
|
||||
- name: create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getLatestRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
})
|
||||
return data.id
|
||||
|
||||
build-tauri:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, ubuntu-20.04, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-20.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: install frontend dependencies
|
||||
run: yarn install # change this to npm or pnpm depending on which one you use
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
|
||||
publish-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [create-release, build-tauri]
|
||||
|
||||
steps:
|
||||
- name: publish release
|
||||
id: publish-release
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
@ -0,0 +1,171 @@
|
||||
<div align="center">
|
||||
<img src="./docs/images/icon.svg" alt="预览"/>
|
||||
|
||||
<h1 align="center">ChatGPT Next Web</h1>
|
||||
|
||||
Implemente su aplicación web privada ChatGPT de forma gratuita con un solo clic.
|
||||
|
||||
[Demo demo](https://chat-gpt-next-web.vercel.app/) / [Problemas de comentarios](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Únete a Discord](https://discord.gg/zrhvHCr79N) / [Grupo QQ](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [Desarrolladores de consejos](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) / [Donar](#捐赠-donate-usdt)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web\&env=OPENAI_API_KEY\&env=CODE\&project-name=chatgpt-next-web\&repository-name=ChatGPT-Next-Web)
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## Comenzar
|
||||
|
||||
1. Prepara el tuyo [Clave API OpenAI](https://platform.openai.com/account/api-keys);
|
||||
2. Haga clic en el botón de la derecha para iniciar la implementación:
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web\&env=OPENAI_API_KEY\&env=CODE\&project-name=chatgpt-next-web\&repository-name=ChatGPT-Next-Web), inicie sesión directamente con su cuenta de Github y recuerde completar la clave API y la suma en la página de variables de entorno[Contraseña de acceso a la página](#配置页面访问密码) CÓDIGO;
|
||||
3. Una vez implementado, puede comenzar;
|
||||
4. (Opcional)[Enlazar un nombre de dominio personalizado](https://vercel.com/docs/concepts/projects/domains/add-a-domain): El nombre de dominio DNS asignado por Vercel está contaminado en algunas regiones y puede conectarse directamente enlazando un nombre de dominio personalizado.
|
||||
|
||||
## Manténgase actualizado
|
||||
|
||||
Si sigue los pasos anteriores para implementar su proyecto con un solo clic, es posible que siempre diga "La actualización existe" porque Vercel creará un nuevo proyecto para usted de forma predeterminada en lugar de bifurcar el proyecto, lo que evitará que la actualización se detecte correctamente.
|
||||
Le recomendamos que siga estos pasos para volver a implementar:
|
||||
|
||||
* Eliminar el repositorio original;
|
||||
* Utilice el botón de bifurcación en la esquina superior derecha de la página para bifurcar este proyecto;
|
||||
* En Vercel, vuelva a seleccionar e implementar,[Echa un vistazo al tutorial detallado](./docs/vercel-cn.md#如何新建项目)。
|
||||
|
||||
### Activar actualizaciones automáticas
|
||||
|
||||
> Si encuentra un error de ejecución de Upstream Sync, ¡Sync Fork manualmente una vez!
|
||||
|
||||
Cuando bifurca el proyecto, debido a las limitaciones de Github, debe ir manualmente a la página Acciones de su proyecto bifurcado para habilitar Flujos de trabajo y habilitar Upstream Sync Action, después de habilitarlo, puede activar las actualizaciones automáticas cada hora:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Actualizar el código manualmente
|
||||
|
||||
Si desea que el manual se actualice inmediatamente, puede consultarlo [Documentación para Github](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) Aprenda a sincronizar un proyecto bifurcado con código ascendente.
|
||||
|
||||
Puede destacar / ver este proyecto o seguir al autor para recibir notificaciones de nuevas actualizaciones de funciones.
|
||||
|
||||
## Configurar la contraseña de acceso a la página
|
||||
|
||||
> Después de configurar la contraseña, el usuario debe completar manualmente el código de acceso en la página de configuración para chatear normalmente, de lo contrario, se solicitará el estado no autorizado a través de un mensaje.
|
||||
|
||||
> **advertir**: Asegúrese de establecer el número de dígitos de la contraseña lo suficientemente largo, preferiblemente más de 7 dígitos, de lo contrario[Será volado](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。
|
||||
|
||||
Este proyecto proporciona control de permisos limitado, agregue el nombre al nombre en la página Variables de entorno del Panel de control del proyecto Vercel `CODE` Variables de entorno con valores para contraseñas personalizadas separadas por comas:
|
||||
|
||||
code1,code2,code3
|
||||
|
||||
Después de agregar o modificar la variable de entorno, por favor**Redesplegar**proyecto para poner en vigor los cambios.
|
||||
|
||||
## Variable de entorno
|
||||
|
||||
> La mayoría de los elementos de configuración de este proyecto se establecen a través de variables de entorno, tutorial:[Cómo modificar las variables de entorno de Vercel](./docs/vercel-cn.md)。
|
||||
|
||||
### `OPENAI_API_KEY` (Requerido)
|
||||
|
||||
OpanAI key, la clave API que solicita en la página de su cuenta openai.
|
||||
|
||||
### `CODE` (Opcional)
|
||||
|
||||
Las contraseñas de acceso, opcionalmente, se pueden separar por comas.
|
||||
|
||||
**advertir**: Si no completa este campo, cualquiera puede usar directamente su sitio web implementado, lo que puede hacer que su token se consuma rápidamente, se recomienda completar esta opción.
|
||||
|
||||
### `BASE_URL` (Opcional)
|
||||
|
||||
> Predeterminado: `https://api.openai.com`
|
||||
|
||||
> Ejemplos: `http://your-openai-proxy.com`
|
||||
|
||||
URL del proxy de interfaz OpenAI, complete esta opción si configuró manualmente el proxy de interfaz openAI.
|
||||
|
||||
> Si encuentra problemas con el certificado SSL, establezca el `BASE_URL` El protocolo se establece en http.
|
||||
|
||||
### `OPENAI_ORG_ID` (Opcional)
|
||||
|
||||
Especifica el identificador de la organización en OpenAI.
|
||||
|
||||
### `HIDE_USER_API_KEY` (Opcional)
|
||||
|
||||
Si no desea que los usuarios rellenen la clave de API ellos mismos, establezca esta variable de entorno en 1.
|
||||
|
||||
### `DISABLE_GPT4` (Opcional)
|
||||
|
||||
Si no desea que los usuarios utilicen GPT-4, establezca esta variable de entorno en 1.
|
||||
|
||||
## explotación
|
||||
|
||||
> No se recomienda encarecidamente desarrollar o implementar localmente, debido a algunas razones técnicas, es difícil configurar el agente API de OpenAI localmente, a menos que pueda asegurarse de que puede conectarse directamente al servidor OpenAI.
|
||||
|
||||
Haga clic en el botón de abajo para iniciar el desarrollo secundario:
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||
Antes de empezar a escribir código, debe crear uno nuevo en la raíz del proyecto `.env.local` archivo, lleno de variables de entorno:
|
||||
|
||||
OPENAI_API_KEY=<your api key here>
|
||||
|
||||
### Desarrollo local
|
||||
|
||||
1. Instale nodejs 18 e hilo, pregunte a ChatGPT para obtener más detalles;
|
||||
2. ejecutar `yarn install && yarn dev` Enlatar. ⚠️ Nota: Este comando es solo para desarrollo local, no para implementación.
|
||||
3. Úselo si desea implementar localmente `yarn install && yarn start` comando, puede cooperar con pm2 a daemon para evitar ser asesinado, pregunte a ChatGPT para obtener más detalles.
|
||||
|
||||
## desplegar
|
||||
|
||||
### Implementación de contenedores (recomendado)
|
||||
|
||||
> La versión de Docker debe ser 20 o posterior, de lo contrario se indicará que no se puede encontrar la imagen.
|
||||
|
||||
> ⚠️ Nota: Las versiones de Docker están de 1 a 2 días por detrás de la última versión la mayor parte del tiempo, por lo que es normal que sigas diciendo "La actualización existe" después de la implementación.
|
||||
|
||||
```shell
|
||||
docker pull yidadaa/chatgpt-next-web
|
||||
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY="sk-xxxx" \
|
||||
-e CODE="页面访问密码" \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
También puede especificar proxy:
|
||||
|
||||
```shell
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY="sk-xxxx" \
|
||||
-e CODE="页面访问密码" \
|
||||
--net=host \
|
||||
-e PROXY_URL="http://127.0.0.1:7890" \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
Si necesita especificar otras variables de entorno, agréguelas usted mismo en el comando anterior `-e 环境变量=环境变量值` para especificar.
|
||||
|
||||
### Implementación local
|
||||
|
||||
Ejecute el siguiente comando en la consola:
|
||||
|
||||
```shell
|
||||
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
||||
```
|
||||
|
||||
⚠️ Nota: Si tiene problemas durante la instalación, utilice la implementación de Docker.
|
||||
|
||||
## Reconocimiento
|
||||
|
||||
### donante
|
||||
|
||||
> Ver versión en inglés.
|
||||
|
||||
### Colaboradores
|
||||
|
||||
[Ver la lista de colaboradores del proyecto](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
||||
|
||||
## Licencia de código abierto
|
||||
|
||||
> Contra 996, empezando por mí.
|
||||
|
||||
[Licencia Anti 996](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
@ -1,9 +0,0 @@
|
||||
import type {
|
||||
CreateChatCompletionRequest,
|
||||
CreateChatCompletionResponse,
|
||||
} from "openai";
|
||||
|
||||
export type ChatRequest = CreateChatCompletionRequest;
|
||||
export type ChatResponse = CreateChatCompletionResponse;
|
||||
|
||||
export type Updater<T> = (updater: (value: T) => void) => void;
|
@ -0,0 +1,145 @@
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { ACCESS_CODE_PREFIX } from "../constant";
|
||||
import { ChatMessage, ModelType, useAccessStore } from "../store";
|
||||
import { ChatGPTApi } from "./platforms/openai";
|
||||
|
||||
export const ROLES = ["system", "user", "assistant"] as const;
|
||||
export type MessageRole = (typeof ROLES)[number];
|
||||
|
||||
export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
|
||||
export type ChatModel = ModelType;
|
||||
|
||||
export interface RequestMessage {
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LLMConfig {
|
||||
model: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stream?: boolean;
|
||||
presence_penalty?: number;
|
||||
frequency_penalty?: number;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
messages: RequestMessage[];
|
||||
config: LLMConfig;
|
||||
|
||||
onUpdate?: (message: string, chunk: string) => void;
|
||||
onFinish: (message: string) => void;
|
||||
onError?: (err: Error) => void;
|
||||
onController?: (controller: AbortController) => void;
|
||||
}
|
||||
|
||||
export interface LLMUsage {
|
||||
used: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export abstract class LLMApi {
|
||||
abstract chat(options: ChatOptions): Promise<void>;
|
||||
abstract usage(): Promise<LLMUsage>;
|
||||
}
|
||||
|
||||
type ProviderName = "openai" | "azure" | "claude" | "palm";
|
||||
|
||||
interface Model {
|
||||
name: string;
|
||||
provider: ProviderName;
|
||||
ctxlen: number;
|
||||
}
|
||||
|
||||
interface ChatProvider {
|
||||
name: ProviderName;
|
||||
apiConfig: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
summaryModel: Model;
|
||||
};
|
||||
models: Model[];
|
||||
|
||||
chat: () => void;
|
||||
usage: () => void;
|
||||
}
|
||||
|
||||
export class ClientApi {
|
||||
public llm: LLMApi;
|
||||
|
||||
constructor() {
|
||||
this.llm = new ChatGPTApi();
|
||||
}
|
||||
|
||||
config() {}
|
||||
|
||||
prompts() {}
|
||||
|
||||
masks() {}
|
||||
|
||||
async share(messages: ChatMessage[], avatarUrl: string | null = null) {
|
||||
const msgs = messages
|
||||
.map((m) => ({
|
||||
from: m.role === "user" ? "human" : "gpt",
|
||||
value: m.content,
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
from: "human",
|
||||
value:
|
||||
"Share from [ChatGPT Next Web]: https://github.com/Yidadaa/ChatGPT-Next-Web",
|
||||
},
|
||||
]);
|
||||
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
|
||||
// Please do not modify this message
|
||||
|
||||
console.log("[Share]", msgs);
|
||||
const clientConfig = getClientConfig();
|
||||
const proxyUrl = "/sharegpt";
|
||||
const rawUrl = "https://sharegpt.com/api/conversations";
|
||||
const shareUrl = clientConfig?.isApp ? rawUrl : proxyUrl;
|
||||
const res = await fetch(shareUrl, {
|
||||
body: JSON.stringify({
|
||||
avatarUrl,
|
||||
items: msgs,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const resJson = await res.json();
|
||||
console.log("[Share]", resJson);
|
||||
if (resJson.id) {
|
||||
return `https://shareg.pt/${resJson.id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ClientApi();
|
||||
|
||||
export function getHeaders() {
|
||||
const accessStore = useAccessStore.getState();
|
||||
let headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
};
|
||||
|
||||
const makeBearer = (token: string) => `Bearer ${token.trim()}`;
|
||||
const validString = (x: string) => x && x.length > 0;
|
||||
|
||||
// use user's api key first
|
||||
if (validString(accessStore.token)) {
|
||||
headers.Authorization = makeBearer(accessStore.token);
|
||||
} else if (
|
||||
accessStore.enabledAccessControl() &&
|
||||
validString(accessStore.accessCode)
|
||||
) {
|
||||
headers.Authorization = makeBearer(
|
||||
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
||||
);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
// To store message streaming controller
|
||||
export const ChatControllerPool = {
|
||||
controllers: {} as Record<string, AbortController>,
|
||||
|
||||
addController(
|
||||
sessionIndex: number,
|
||||
messageId: number,
|
||||
controller: AbortController,
|
||||
) {
|
||||
const key = this.key(sessionIndex, messageId);
|
||||
this.controllers[key] = controller;
|
||||
return key;
|
||||
},
|
||||
|
||||
stop(sessionIndex: number, messageId: number) {
|
||||
const key = this.key(sessionIndex, messageId);
|
||||
const controller = this.controllers[key];
|
||||
controller?.abort();
|
||||
},
|
||||
|
||||
stopAll() {
|
||||
Object.values(this.controllers).forEach((v) => v.abort());
|
||||
},
|
||||
|
||||
hasPending() {
|
||||
return Object.values(this.controllers).length > 0;
|
||||
},
|
||||
|
||||
remove(sessionIndex: number, messageId: number) {
|
||||
const key = this.key(sessionIndex, messageId);
|
||||
delete this.controllers[key];
|
||||
},
|
||||
|
||||
key(sessionIndex: number, messageIndex: number) {
|
||||
return `${sessionIndex},${messageIndex}`;
|
||||
},
|
||||
};
|
@ -0,0 +1,227 @@
|
||||
import { OpenaiPath, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import { ChatOptions, getHeaders, LLMApi, LLMUsage } from "../api";
|
||||
import Locale from "../../locales";
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
|
||||
export class ChatGPTApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
let openaiUrl = useAccessStore.getState().openaiUrl;
|
||||
if (openaiUrl.endsWith("/")) {
|
||||
openaiUrl = openaiUrl.slice(0, openaiUrl.length - 1);
|
||||
}
|
||||
return [openaiUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: v.content,
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
const requestPayload = {
|
||||
messages,
|
||||
stream: options.config.stream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
};
|
||||
|
||||
console.log("[Request] openai payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(OpenaiPath.ChatPath);
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let finished = false;
|
||||
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
options.onFinish(responseText);
|
||||
finished = true;
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[OpenAI] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [responseText];
|
||||
let extraInfo = await res.clone().text();
|
||||
try {
|
||||
const resJson = await res.clone().json();
|
||||
extraInfo = prettyObject(resJson);
|
||||
} catch {}
|
||||
|
||||
if (res.status === 401) {
|
||||
responseTexts.push(Locale.Error.Unauthorized);
|
||||
}
|
||||
|
||||
if (extraInfo) {
|
||||
responseTexts.push(extraInfo);
|
||||
}
|
||||
|
||||
responseText = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || finished) {
|
||||
return finish();
|
||||
}
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const delta = json.choices[0].delta.content;
|
||||
if (delta) {
|
||||
responseText += delta;
|
||||
options.onUpdate?.(responseText, delta);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text, msg);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat reqeust", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
async usage() {
|
||||
const formatDate = (d: Date) =>
|
||||
`${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
|
||||
.getDate()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startDate = formatDate(startOfMonth);
|
||||
const endDate = formatDate(new Date(Date.now() + ONE_DAY));
|
||||
|
||||
const [used, subs] = await Promise.all([
|
||||
fetch(
|
||||
this.path(
|
||||
`${OpenaiPath.UsagePath}?start_date=${startDate}&end_date=${endDate}`,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: getHeaders(),
|
||||
},
|
||||
),
|
||||
fetch(this.path(OpenaiPath.SubsPath), {
|
||||
method: "GET",
|
||||
headers: getHeaders(),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (used.status === 401) {
|
||||
throw new Error(Locale.Error.Unauthorized);
|
||||
}
|
||||
|
||||
if (!used.ok || !subs.ok) {
|
||||
throw new Error("Failed to query usage from openai");
|
||||
}
|
||||
|
||||
const response = (await used.json()) as {
|
||||
total_usage?: number;
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
const total = (await subs.json()) as {
|
||||
hard_limit_usd?: number;
|
||||
};
|
||||
|
||||
if (response.error && response.error.type) {
|
||||
throw Error(response.error.message);
|
||||
}
|
||||
|
||||
if (response.total_usage) {
|
||||
response.total_usage = Math.round(response.total_usage) / 100;
|
||||
}
|
||||
|
||||
if (total.hard_limit_usd) {
|
||||
total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
|
||||
}
|
||||
|
||||
return {
|
||||
used: response.total_usage,
|
||||
total: total.hard_limit_usd,
|
||||
} as LLMUsage;
|
||||
}
|
||||
}
|
||||
export { OpenaiPath };
|
@ -0,0 +1,36 @@
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
.auth-logo {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.auth-tips {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
margin: 3vh 0;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
button:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import styles from "./auth.module.scss";
|
||||
import { IconButton } from "./button";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import { useAccessStore } from "../store";
|
||||
import Locale from "../locales";
|
||||
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
|
||||
export function AuthPage() {
|
||||
const navigate = useNavigate();
|
||||
const access = useAccessStore();
|
||||
|
||||
const goHome = () => navigate(Path.Home);
|
||||
|
||||
return (
|
||||
<div className={styles["auth-page"]}>
|
||||
<div className={`no-dark ${styles["auth-logo"]}`}>
|
||||
<BotIcon />
|
||||
</div>
|
||||
|
||||
<div className={styles["auth-title"]}>{Locale.Auth.Title}</div>
|
||||
<div className={styles["auth-tips"]}>{Locale.Auth.Tips}</div>
|
||||
|
||||
<input
|
||||
className={styles["auth-input"]}
|
||||
type="password"
|
||||
placeholder={Locale.Auth.Input}
|
||||
value={access.accessCode}
|
||||
onChange={(e) => {
|
||||
access.updateCode(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles["auth-actions"]}>
|
||||
<IconButton
|
||||
text={Locale.Auth.Confirm}
|
||||
type="primary"
|
||||
onClick={goHome}
|
||||
/>
|
||||
<IconButton text={Locale.Auth.Later} onClick={goHome} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,217 @@
|
||||
.message-exporter {
|
||||
&-body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.export-content {
|
||||
white-space: break-spaces;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.steps {
|
||||
background-color: var(--gray);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
position: relative;
|
||||
box-shadow: var(--card-shadow) inset;
|
||||
|
||||
.steps-progress {
|
||||
$padding: 5px;
|
||||
height: calc(100% - 2 * $padding);
|
||||
width: calc(100% - 2 * $padding);
|
||||
position: absolute;
|
||||
top: $padding;
|
||||
left: $padding;
|
||||
|
||||
&-inner {
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--card-shadow);
|
||||
border: var(--border-in-light);
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background-color: var(--white);
|
||||
transition: all ease 0.3s;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.steps-inner {
|
||||
display: flex;
|
||||
transform: scale(1);
|
||||
|
||||
.step {
|
||||
flex-grow: 1;
|
||||
padding: 5px 10px;
|
||||
font-size: 14px;
|
||||
color: var(--black);
|
||||
opacity: 0.5;
|
||||
transition: all ease 0.3s;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
$radius: 8px;
|
||||
|
||||
&-finished {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&-current {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.step-index {
|
||||
background-color: var(--gray);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
padding: 0px 5px;
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
button {
|
||||
flex-grow: 1;
|
||||
&:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-previewer {
|
||||
.preview-body {
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--card-shadow) inset;
|
||||
background-color: var(--gray);
|
||||
|
||||
.chat-info {
|
||||
background-color: var(--second);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.icons {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 50%;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 20px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icons {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon-space {
|
||||
font-size: 12px;
|
||||
margin: 0 10px;
|
||||
font-weight: bolder;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-info-item {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
padding: 2px 15px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--card-shadow);
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
|
||||
.avatar {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.body {
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
max-width: calc(100% - 104px);
|
||||
box-shadow: var(--card-shadow);
|
||||
border: var(--border-in-light);
|
||||
|
||||
* {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&-assistant {
|
||||
.body {
|
||||
background-color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
&-user {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.avatar {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
background-color: var(--second);
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.default-theme {
|
||||
}
|
||||
}
|
@ -0,0 +1,528 @@
|
||||
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
||||
import Locale from "../locales";
|
||||
import styles from "./exporter.module.scss";
|
||||
import { List, ListItem, Modal, Select, showToast } from "./ui-lib";
|
||||
import { IconButton } from "./button";
|
||||
import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
|
||||
|
||||
import CopyIcon from "../icons/copy.svg";
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
import ChatGptIcon from "../icons/chatgpt.png";
|
||||
import ShareIcon from "../icons/share.svg";
|
||||
import BotIcon from "../icons/bot.png";
|
||||
|
||||
import DownloadIcon from "../icons/download.svg";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MessageSelector, useMessageSelector } from "./message-selector";
|
||||
import { Avatar } from "./emoji";
|
||||
import dynamic from "next/dynamic";
|
||||
import NextImage from "next/image";
|
||||
|
||||
import { toBlob, toJpeg, toPng } from "html-to-image";
|
||||
import { DEFAULT_MASK_AVATAR } from "../store/mask";
|
||||
import { api } from "../client/api";
|
||||
import { prettyObject } from "../utils/format";
|
||||
import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
});
|
||||
|
||||
export function ExportMessageModal(props: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal title={Locale.Export.Title} onClose={props.onClose}>
|
||||
<div style={{ minHeight: "40vh" }}>
|
||||
<MessageExporter />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSteps(
|
||||
steps: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>,
|
||||
) {
|
||||
const stepCount = steps.length;
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const nextStep = () =>
|
||||
setCurrentStepIndex((currentStepIndex + 1) % stepCount);
|
||||
const prevStep = () =>
|
||||
setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
|
||||
|
||||
return {
|
||||
currentStepIndex,
|
||||
setCurrentStepIndex,
|
||||
nextStep,
|
||||
prevStep,
|
||||
currentStep: steps[currentStepIndex],
|
||||
};
|
||||
}
|
||||
|
||||
function Steps<
|
||||
T extends {
|
||||
name: string;
|
||||
value: string;
|
||||
}[],
|
||||
>(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
|
||||
const steps = props.steps;
|
||||
const stepCount = steps.length;
|
||||
|
||||
return (
|
||||
<div className={styles["steps"]}>
|
||||
<div className={styles["steps-progress"]}>
|
||||
<div
|
||||
className={styles["steps-progress-inner"]}
|
||||
style={{
|
||||
width: `${((props.index + 1) / stepCount) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className={styles["steps-inner"]}>
|
||||
{steps.map((step, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles["step"]} ${
|
||||
styles[i <= props.index ? "step-finished" : ""]
|
||||
} ${i === props.index && styles["step-current"]} clickable`}
|
||||
onClick={() => {
|
||||
props.onStepChange?.(i);
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
<span className={styles["step-index"]}>{i + 1}</span>
|
||||
<span className={styles["step-name"]}>{step.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageExporter() {
|
||||
const steps = [
|
||||
{
|
||||
name: Locale.Export.Steps.Select,
|
||||
value: "select",
|
||||
},
|
||||
{
|
||||
name: Locale.Export.Steps.Preview,
|
||||
value: "preview",
|
||||
},
|
||||
];
|
||||
const { currentStep, setCurrentStepIndex, currentStepIndex } =
|
||||
useSteps(steps);
|
||||
const formats = ["text", "image"] as const;
|
||||
type ExportFormat = (typeof formats)[number];
|
||||
|
||||
const [exportConfig, setExportConfig] = useState({
|
||||
format: "image" as ExportFormat,
|
||||
includeContext: true,
|
||||
});
|
||||
|
||||
function updateExportConfig(updater: (config: typeof exportConfig) => void) {
|
||||
const config = { ...exportConfig };
|
||||
updater(config);
|
||||
setExportConfig(config);
|
||||
}
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const { selection, updateSelection } = useMessageSelector();
|
||||
const selectedMessages = useMemo(() => {
|
||||
const ret: ChatMessage[] = [];
|
||||
if (exportConfig.includeContext) {
|
||||
ret.push(...session.mask.context);
|
||||
}
|
||||
ret.push(...session.messages.filter((m, i) => selection.has(m.id ?? i)));
|
||||
return ret;
|
||||
}, [
|
||||
exportConfig.includeContext,
|
||||
session.messages,
|
||||
session.mask.context,
|
||||
selection,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Steps
|
||||
steps={steps}
|
||||
index={currentStepIndex}
|
||||
onStepChange={setCurrentStepIndex}
|
||||
/>
|
||||
<div
|
||||
className={styles["message-exporter-body"]}
|
||||
style={currentStep.value !== "select" ? { display: "none" } : {}}
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Export.Format.Title}
|
||||
subTitle={Locale.Export.Format.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={exportConfig.format}
|
||||
onChange={(e) =>
|
||||
updateExportConfig(
|
||||
(config) =>
|
||||
(config.format = e.currentTarget.value as ExportFormat),
|
||||
)
|
||||
}
|
||||
>
|
||||
{formats.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Export.IncludeContext.Title}
|
||||
subTitle={Locale.Export.IncludeContext.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportConfig.includeContext}
|
||||
onChange={(e) => {
|
||||
updateExportConfig(
|
||||
(config) => (config.includeContext = e.currentTarget.checked),
|
||||
);
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
</List>
|
||||
<MessageSelector
|
||||
selection={selection}
|
||||
updateSelection={updateSelection}
|
||||
defaultSelectAll
|
||||
/>
|
||||
</div>
|
||||
{currentStep.value === "preview" && (
|
||||
<div className={styles["message-exporter-body"]}>
|
||||
{exportConfig.format === "text" ? (
|
||||
<MarkdownPreviewer
|
||||
messages={selectedMessages}
|
||||
topic={session.topic}
|
||||
/>
|
||||
) : (
|
||||
<ImagePreviewer messages={selectedMessages} topic={session.topic} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderExport(props: {
|
||||
messages: ChatMessage[];
|
||||
onRender: (messages: ChatMessage[]) => void;
|
||||
}) {
|
||||
const domRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!domRef.current) return;
|
||||
const dom = domRef.current;
|
||||
const messages = Array.from(
|
||||
dom.getElementsByClassName(EXPORT_MESSAGE_CLASS_NAME),
|
||||
);
|
||||
|
||||
if (messages.length !== props.messages.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderMsgs = messages.map((v) => {
|
||||
const [_, role] = v.id.split(":");
|
||||
return {
|
||||
role: role as any,
|
||||
content: v.innerHTML,
|
||||
date: "",
|
||||
};
|
||||
});
|
||||
|
||||
props.onRender(renderMsgs);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={domRef}>
|
||||
{props.messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
id={`${m.role}:${i}`}
|
||||
className={EXPORT_MESSAGE_CLASS_NAME}
|
||||
>
|
||||
<Markdown content={m.content} defaultShow />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewActions(props: {
|
||||
download: () => void;
|
||||
copy: () => void;
|
||||
showCopy?: boolean;
|
||||
messages?: ChatMessage[];
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shouldExport, setShouldExport] = useState(false);
|
||||
|
||||
const onRenderMsgs = (msgs: ChatMessage[]) => {
|
||||
setShouldExport(false);
|
||||
|
||||
api
|
||||
.share(msgs)
|
||||
.then((res) => {
|
||||
if (!res) return;
|
||||
copyToClipboard(res);
|
||||
setTimeout(() => {
|
||||
window.open(res, "_blank");
|
||||
}, 800);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[Share]", e);
|
||||
showToast(prettyObject(e));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const share = async () => {
|
||||
if (props.messages?.length) {
|
||||
setLoading(true);
|
||||
setShouldExport(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles["preview-actions"]}>
|
||||
{props.showCopy && (
|
||||
<IconButton
|
||||
text={Locale.Export.Copy}
|
||||
bordered
|
||||
shadow
|
||||
icon={<CopyIcon />}
|
||||
onClick={props.copy}
|
||||
></IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
text={Locale.Export.Download}
|
||||
bordered
|
||||
shadow
|
||||
icon={<DownloadIcon />}
|
||||
onClick={props.download}
|
||||
></IconButton>
|
||||
<IconButton
|
||||
text={Locale.Export.Share}
|
||||
bordered
|
||||
shadow
|
||||
icon={loading ? <LoadingIcon /> : <ShareIcon />}
|
||||
onClick={share}
|
||||
></IconButton>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: "200vw",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{shouldExport && (
|
||||
<RenderExport
|
||||
messages={props.messages ?? []}
|
||||
onRender={onRenderMsgs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportAvatar(props: { avatar: string }) {
|
||||
if (props.avatar === DEFAULT_MASK_AVATAR) {
|
||||
return (
|
||||
<NextImage
|
||||
src={BotIcon.src}
|
||||
width={30}
|
||||
height={30}
|
||||
alt="bot"
|
||||
className="user-avatar"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Avatar avatar={props.avatar}></Avatar>;
|
||||
}
|
||||
|
||||
export function ImagePreviewer(props: {
|
||||
messages: ChatMessage[];
|
||||
topic: string;
|
||||
}) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const mask = session.mask;
|
||||
const config = useAppConfig();
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const copy = () => {
|
||||
const dom = previewRef.current;
|
||||
if (!dom) return;
|
||||
toBlob(dom).then((blob) => {
|
||||
if (!blob) return;
|
||||
try {
|
||||
navigator.clipboard
|
||||
.write([
|
||||
new ClipboardItem({
|
||||
"image/png": blob,
|
||||
}),
|
||||
])
|
||||
.then(() => {
|
||||
showToast(Locale.Copy.Success);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Copy Image] ", e);
|
||||
showToast(Locale.Copy.Failed);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isMobile = useMobileScreen();
|
||||
|
||||
const download = () => {
|
||||
const dom = previewRef.current;
|
||||
if (!dom) return;
|
||||
toPng(dom)
|
||||
.then((blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
if (isMobile) {
|
||||
const image = new Image();
|
||||
image.src = blob;
|
||||
const win = window.open("");
|
||||
win?.document.write(image.outerHTML);
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.download = `${props.topic}.png`;
|
||||
link.href = blob;
|
||||
link.click();
|
||||
}
|
||||
})
|
||||
.catch((e) => console.log("[Export Image] ", e));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["image-previewer"]}>
|
||||
<PreviewActions
|
||||
copy={copy}
|
||||
download={download}
|
||||
showCopy={!isMobile}
|
||||
messages={props.messages}
|
||||
/>
|
||||
<div
|
||||
className={`${styles["preview-body"]} ${styles["default-theme"]}`}
|
||||
ref={previewRef}
|
||||
>
|
||||
<div className={styles["chat-info"]}>
|
||||
<div className={styles["logo"] + " no-dark"}>
|
||||
<NextImage
|
||||
src={ChatGptIcon.src}
|
||||
alt="logo"
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles["main-title"]}>ChatGPT Next Web</div>
|
||||
<div className={styles["sub-title"]}>
|
||||
github.com/Yidadaa/ChatGPT-Next-Web
|
||||
</div>
|
||||
<div className={styles["icons"]}>
|
||||
<ExportAvatar avatar={config.avatar} />
|
||||
<span className={styles["icon-space"]}>&</span>
|
||||
<ExportAvatar avatar={mask.avatar} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles["chat-info-item"]}>
|
||||
Model: {mask.modelConfig.model}
|
||||
</div>
|
||||
<div className={styles["chat-info-item"]}>
|
||||
Messages: {props.messages.length}
|
||||
</div>
|
||||
<div className={styles["chat-info-item"]}>
|
||||
Topic: {session.topic}
|
||||
</div>
|
||||
<div className={styles["chat-info-item"]}>
|
||||
Time:{" "}
|
||||
{new Date(
|
||||
props.messages.at(-1)?.date ?? Date.now(),
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{props.messages.map((m, i) => {
|
||||
return (
|
||||
<div
|
||||
className={styles["message"] + " " + styles["message-" + m.role]}
|
||||
key={i}
|
||||
>
|
||||
<div className={styles["avatar"]}>
|
||||
<ExportAvatar
|
||||
avatar={m.role === "user" ? config.avatar : mask.avatar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles["body"]}>
|
||||
<Markdown
|
||||
content={m.content}
|
||||
fontSize={config.fontSize}
|
||||
defaultShow
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownPreviewer(props: {
|
||||
messages: ChatMessage[];
|
||||
topic: string;
|
||||
}) {
|
||||
const mdText =
|
||||
`# ${props.topic}\n\n` +
|
||||
props.messages
|
||||
.map((m) => {
|
||||
return m.role === "user"
|
||||
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
|
||||
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const copy = () => {
|
||||
copyToClipboard(mdText);
|
||||
};
|
||||
const download = () => {
|
||||
downloadAs(mdText, `${props.topic}.md`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreviewActions
|
||||
copy={copy}
|
||||
download={download}
|
||||
messages={props.messages}
|
||||
/>
|
||||
<div className="markdown-body">
|
||||
<pre className={styles["export-content"]}>{mdText}</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
.message-selector {
|
||||
.message-filter {
|
||||
display: flex;
|
||||
|
||||
.search-bar {
|
||||
max-width: unset;
|
||||
flex-grow: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
|
||||
button:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
|
||||
.search-bar {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
margin-top: 20px;
|
||||
border-radius: 10px;
|
||||
border: var(--border-in-light);
|
||||
overflow: hidden;
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&-selected {
|
||||
background-color: var(--second);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: var(--border-in-light);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex-grow: 1;
|
||||
max-width: calc(100% - 40px);
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
||||
import { Updater } from "../typing";
|
||||
import { IconButton } from "./button";
|
||||
import { Avatar } from "./emoji";
|
||||
import { MaskAvatar } from "./mask";
|
||||
import Locale from "../locales";
|
||||
|
||||
import styles from "./message-selector.module.scss";
|
||||
|
||||
function useShiftRange() {
|
||||
const [startIndex, setStartIndex] = useState<number>();
|
||||
const [endIndex, setEndIndex] = useState<number>();
|
||||
const [shiftDown, setShiftDown] = useState(false);
|
||||
|
||||
const onClickIndex = (index: number) => {
|
||||
if (shiftDown && startIndex !== undefined) {
|
||||
setEndIndex(index);
|
||||
} else {
|
||||
setStartIndex(index);
|
||||
setEndIndex(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Shift") return;
|
||||
setShiftDown(true);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Shift") return;
|
||||
setShiftDown(false);
|
||||
setStartIndex(undefined);
|
||||
setEndIndex(undefined);
|
||||
};
|
||||
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
onClickIndex,
|
||||
startIndex,
|
||||
endIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMessageSelector() {
|
||||
const [selection, setSelection] = useState(new Set<number>());
|
||||
const updateSelection: Updater<Set<number>> = (updater) => {
|
||||
const newSelection = new Set<number>(selection);
|
||||
updater(newSelection);
|
||||
setSelection(newSelection);
|
||||
};
|
||||
|
||||
return {
|
||||
selection,
|
||||
updateSelection,
|
||||
};
|
||||
}
|
||||
|
||||
export function MessageSelector(props: {
|
||||
selection: Set<number>;
|
||||
updateSelection: Updater<Set<number>>;
|
||||
defaultSelectAll?: boolean;
|
||||
onSelected?: (messages: ChatMessage[]) => void;
|
||||
}) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
|
||||
const messages = session.messages.filter(
|
||||
(m, i) =>
|
||||
m.id && // message must have id
|
||||
isValid(m) &&
|
||||
(i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
|
||||
);
|
||||
const messageCount = messages.length;
|
||||
const config = useAppConfig();
|
||||
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchIds, setSearchIds] = useState(new Set<number>());
|
||||
const isInSearchResult = (id: number) => {
|
||||
return searchInput.length === 0 || searchIds.has(id);
|
||||
};
|
||||
const doSearch = (text: string) => {
|
||||
const searchResults = new Set<number>();
|
||||
if (text.length > 0) {
|
||||
messages.forEach((m) =>
|
||||
m.content.includes(text) ? searchResults.add(m.id!) : null,
|
||||
);
|
||||
}
|
||||
setSearchIds(searchResults);
|
||||
};
|
||||
|
||||
// for range selection
|
||||
const { startIndex, endIndex, onClickIndex } = useShiftRange();
|
||||
|
||||
const selectAll = () => {
|
||||
props.updateSelection((selection) =>
|
||||
messages.forEach((m) => selection.add(m.id!)),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.defaultSelectAll) {
|
||||
selectAll();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (startIndex === undefined || endIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const [start, end] = [startIndex, endIndex].sort((a, b) => a - b);
|
||||
props.updateSelection((selection) => {
|
||||
for (let i = start; i <= end; i += 1) {
|
||||
selection.add(messages[i].id ?? i);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [startIndex, endIndex]);
|
||||
|
||||
const LATEST_COUNT = 4;
|
||||
|
||||
return (
|
||||
<div className={styles["message-selector"]}>
|
||||
<div className={styles["message-filter"]}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={Locale.Select.Search}
|
||||
className={styles["filter-item"] + " " + styles["search-bar"]}
|
||||
value={searchInput}
|
||||
onInput={(e) => {
|
||||
setSearchInput(e.currentTarget.value);
|
||||
doSearch(e.currentTarget.value);
|
||||
}}
|
||||
></input>
|
||||
|
||||
<div className={styles["actions"]}>
|
||||
<IconButton
|
||||
text={Locale.Select.All}
|
||||
bordered
|
||||
className={styles["filter-item"]}
|
||||
onClick={selectAll}
|
||||
/>
|
||||
<IconButton
|
||||
text={Locale.Select.Latest}
|
||||
bordered
|
||||
className={styles["filter-item"]}
|
||||
onClick={() =>
|
||||
props.updateSelection((selection) => {
|
||||
selection.clear();
|
||||
messages
|
||||
.slice(messageCount - LATEST_COUNT)
|
||||
.forEach((m) => selection.add(m.id!));
|
||||
})
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
text={Locale.Select.Clear}
|
||||
bordered
|
||||
className={styles["filter-item"]}
|
||||
onClick={() =>
|
||||
props.updateSelection((selection) => selection.clear())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["messages"]}>
|
||||
{messages.map((m, i) => {
|
||||
if (!isInSearchResult(m.id!)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles["message"]} ${
|
||||
props.selection.has(m.id!) && styles["message-selected"]
|
||||
}`}
|
||||
key={i}
|
||||
onClick={() => {
|
||||
props.updateSelection((selection) => {
|
||||
const id = m.id ?? i;
|
||||
selection.has(id) ? selection.delete(id) : selection.add(id);
|
||||
});
|
||||
onClickIndex(i);
|
||||
}}
|
||||
>
|
||||
<div className={styles["avatar"]}>
|
||||
{m.role === "user" ? (
|
||||
<Avatar avatar={config.avatar}></Avatar>
|
||||
) : (
|
||||
<MaskAvatar mask={session.mask} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles["body"]}>
|
||||
<div className={styles["date"]}>
|
||||
{new Date(m.date).toLocaleString()}
|
||||
</div>
|
||||
<div className={`${styles["content"]} one-line`}>
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { BuildConfig, getBuildConfig } from "./build";
|
||||
|
||||
export function getClientConfig() {
|
||||
if (typeof document !== "undefined") {
|
||||
// client side
|
||||
return JSON.parse(queryMeta("config")) as BuildConfig;
|
||||
}
|
||||
|
||||
if (typeof process !== "undefined") {
|
||||
// server side
|
||||
return getBuildConfig();
|
||||
}
|
||||
}
|
||||
|
||||
function queryMeta(key: string, defaultValue?: string): string {
|
||||
let ret: string;
|
||||
if (document) {
|
||||
const meta = document.head.querySelector(
|
||||
`meta[name='${key}']`,
|
||||
) as HTMLMetaElement;
|
||||
ret = meta?.content ?? "";
|
||||
} else {
|
||||
ret = defaultValue ?? "";
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 736 B After Width: | Height: | Size: 706 B |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 4.5 KiB |
After Width: | Height: | Size: 9.0 KiB |
After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 1010 B After Width: | Height: | Size: 981 B |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 555 B |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 573 B After Width: | Height: | Size: 553 B |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1020 B |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 740 B After Width: | Height: | Size: 645 B |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,237 @@
|
||||
import { SubmitKey } from "../store/config";
|
||||
import type { LocaleType } from "./index";
|
||||
|
||||
const fr: LocaleType = {
|
||||
WIP: "Prochainement...",
|
||||
Error: {
|
||||
Unauthorized:
|
||||
"Accès non autorisé, veuillez saisir le code d'accès dans la page des paramètres.",
|
||||
},
|
||||
ChatItem: {
|
||||
ChatItemCount: (count: number) => `${count} messages en total`,
|
||||
},
|
||||
Chat: {
|
||||
SubTitle: (count: number) => `${count} messages échangés avec ChatGPT`,
|
||||
Actions: {
|
||||
ChatList: "Aller à la liste de discussion",
|
||||
CompressedHistory: "Mémoire d'historique compressée Prompt",
|
||||
Export: "Exporter tous les messages en tant que Markdown",
|
||||
Copy: "Copier",
|
||||
Stop: "Arrêter",
|
||||
Retry: "Réessayer",
|
||||
Delete: "Supprimer",
|
||||
},
|
||||
Rename: "Renommer la conversation",
|
||||
Typing: "En train d'écrire…",
|
||||
Input: (submitKey: string) => {
|
||||
var inputHints = `Appuyez sur ${submitKey} pour envoyer`;
|
||||
if (submitKey === String(SubmitKey.Enter)) {
|
||||
inputHints += ", Shift + Enter pour insérer un saut de ligne";
|
||||
}
|
||||
return inputHints + ", / pour rechercher des prompts";
|
||||
},
|
||||
Send: "Envoyer",
|
||||
Config: {
|
||||
Reset: "Restaurer les paramètres par défaut",
|
||||
SaveAs: "Enregistrer en tant que masque",
|
||||
},
|
||||
},
|
||||
Export: {
|
||||
Title: "Tous les messages",
|
||||
Copy: "Tout sélectionner",
|
||||
Download: "Télécharger",
|
||||
MessageFromYou: "Message de votre part",
|
||||
MessageFromChatGPT: "Message de ChatGPT",
|
||||
},
|
||||
Memory: {
|
||||
Title: "Prompt mémoire",
|
||||
EmptyContent: "Rien encore.",
|
||||
Send: "Envoyer la mémoire",
|
||||
Copy: "Copier la mémoire",
|
||||
Reset: "Réinitialiser la session",
|
||||
ResetConfirm:
|
||||
"La réinitialisation supprimera l'historique de la conversation actuelle ainsi que la mémoire de l'historique. Êtes-vous sûr de vouloir procéder à la réinitialisation?",
|
||||
},
|
||||
Home: {
|
||||
NewChat: "Nouvelle discussion",
|
||||
DeleteChat: "Confirmer la suppression de la conversation sélectionnée ?",
|
||||
DeleteToast: "Conversation supprimée",
|
||||
Revert: "Revenir en arrière",
|
||||
},
|
||||
Settings: {
|
||||
Title: "Paramètres",
|
||||
SubTitle: "Toutes les configurations",
|
||||
Actions: {
|
||||
ClearAll: "Effacer toutes les données",
|
||||
ResetAll: "Réinitialiser les configurations",
|
||||
Close: "Fermer",
|
||||
ConfirmResetAll:
|
||||
"Êtes-vous sûr de vouloir réinitialiser toutes les configurations?",
|
||||
ConfirmClearAll: "Êtes-vous sûr de vouloir supprimer toutes les données?",
|
||||
},
|
||||
Lang: {
|
||||
Name: "Language", // ATTENTION : si vous souhaitez ajouter une nouvelle traduction, ne traduisez pas cette valeur, laissez-la sous forme de `Language`
|
||||
All: "Toutes les langues",
|
||||
},
|
||||
|
||||
Avatar: "Avatar",
|
||||
FontSize: {
|
||||
Title: "Taille des polices",
|
||||
SubTitle: "Ajuste la taille de police du contenu de la conversation",
|
||||
},
|
||||
Update: {
|
||||
Version: (x: string) => `Version : ${x}`,
|
||||
IsLatest: "Dernière version",
|
||||
CheckUpdate: "Vérifier la mise à jour",
|
||||
IsChecking: "Vérification de la mise à jour...",
|
||||
FoundUpdate: (x: string) => `Nouvelle version disponible : ${x}`,
|
||||
GoToUpdate: "Mise à jour",
|
||||
},
|
||||
SendKey: "Clé d'envoi",
|
||||
Theme: "Thème",
|
||||
TightBorder: "Bordure serrée",
|
||||
SendPreviewBubble: {
|
||||
Title: "Aperçu de l'envoi dans une bulle",
|
||||
SubTitle: "Aperçu du Markdown dans une bulle",
|
||||
},
|
||||
Mask: {
|
||||
Title: "Écran de masque",
|
||||
SubTitle:
|
||||
"Afficher un écran de masque avant de démarrer une nouvelle discussion",
|
||||
},
|
||||
Prompt: {
|
||||
Disable: {
|
||||
Title: "Désactiver la saisie semi-automatique",
|
||||
SubTitle: "Appuyez sur / pour activer la saisie semi-automatique",
|
||||
},
|
||||
List: "Liste de prompts",
|
||||
ListCount: (builtin: number, custom: number) =>
|
||||
`${builtin} intégré, ${custom} personnalisé`,
|
||||
Edit: "Modifier",
|
||||
Modal: {
|
||||
Title: "Liste de prompts",
|
||||
Add: "Ajouter un élément",
|
||||
Search: "Rechercher des prompts",
|
||||
},
|
||||
EditModal: {
|
||||
Title: "Modifier le prompt",
|
||||
},
|
||||
},
|
||||
HistoryCount: {
|
||||
Title: "Nombre de messages joints",
|
||||
SubTitle: "Nombre de messages envoyés attachés par demande",
|
||||
},
|
||||
CompressThreshold: {
|
||||
Title: "Seuil de compression de l'historique",
|
||||
SubTitle:
|
||||
"Comprimera si la longueur des messages non compressés dépasse cette valeur",
|
||||
},
|
||||
Token: {
|
||||
Title: "Clé API",
|
||||
SubTitle: "Utilisez votre clé pour ignorer la limite du code d'accès",
|
||||
Placeholder: "Clé OpenAI API",
|
||||
},
|
||||
Usage: {
|
||||
Title: "Solde du compte",
|
||||
SubTitle(used: any, total: any) {
|
||||
return `Épuisé ce mois-ci $${used}, abonnement $${total}`;
|
||||
},
|
||||
IsChecking: "Vérification...",
|
||||
Check: "Vérifier",
|
||||
NoAccess: "Entrez la clé API pour vérifier le solde",
|
||||
},
|
||||
AccessCode: {
|
||||
Title: "Code d'accès",
|
||||
SubTitle: "Contrôle d'accès activé",
|
||||
Placeholder: "Code d'accès requis",
|
||||
},
|
||||
Model: "Modèle",
|
||||
Temperature: {
|
||||
Title: "Température",
|
||||
SubTitle: "Une valeur plus élevée rendra les réponses plus aléatoires",
|
||||
},
|
||||
MaxTokens: {
|
||||
Title: "Max Tokens",
|
||||
SubTitle: "Longueur maximale des tokens d'entrée et des tokens générés",
|
||||
},
|
||||
PresencePenalty: {
|
||||
Title: "Pénalité de présence",
|
||||
SubTitle:
|
||||
"Une valeur plus élevée augmentera la probabilité d'introduire de nouveaux sujets",
|
||||
},
|
||||
},
|
||||
Store: {
|
||||
DefaultTopic: "Nouvelle conversation",
|
||||
BotHello: "Bonjour ! Comment puis-je vous aider aujourd'hui ?",
|
||||
Error: "Quelque chose s'est mal passé, veuillez réessayer plus tard.",
|
||||
Prompt: {
|
||||
History: (content: string) =>
|
||||
"Ceci est un résumé de l'historique des discussions entre l'IA et l'utilisateur : " +
|
||||
content,
|
||||
Topic:
|
||||
"Veuillez générer un titre de quatre à cinq mots résumant notre conversation sans introduction, ponctuation, guillemets, points, symboles ou texte supplémentaire. Supprimez les guillemets inclus.",
|
||||
Summarize:
|
||||
"Résumez brièvement nos discussions en 200 mots ou moins pour les utiliser comme prompt de contexte futur.",
|
||||
},
|
||||
},
|
||||
Copy: {
|
||||
Success: "Copié dans le presse-papiers",
|
||||
Failed:
|
||||
"La copie a échoué, veuillez accorder l'autorisation d'accès au presse-papiers",
|
||||
},
|
||||
Context: {
|
||||
Toast: (x: any) => `Avec ${x} contextes de prompts`,
|
||||
Edit: "Contextes et mémoires de prompts",
|
||||
Add: "Ajouter un prompt",
|
||||
},
|
||||
Plugin: {
|
||||
Name: "Extension",
|
||||
},
|
||||
Mask: {
|
||||
Name: "Masque",
|
||||
Page: {
|
||||
Title: "Modèle de prompt",
|
||||
SubTitle: (count: number) => `${count} modèles de prompts`,
|
||||
Search: "Rechercher des modèles",
|
||||
Create: "Créer",
|
||||
},
|
||||
Item: {
|
||||
Info: (count: number) => `${count} prompts`,
|
||||
Chat: "Discussion",
|
||||
View: "Vue",
|
||||
Edit: "Modifier",
|
||||
Delete: "Supprimer",
|
||||
DeleteConfirm: "Confirmer la suppression?",
|
||||
},
|
||||
EditModal: {
|
||||
Title: (readonly: boolean) =>
|
||||
`Modifier le modèle de prompt ${readonly ? "(en lecture seule)" : ""}`,
|
||||
Download: "Télécharger",
|
||||
Clone: "Dupliquer",
|
||||
},
|
||||
Config: {
|
||||
Avatar: "Avatar du bot",
|
||||
Name: "Nom du bot",
|
||||
},
|
||||
},
|
||||
NewChat: {
|
||||
Return: "Retour",
|
||||
Skip: "Passer",
|
||||
Title: "Choisir un masque",
|
||||
SubTitle: "Discutez avec l'âme derrière le masque",
|
||||
More: "En savoir plus",
|
||||
NotShow: "Ne pas afficher à nouveau",
|
||||
ConfirmNoShow:
|
||||
"Confirmez-vous vouloir désactiver cela? Vous pouvez le réactiver plus tard dans les paramètres.",
|
||||
},
|
||||
|
||||
UI: {
|
||||
Confirm: "Confirmer",
|
||||
Cancel: "Annuler",
|
||||
Close: "Fermer",
|
||||
Create: "Créer",
|
||||
Edit: "Éditer",
|
||||
},
|
||||
};
|
||||
|
||||
export default fr;
|
@ -0,0 +1,230 @@
|
||||
import { SubmitKey } from "../store/config";
|
||||
|
||||
import type { LocaleType } from "./index";
|
||||
|
||||
const ko: LocaleType = {
|
||||
WIP: "곧 출시 예정...",
|
||||
Error: {
|
||||
Unauthorized: "권한이 없습니다. 설정 페이지에서 액세스 코드를 입력하세요.",
|
||||
},
|
||||
ChatItem: {
|
||||
ChatItemCount: (count: number) => `${count}개의 메시지`,
|
||||
},
|
||||
Chat: {
|
||||
SubTitle: (count: number) => `ChatGPT와의 ${count}개의 메시지`,
|
||||
Actions: {
|
||||
ChatList: "채팅 목록으로 이동",
|
||||
CompressedHistory: "압축된 기억력 메모리 프롬프트",
|
||||
Export: "모든 메시지를 Markdown으로 내보내기",
|
||||
Copy: "복사",
|
||||
Stop: "중지",
|
||||
Retry: "다시 시도",
|
||||
Delete: "삭제",
|
||||
},
|
||||
Rename: "채팅 이름 변경",
|
||||
Typing: "입력 중...",
|
||||
Input: (submitKey: string) => {
|
||||
var inputHints = `${submitKey}를 눌러 보내기`;
|
||||
if (submitKey === String(SubmitKey.Enter)) {
|
||||
inputHints += ", Shift + Enter로 줄 바꿈";
|
||||
}
|
||||
return inputHints + ", 프롬프트 검색을 위해 / 입력";
|
||||
},
|
||||
Send: "보내기",
|
||||
Config: {
|
||||
Reset: "기본값으로 재설정",
|
||||
SaveAs: "마스크로 저장",
|
||||
},
|
||||
},
|
||||
Export: {
|
||||
Title: "모든 메시지",
|
||||
Copy: "모두 복사",
|
||||
Download: "다운로드",
|
||||
MessageFromYou: "나의 메시지",
|
||||
MessageFromChatGPT: "ChatGPT의 메시지",
|
||||
},
|
||||
Memory: {
|
||||
Title: "기억 프롬프트",
|
||||
EmptyContent: "아직 내용이 없습니다.",
|
||||
Send: "기억 보내기",
|
||||
Copy: "기억 복사",
|
||||
Reset: "세션 재설정",
|
||||
ResetConfirm:
|
||||
"재설정하면 현재 대화 기록과 기억력이 삭제됩니다. 정말 재설정하시겠습니까?",
|
||||
},
|
||||
Home: {
|
||||
NewChat: "새로운 채팅",
|
||||
DeleteChat: "선택한 대화를 삭제하시겠습니까?",
|
||||
DeleteToast: "채팅이 삭제되었습니다.",
|
||||
Revert: "되돌리기",
|
||||
},
|
||||
Settings: {
|
||||
Title: "설정",
|
||||
SubTitle: "모든 설정",
|
||||
Actions: {
|
||||
ClearAll: "모든 데이터 지우기",
|
||||
ResetAll: "모든 설정 초기화",
|
||||
Close: "닫기",
|
||||
ConfirmResetAll: "모든 설정을 초기화하시겠습니까?",
|
||||
ConfirmClearAll: "모든 데이터를 지우시겠습니까?",
|
||||
},
|
||||
Lang: {
|
||||
Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
|
||||
All: "All Languages",
|
||||
},
|
||||
Avatar: "아바타",
|
||||
FontSize: {
|
||||
Title: "글꼴 크기",
|
||||
SubTitle: "채팅 내용의 글꼴 크기 조정",
|
||||
},
|
||||
Update: {
|
||||
Version: (x: string) => `버전: ${x}`,
|
||||
IsLatest: "최신 버전",
|
||||
CheckUpdate: "업데이트 확인",
|
||||
IsChecking: "업데이트 확인 중...",
|
||||
FoundUpdate: (x: string) => `새 버전 발견: ${x}`,
|
||||
GoToUpdate: "업데이트",
|
||||
},
|
||||
SendKey: "전송 키",
|
||||
Theme: "테마",
|
||||
TightBorder: "조밀한 테두리",
|
||||
SendPreviewBubble: {
|
||||
Title: "미리 보기 버블 전송",
|
||||
SubTitle: "버블에서 마크다운 미리 보기",
|
||||
},
|
||||
Mask: {
|
||||
Title: "마스크 시작 화면",
|
||||
SubTitle: "새로운 채팅 시작 전에 마스크 시작 화면 표시",
|
||||
},
|
||||
Prompt: {
|
||||
Disable: {
|
||||
Title: "자동 완성 비활성화",
|
||||
SubTitle: "자동 완성을 활성화하려면 /를 입력하세요.",
|
||||
},
|
||||
List: "프롬프트 목록",
|
||||
ListCount: (builtin: number, custom: number) =>
|
||||
`내장 ${builtin}개, 사용자 정의 ${custom}개`,
|
||||
Edit: "편집",
|
||||
Modal: {
|
||||
Title: "프롬프트 목록",
|
||||
Add: "추가",
|
||||
Search: "프롬프트 검색",
|
||||
},
|
||||
EditModal: {
|
||||
Title: "프롬프트 편집",
|
||||
},
|
||||
},
|
||||
HistoryCount: {
|
||||
Title: "첨부된 메시지 수",
|
||||
SubTitle: "요청당 첨부된 전송된 메시지 수",
|
||||
},
|
||||
CompressThreshold: {
|
||||
Title: "기록 압축 임계값",
|
||||
SubTitle: "미압축 메시지 길이가 임계값을 초과하면 압축됨",
|
||||
},
|
||||
Token: {
|
||||
Title: "API 키",
|
||||
SubTitle: "액세스 코드 제한을 무시하기 위해 키 사용",
|
||||
Placeholder: "OpenAI API 키",
|
||||
},
|
||||
Usage: {
|
||||
Title: "계정 잔액",
|
||||
SubTitle(used: any, total: any) {
|
||||
return `이번 달 사용액 ${used}, 구독액 ${total}`;
|
||||
},
|
||||
IsChecking: "확인 중...",
|
||||
Check: "확인",
|
||||
NoAccess: "잔액 확인을 위해 API 키를 입력하세요.",
|
||||
},
|
||||
AccessCode: {
|
||||
Title: "액세스 코드",
|
||||
SubTitle: "액세스 제어가 활성화됨",
|
||||
Placeholder: "액세스 코드 입력",
|
||||
},
|
||||
Model: "모델",
|
||||
Temperature: {
|
||||
Title: "온도 (temperature)",
|
||||
SubTitle: "값이 클수록 더 무작위한 출력이 생성됩니다.",
|
||||
},
|
||||
MaxTokens: {
|
||||
Title: "최대 토큰 수 (max_tokens)",
|
||||
SubTitle: "입력 토큰과 생성된 토큰의 최대 길이",
|
||||
},
|
||||
PresencePenalty: {
|
||||
Title: "존재 페널티 (presence_penalty)",
|
||||
SubTitle: "값이 클수록 새로운 주제에 대해 대화할 가능성이 높아집니다.",
|
||||
},
|
||||
},
|
||||
Store: {
|
||||
DefaultTopic: "새 대화",
|
||||
BotHello: "안녕하세요! 오늘 도움이 필요하신가요?",
|
||||
Error: "문제가 발생했습니다. 나중에 다시 시도해주세요.",
|
||||
Prompt: {
|
||||
History: (content: string) =>
|
||||
"이것은 AI와 사용자 간의 대화 기록을 요약한 내용입니다: " + content,
|
||||
Topic:
|
||||
"다음과 같이 대화 내용을 요약하는 4~5단어 제목을 생성해주세요. 따옴표, 구두점, 인용부호, 기호 또는 추가 텍스트를 제거하십시오. 따옴표로 감싸진 부분을 제거하십시오.",
|
||||
Summarize:
|
||||
"200단어 이내로 저희 토론을 간략히 요약하여 앞으로의 맥락으로 사용할 수 있는 프롬프트로 만들어주세요.",
|
||||
},
|
||||
},
|
||||
Copy: {
|
||||
Success: "클립보드에 복사되었습니다.",
|
||||
Failed: "복사 실패, 클립보드 접근 권한을 허용해주세요.",
|
||||
},
|
||||
Context: {
|
||||
Toast: (x: any) => `컨텍스트 프롬프트 ${x}개 사용`,
|
||||
Edit: "컨텍스트 및 메모리 프롬프트",
|
||||
Add: "프롬프트 추가",
|
||||
},
|
||||
Plugin: {
|
||||
Name: "플러그인",
|
||||
},
|
||||
Mask: {
|
||||
Name: "마스크",
|
||||
Page: {
|
||||
Title: "프롬프트 템플릿",
|
||||
SubTitle: (count: number) => `${count}개의 프롬프트 템플릿`,
|
||||
Search: "템플릿 검색",
|
||||
Create: "생성",
|
||||
},
|
||||
Item: {
|
||||
Info: (count: number) => `${count}개의 프롬프롬프트`,
|
||||
Chat: "채팅",
|
||||
View: "보기",
|
||||
Edit: "편집",
|
||||
Delete: "삭제",
|
||||
DeleteConfirm: "삭제하시겠습니까?",
|
||||
},
|
||||
EditModal: {
|
||||
Title: (readonly: boolean) =>
|
||||
`프롬프트 템플릿 편집 ${readonly ? "(읽기 전용)" : ""}`,
|
||||
Download: "다운로드",
|
||||
Clone: "복제",
|
||||
},
|
||||
Config: {
|
||||
Avatar: "봇 아바타",
|
||||
Name: "봇 이름",
|
||||
},
|
||||
},
|
||||
NewChat: {
|
||||
Return: "돌아가기",
|
||||
Skip: "건너뛰기",
|
||||
Title: "마스크 선택",
|
||||
SubTitle: "마스크 뒤의 영혼과 대화하세요",
|
||||
More: "더 보기",
|
||||
NotShow: "다시 표시하지 않음",
|
||||
ConfirmNoShow:
|
||||
"비활성화하시겠습니까? 나중에 설정에서 다시 활성화할 수 있습니다.",
|
||||
},
|
||||
|
||||
UI: {
|
||||
Confirm: "확인",
|
||||
Cancel: "취소",
|
||||
Close: "닫기",
|
||||
Create: "생성",
|
||||
Edit: "편집",
|
||||
},
|
||||
};
|
||||
|
||||
export default ko;
|
@ -1,3 +1,5 @@
|
||||
import { type Mask } from "../store/mask";
|
||||
|
||||
export type BuiltinMask = Omit<Mask, "id">;
|
||||
export type BuiltinMask = Omit<Mask, "id"> & {
|
||||
builtin: true;
|
||||
};
|
||||
|
@ -1,285 +0,0 @@
|
||||
import type { ChatRequest, ChatResponse } from "./api/openai/typing";
|
||||
import {
|
||||
Message,
|
||||
ModelConfig,
|
||||
ModelType,
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
} from "./store";
|
||||
import { showToast } from "./components/ui-lib";
|
||||
import { ACCESS_CODE_PREFIX } from "./constant";
|
||||
|
||||
const TIME_OUT_MS = 60000;
|
||||
|
||||
const makeRequestParam = (
|
||||
messages: Message[],
|
||||
options?: {
|
||||
stream?: boolean;
|
||||
overrideModel?: ModelType;
|
||||
},
|
||||
): ChatRequest => {
|
||||
let sendMessages = messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: v.content,
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
};
|
||||
|
||||
// override model config
|
||||
if (options?.overrideModel) {
|
||||
modelConfig.model = options.overrideModel;
|
||||
}
|
||||
|
||||
return {
|
||||
messages: sendMessages,
|
||||
stream: options?.stream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
};
|
||||
};
|
||||
|
||||
function getHeaders() {
|
||||
const accessStore = useAccessStore.getState();
|
||||
let headers: Record<string, string> = {};
|
||||
|
||||
const makeBearer = (token: string) => `Bearer ${token.trim()}`;
|
||||
const validString = (x: string) => x && x.length > 0;
|
||||
|
||||
// use user's api key first
|
||||
if (validString(accessStore.token)) {
|
||||
headers.Authorization = makeBearer(accessStore.token);
|
||||
} else if (
|
||||
accessStore.enabledAccessControl() &&
|
||||
validString(accessStore.accessCode)
|
||||
) {
|
||||
headers.Authorization = makeBearer(
|
||||
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
||||
);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function requestOpenaiClient(path: string) {
|
||||
const openaiUrl = useAccessStore.getState().openaiUrl;
|
||||
return (body: any, method = "POST") =>
|
||||
fetch(openaiUrl + path, {
|
||||
method,
|
||||
body: body && JSON.stringify(body),
|
||||
headers: getHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestChat(
|
||||
messages: Message[],
|
||||
options?: {
|
||||
model?: ModelType;
|
||||
},
|
||||
) {
|
||||
const req: ChatRequest = makeRequestParam(messages, {
|
||||
overrideModel: options?.model,
|
||||
});
|
||||
|
||||
const res = await requestOpenaiClient("v1/chat/completions")(req);
|
||||
|
||||
try {
|
||||
const response = (await res.json()) as ChatResponse;
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("[Request Chat] ", error, res.body);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestUsage() {
|
||||
const formatDate = (d: Date) =>
|
||||
`${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
|
||||
.getDate()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startDate = formatDate(startOfMonth);
|
||||
const endDate = formatDate(new Date(Date.now() + ONE_DAY));
|
||||
|
||||
const [used, subs] = await Promise.all([
|
||||
requestOpenaiClient(
|
||||
`dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`,
|
||||
)(null, "GET"),
|
||||
requestOpenaiClient("dashboard/billing/subscription")(null, "GET"),
|
||||
]);
|
||||
|
||||
const response = (await used.json()) as {
|
||||
total_usage?: number;
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
const total = (await subs.json()) as {
|
||||
hard_limit_usd?: number;
|
||||
};
|
||||
|
||||
if (response.error && response.error.type) {
|
||||
showToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.total_usage) {
|
||||
response.total_usage = Math.round(response.total_usage) / 100;
|
||||
}
|
||||
|
||||
if (total.hard_limit_usd) {
|
||||
total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
|
||||
}
|
||||
|
||||
return {
|
||||
used: response.total_usage,
|
||||
subscription: total.hard_limit_usd,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestChatStream(
|
||||
messages: Message[],
|
||||
options?: {
|
||||
modelConfig?: ModelConfig;
|
||||
overrideModel?: ModelType;
|
||||
onMessage: (message: string, done: boolean) => void;
|
||||
onError: (error: Error, statusCode?: number) => void;
|
||||
onController?: (controller: AbortController) => void;
|
||||
},
|
||||
) {
|
||||
const req = makeRequestParam(messages, {
|
||||
stream: true,
|
||||
overrideModel: options?.overrideModel,
|
||||
});
|
||||
|
||||
console.log("[Request] ", req);
|
||||
|
||||
const controller = new AbortController();
|
||||
const reqTimeoutId = setTimeout(() => controller.abort(), TIME_OUT_MS);
|
||||
|
||||
try {
|
||||
const openaiUrl = useAccessStore.getState().openaiUrl;
|
||||
const res = await fetch(openaiUrl + "v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getHeaders(),
|
||||
},
|
||||
body: JSON.stringify(req),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(reqTimeoutId);
|
||||
|
||||
let responseText = "";
|
||||
|
||||
const finish = () => {
|
||||
options?.onMessage(responseText, true);
|
||||
controller.abort();
|
||||
};
|
||||
|
||||
if (res.ok) {
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
options?.onController?.(controller);
|
||||
|
||||
while (true) {
|
||||
const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS);
|
||||
const content = await reader?.read();
|
||||
clearTimeout(resTimeoutId);
|
||||
|
||||
if (!content || !content.value) {
|
||||
break;
|
||||
}
|
||||
|
||||
const text = decoder.decode(content.value, { stream: true });
|
||||
responseText += text;
|
||||
|
||||
const done = content.done;
|
||||
options?.onMessage(responseText, false);
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
finish();
|
||||
} else if (res.status === 401) {
|
||||
console.error("Unauthorized");
|
||||
options?.onError(new Error("Unauthorized"), res.status);
|
||||
} else {
|
||||
console.error("Stream Error", res.body);
|
||||
options?.onError(new Error("Stream Error"), res.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("NetWork Error", err);
|
||||
options?.onError(err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestWithPrompt(
|
||||
messages: Message[],
|
||||
prompt: string,
|
||||
options?: {
|
||||
model?: ModelType;
|
||||
},
|
||||
) {
|
||||
messages = messages.concat([
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
date: new Date().toLocaleString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await requestChat(messages, options);
|
||||
|
||||
return res?.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
// To store message streaming controller
|
||||
export const ControllerPool = {
|
||||
controllers: {} as Record<string, AbortController>,
|
||||
|
||||
addController(
|
||||
sessionIndex: number,
|
||||
messageId: number,
|
||||
controller: AbortController,
|
||||
) {
|
||||
const key = this.key(sessionIndex, messageId);
|
||||
this.controllers[key] = controller;
|
||||
return key;
|
||||
},
|
||||
|
||||
stop(sessionIndex: number, messageId: number) {
|
||||
const key = this.key(sessionIndex, messageId);
|
||||
const controller = this.controllers[key];
|
||||
controller?.abort();
|
||||
},
|
||||
|
||||
stopAll() {
|
||||
Object.values(this.controllers).forEach((v) => v.abort());
|
||||
},
|
||||
|
||||
hasPending() {
|
||||
return Object.values(this.controllers).length > 0;
|
||||
},
|
||||
|
||||
remove(sessionIndex: number, messageId: number) {
|
||||
const key = this.key(sessionIndex, messageId);
|
||||
delete this.controllers[key];
|
||||
},
|
||||
|
||||
key(sessionIndex: number, messageIndex: number) {
|
||||
return `${sessionIndex},${messageIndex}`;
|
||||
},
|
||||
};
|
@ -0,0 +1 @@
|
||||
export type Updater<T> = (updater: (value: T) => void) => void;
|
@ -0,0 +1,13 @@
|
||||
export function prettyObject(msg: any) {
|
||||
const obj = msg;
|
||||
if (typeof msg !== "string") {
|
||||
msg = JSON.stringify(msg, null, " ");
|
||||
}
|
||||
if (msg === "{}") {
|
||||
return obj.toString();
|
||||
}
|
||||
if (msg.startsWith("```json")) {
|
||||
return msg;
|
||||
}
|
||||
return ["```json", msg, "```"].join("\n");
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
export function merge(target: any, source: any) {
|
||||
Object.keys(source).forEach(function (key) {
|
||||
if (source[key] && typeof source[key] === "object") {
|
||||
merge((target[key] = target[key] || {}), source[key]);
|
||||
return;
|
||||
}
|
||||
target[key] = source[key];
|
||||
});
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
export function estimateTokenLength(input: string): number {
|
||||
let tokenLength = 0;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const charCode = input.charCodeAt(i);
|
||||
|
||||
if (charCode < 128) {
|
||||
// ASCII character
|
||||
if (charCode <= 122 && charCode >= 65) {
|
||||
// a-Z
|
||||
tokenLength += 0.25;
|
||||
} else {
|
||||
tokenLength += 0.5;
|
||||
}
|
||||
} else {
|
||||
// Unicode character
|
||||
tokenLength += 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
return tokenLength;
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
chatgpt-next-web:
|
||||
profiles: ["no-proxy"]
|
||||
container_name: chatgpt-next-web
|
||||
image: yidadaa/chatgpt-next-web
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
- OPENAI_API_KEY=$OPENAI_API_KEY
|
||||
- CODE=$CODE
|
||||
- BASE_URL=$BASE_URL
|
||||
- OPENAI_ORG_ID=$OPENAI_ORG_ID
|
||||
- HIDE_USER_API_KEY=$HIDE_USER_API_KEY
|
||||
- DISABLE_GPT4=$DISABLE_GPT4
|
||||
|
||||
chatgpt-next-web-proxy:
|
||||
profiles: ["proxy"]
|
||||
container_name: chatgpt-next-web-proxy
|
||||
image: yidadaa/chatgpt-next-web
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
- OPENAI_API_KEY=$OPENAI_API_KEY
|
||||
- CODE=$CODE
|
||||
- PROXY_URL=$PROXY_URL
|
||||
- BASE_URL=$BASE_URL
|
||||
- OPENAI_ORG_ID=$OPENAI_ORG_ID
|
||||
- HIDE_USER_API_KEY=$HIDE_USER_API_KEY
|
||||
- DISABLE_GPT4=$DISABLE_GPT4
|
@ -0,0 +1,37 @@
|
||||
# Guía de implementación de Cloudflare Pages
|
||||
|
||||
## Cómo crear un nuevo proyecto
|
||||
|
||||
Bifurca el proyecto en Github, luego inicia sesión en dash.cloudflare.com y ve a Pages.
|
||||
|
||||
1. Haga clic en "Crear un proyecto".
|
||||
2. Selecciona Conectar a Git.
|
||||
3. Vincula páginas de Cloudflare a tu cuenta de GitHub.
|
||||
4. Seleccione este proyecto que bifurcó.
|
||||
5. Haga clic en "Comenzar configuración".
|
||||
6. Para "Nombre del proyecto" y "Rama de producción", puede utilizar los valores predeterminados o cambiarlos según sea necesario.
|
||||
7. En Configuración de compilación, seleccione la opción Ajustes preestablecidos de Framework y seleccione Siguiente.js.
|
||||
8. Debido a los errores de node:buffer, no use el "comando Construir" predeterminado por ahora. Utilice el siguiente comando:
|
||||
npx https://prerelease-registry.devprod.cloudflare.dev/next-on-pages/runs/4930842298/npm-package-next-on-pages-230 --experimental-minify
|
||||
9. Para "Generar directorio de salida", utilice los valores predeterminados y no los modifique.
|
||||
10. No modifique el "Directorio raíz".
|
||||
11. Para "Variables de entorno", haga clic en ">" y luego haga clic en "Agregar variable". Rellene la siguiente información:
|
||||
|
||||
* `NODE_VERSION=20.1`
|
||||
* `NEXT_TELEMETRY_DISABLE=1`
|
||||
* `OPENAI_API_KEY=你自己的API Key`
|
||||
* `YARN_VERSION=1.22.19`
|
||||
* `PHP_VERSION=7.4`
|
||||
|
||||
Dependiendo de sus necesidades reales, puede completar opcionalmente las siguientes opciones:
|
||||
|
||||
* `CODE= 可选填,访问密码,可以使用逗号隔开多个密码`
|
||||
* `OPENAI_ORG_ID= 可选填,指定 OpenAI 中的组织 ID`
|
||||
* `HIDE_USER_API_KEY=1 可选,不让用户自行填入 API Key`
|
||||
* `DISABLE_GPT4=1 可选,不让用户使用 GPT-4`
|
||||
12. Haga clic en "Guardar e implementar".
|
||||
13. Haga clic en "Cancelar implementación" porque necesita rellenar los indicadores de compatibilidad.
|
||||
14. Vaya a "Configuración de compilación", "Funciones" y busque "Indicadores de compatibilidad".
|
||||
15. Rellene "nodejs_compat" en "Configurar indicador de compatibilidad de producción" y "Configurar indicador de compatibilidad de vista previa".
|
||||
16. Vaya a "Implementaciones" y haga clic en "Reintentar implementación".
|
||||
17. Disfrutar.
|