mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-05-19 04:00:16 +09:00
Merge 0db6b6be56
into 3809375694
This commit is contained in:
commit
9eb15e09d4
179
app/components/TensorFlow.module.scss
Normal file
179
app/components/TensorFlow.module.scss
Normal file
@ -0,0 +1,179 @@
|
||||
.voiceRecognitionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.statusContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.statusIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
|
||||
&.idle {
|
||||
background-color: #888888;
|
||||
}
|
||||
|
||||
&.recording {
|
||||
background-color: #ff9800;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
&.training {
|
||||
background-color: #2196f3;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
&.recognizing {
|
||||
background-color: #9c27b0;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
&.trained {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
&.matched {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
&.not_matched {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: #f44336;
|
||||
}
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.visualizerContainer {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controlsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.trainingControls,
|
||||
.recognitionControls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 48%;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 10px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #cccccc;
|
||||
color: #666666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.resultContainer {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.scoreBar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.scoreIndicator {
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, #f44336, #ffeb3b, #4caf50);
|
||||
border-radius: 10px;
|
||||
transition: width 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.scoreValue {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(255, 255, 255, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
||||
}
|
506
app/components/TensorFlow.tsx
Normal file
506
app/components/TensorFlow.tsx
Normal file
@ -0,0 +1,506 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import * as tf from "@tensorflow/tfjs";
|
||||
import { VoicePrint } from "./voice-print/voice-print";
|
||||
import styles from "./TensorFlow.module.scss";
|
||||
|
||||
// 声纹识别状态
|
||||
enum VoiceRecognitionStatus {
|
||||
IDLE = "空闲",
|
||||
RECORDING = "录制中",
|
||||
TRAINING = "训练中",
|
||||
RECOGNIZING = "识别中",
|
||||
TRAINED = "已训练",
|
||||
MATCHED = "声纹匹配",
|
||||
NOT_MATCHED = "声纹不匹配",
|
||||
ERROR = "错误",
|
||||
}
|
||||
|
||||
// 声纹特征提取参数
|
||||
const SAMPLE_RATE = 16000; // 采样率
|
||||
const FFT_SIZE = 1024; // FFT大小
|
||||
const MEL_BINS = 40; // Mel滤波器数量
|
||||
const FRAME_LENGTH = 25; // 帧长度(ms)
|
||||
const FRAME_STEP = 10; // 帧步长(ms)
|
||||
const FEATURE_LENGTH = 100; // 特征序列长度
|
||||
|
||||
const TensorFlow: React.FC = () => {
|
||||
// 状态管理
|
||||
const [status, setStatus] = useState<VoiceRecognitionStatus>(
|
||||
VoiceRecognitionStatus.IDLE,
|
||||
);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [isRecording, setIsRecording] = useState<boolean>(false);
|
||||
const [isTrained, setIsTrained] = useState<boolean>(false);
|
||||
const [matchScore, setMatchScore] = useState<number>(0);
|
||||
const [frequencies, setFrequencies] = useState<Uint8Array | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// 引用
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const recordedChunksRef = useRef<Float32Array[]>([]);
|
||||
const modelRef = useRef<tf.LayersModel | null>(null);
|
||||
const voiceprintRef = useRef<Float32Array | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
// 检查是否有保存的声纹模型
|
||||
const savedVoiceprint = localStorage.getItem("userVoiceprint");
|
||||
if (savedVoiceprint) {
|
||||
try {
|
||||
voiceprintRef.current = new Float32Array(JSON.parse(savedVoiceprint));
|
||||
setIsTrained(true);
|
||||
setStatus(VoiceRecognitionStatus.TRAINED);
|
||||
setMessage("已加载保存的声纹模型");
|
||||
} catch (error) {
|
||||
console.error("加载保存的声纹模型失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载TensorFlow模型
|
||||
loadModel();
|
||||
|
||||
return () => {
|
||||
stopRecording();
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载声纹识别模型
|
||||
const loadModel = async () => {
|
||||
try {
|
||||
// 创建简单的声纹识别模型
|
||||
const model = tf.sequential();
|
||||
|
||||
// 添加卷积层处理音频特征
|
||||
model.add(
|
||||
tf.layers.conv1d({
|
||||
inputShape: [FEATURE_LENGTH, MEL_BINS],
|
||||
filters: 32,
|
||||
kernelSize: 3,
|
||||
activation: "relu",
|
||||
}),
|
||||
);
|
||||
|
||||
model.add(tf.layers.maxPooling1d({ poolSize: 2 }));
|
||||
|
||||
model.add(
|
||||
tf.layers.conv1d({
|
||||
filters: 64,
|
||||
kernelSize: 3,
|
||||
activation: "relu",
|
||||
}),
|
||||
);
|
||||
|
||||
model.add(tf.layers.maxPooling1d({ poolSize: 2 }));
|
||||
model.add(tf.layers.flatten());
|
||||
|
||||
// 添加全连接层
|
||||
model.add(tf.layers.dense({ units: 128, activation: "relu" }));
|
||||
model.add(tf.layers.dropout({ rate: 0.5 }));
|
||||
|
||||
// 输出层 - 声纹特征向量
|
||||
model.add(tf.layers.dense({ units: 64, activation: "linear" }));
|
||||
|
||||
// 编译模型
|
||||
model.compile({
|
||||
optimizer: "adam",
|
||||
loss: "meanSquaredError",
|
||||
});
|
||||
|
||||
modelRef.current = model;
|
||||
console.log("声纹识别模型已加载");
|
||||
} catch (error) {
|
||||
console.error("加载模型失败:", error);
|
||||
setStatus(VoiceRecognitionStatus.ERROR);
|
||||
setMessage("加载模型失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 开始录音
|
||||
const startRecording = async (isTraining: boolean = false) => {
|
||||
try {
|
||||
if (isRecording) return;
|
||||
|
||||
// 重置录音数据
|
||||
recordedChunksRef.current = [];
|
||||
|
||||
// 请求麦克风权限
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
// 创建音频上下文
|
||||
const audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
// 创建分析器节点用于可视化
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = FFT_SIZE;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
// 创建音频源
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
source.connect(analyser);
|
||||
|
||||
// 创建处理器节点
|
||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
// 处理音频数据
|
||||
processor.onaudioprocess = (e) => {
|
||||
const inputData = e.inputBuffer.getChannelData(0);
|
||||
recordedChunksRef.current.push(new Float32Array(inputData));
|
||||
};
|
||||
|
||||
// 连接节点
|
||||
analyser.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
// 更新状态
|
||||
setIsRecording(true);
|
||||
setStatus(
|
||||
isTraining
|
||||
? VoiceRecognitionStatus.RECORDING
|
||||
: VoiceRecognitionStatus.RECOGNIZING,
|
||||
);
|
||||
setMessage(
|
||||
isTraining ? "请说话3-5秒钟用于训练..." : "请说话进行声纹识别...",
|
||||
);
|
||||
|
||||
// 开始频谱可视化
|
||||
startVisualization();
|
||||
|
||||
// 设置自动停止录音(训练模式下5秒后自动停止)
|
||||
if (isTraining) {
|
||||
setTimeout(() => {
|
||||
stopRecording();
|
||||
trainVoiceprint();
|
||||
}, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("开始录音失败:", error);
|
||||
setStatus(VoiceRecognitionStatus.ERROR);
|
||||
setMessage("无法访问麦克风,请检查权限");
|
||||
}
|
||||
};
|
||||
|
||||
// 停止录音
|
||||
const stopRecording = () => {
|
||||
if (!isRecording) return;
|
||||
|
||||
// 停止所有音频流
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
|
||||
// 关闭音频上下文
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
// 停止可视化
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
|
||||
setIsRecording(false);
|
||||
setFrequencies(undefined);
|
||||
};
|
||||
|
||||
// 开始频谱可视化
|
||||
const startVisualization = () => {
|
||||
const analyser = analyserRef.current;
|
||||
if (!analyser) return;
|
||||
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
const updateVisualization = () => {
|
||||
if (!analyser) return;
|
||||
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
setFrequencies(dataArray);
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(updateVisualization);
|
||||
};
|
||||
|
||||
updateVisualization();
|
||||
};
|
||||
|
||||
// 提取音频特征
|
||||
const extractFeatures = async (
|
||||
audioData: Float32Array[],
|
||||
): Promise<tf.Tensor | null> => {
|
||||
try {
|
||||
// 合并所有音频块
|
||||
const mergedData = new Float32Array(
|
||||
audioData.reduce((acc, chunk) => acc + chunk.length, 0),
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of audioData) {
|
||||
mergedData.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// 转换为张量
|
||||
const audioTensor = tf.tensor1d(mergedData);
|
||||
|
||||
// 计算梅尔频谱图 (简化版)
|
||||
// 在实际应用中,这里应该使用更复杂的信号处理方法
|
||||
// 如MFCC (Mel-frequency cepstral coefficients)
|
||||
const frameLength = Math.round((SAMPLE_RATE * FRAME_LENGTH) / 1000);
|
||||
const frameStep = Math.round((SAMPLE_RATE * FRAME_STEP) / 1000);
|
||||
|
||||
// 使用短时傅里叶变换提取特征
|
||||
// 注意:这是简化版,实际应用中应使用专业的DSP库
|
||||
const frames = [];
|
||||
for (let i = 0; i + frameLength <= mergedData.length; i += frameStep) {
|
||||
const frame = mergedData.slice(i, i + frameLength);
|
||||
frames.push(Array.from(frame));
|
||||
}
|
||||
|
||||
// 限制帧数
|
||||
const limitedFrames = frames.slice(0, FEATURE_LENGTH);
|
||||
|
||||
// 如果帧数不足,用零填充
|
||||
while (limitedFrames.length < FEATURE_LENGTH) {
|
||||
limitedFrames.push(new Array(frameLength).fill(0));
|
||||
}
|
||||
|
||||
// 创建特征张量
|
||||
const featureTensor = tf.tensor(limitedFrames);
|
||||
|
||||
// 简化的梅尔频谱计算
|
||||
// 在实际应用中应使用更准确的方法
|
||||
const melSpectrogram = tf.tidy(() => {
|
||||
// 应用FFT (简化)
|
||||
const fftMag = featureTensor.abs();
|
||||
|
||||
// 降维到MEL_BINS
|
||||
const reshaped = fftMag.reshape([FEATURE_LENGTH, -1]);
|
||||
const melFeatures = reshaped.slice([0, 0], [FEATURE_LENGTH, MEL_BINS]);
|
||||
|
||||
// 归一化
|
||||
const normalized = melFeatures.div(tf.scalar(255.0));
|
||||
|
||||
return normalized.expandDims(0); // 添加批次维度
|
||||
});
|
||||
|
||||
return melSpectrogram;
|
||||
} catch (error) {
|
||||
console.error("特征提取失败:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 训练声纹模型
|
||||
const trainVoiceprint = async () => {
|
||||
if (recordedChunksRef.current.length === 0 || !modelRef.current) {
|
||||
setStatus(VoiceRecognitionStatus.ERROR);
|
||||
setMessage("没有录音数据或模型未加载");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(VoiceRecognitionStatus.TRAINING);
|
||||
setMessage("正在训练声纹模型...");
|
||||
|
||||
try {
|
||||
// 提取特征
|
||||
const features = await extractFeatures(recordedChunksRef.current);
|
||||
if (!features) throw new Error("特征提取失败");
|
||||
|
||||
// 使用模型提取声纹特征向量
|
||||
const voiceprint = tf.tidy(() => {
|
||||
// 前向传播获取声纹特征
|
||||
const prediction = modelRef.current!.predict(features) as tf.Tensor;
|
||||
// 归一化特征向量
|
||||
return tf.div(prediction, tf.norm(prediction));
|
||||
});
|
||||
|
||||
// 保存声纹特征
|
||||
const voiceprintData = await voiceprint.data();
|
||||
voiceprintRef.current = new Float32Array(voiceprintData);
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem(
|
||||
"userVoiceprint",
|
||||
JSON.stringify(Array.from(voiceprintData)),
|
||||
);
|
||||
|
||||
setIsTrained(true);
|
||||
setStatus(VoiceRecognitionStatus.TRAINED);
|
||||
setMessage("声纹模型训练完成并已保存");
|
||||
|
||||
// 清理
|
||||
voiceprint.dispose();
|
||||
features.dispose();
|
||||
} catch (error) {
|
||||
console.error("训练失败:", error);
|
||||
setStatus(VoiceRecognitionStatus.ERROR);
|
||||
setMessage("声纹训练失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 识别声纹
|
||||
const recognizeVoice = async () => {
|
||||
if (!isTrained || !voiceprintRef.current) {
|
||||
setStatus(VoiceRecognitionStatus.ERROR);
|
||||
setMessage("请先训练声纹模型");
|
||||
return;
|
||||
}
|
||||
|
||||
if (recordedChunksRef.current.length === 0 || !modelRef.current) {
|
||||
setStatus(VoiceRecognitionStatus.ERROR);
|
||||
setMessage("没有录音数据或模型未加载");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 提取特征
|
||||
const features = await extractFeatures(recordedChunksRef.current);
|
||||
if (!features) throw new Error("特征提取失败");
|
||||
|
||||
// 使用模型提取声纹特征向量
|
||||
const currentVoiceprint = tf.tidy(() => {
|
||||
// 前向传播获取声纹特征
|
||||
const prediction = modelRef.current!.predict(features) as tf.Tensor;
|
||||
// 归一化特征向量
|
||||
return tf.div(prediction, tf.norm(prediction));
|
||||
});
|
||||
|
||||
// 计算与保存的声纹的余弦相似度
|
||||
const similarity = tf.tidy(() => {
|
||||
const savedVoiceprint = tf.tensor1d(voiceprintRef.current!);
|
||||
// 计算点积
|
||||
const dotProduct = tf.sum(
|
||||
tf.mul(currentVoiceprint.reshape([-1]), savedVoiceprint),
|
||||
);
|
||||
return dotProduct;
|
||||
});
|
||||
|
||||
// 获取相似度分数 (范围从-1到1,越接近1表示越相似)
|
||||
const similarityScore = await similarity.data();
|
||||
const score = similarityScore[0];
|
||||
setMatchScore(score);
|
||||
|
||||
// 判断是否为同一人 (阈值可调整)
|
||||
const threshold = 0.7;
|
||||
const isMatch = score > threshold;
|
||||
|
||||
setStatus(
|
||||
isMatch
|
||||
? VoiceRecognitionStatus.MATCHED
|
||||
: VoiceRecognitionStatus.NOT_MATCHED,
|
||||
);
|
||||
setMessage(
|
||||
isMatch
|
||||
? `声纹匹配成功!相似度: ${(score * 100).toFixed(2)}%`
|
||||
: `声纹不匹配。相似度: ${(score * 100).toFixed(2)}%`,
|
||||
);
|
||||
|
||||
// 清理
|
||||
currentVoiceprint.dispose();
|
||||
features.dispose();
|
||||
similarity.dispose();
|
||||
} catch (error) {
|
||||
console.error("识别失败:", error);
|
||||
setStatus(VoiceRecognitionStatus.ERROR);
|
||||
setMessage("声纹识别失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 清除训练数据
|
||||
const clearTrainedData = () => {
|
||||
localStorage.removeItem("userVoiceprint");
|
||||
voiceprintRef.current = null;
|
||||
setIsTrained(false);
|
||||
setStatus(VoiceRecognitionStatus.IDLE);
|
||||
setMessage("声纹数据已清除");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.voiceRecognitionContainer}>
|
||||
<h2 className={styles.title}>声纹识别系统</h2>
|
||||
|
||||
<div className={styles.statusContainer}>
|
||||
<div className={styles.statusIndicator}>
|
||||
<div
|
||||
className={`${styles.statusDot} ${styles[status.toLowerCase()]}`}
|
||||
></div>
|
||||
<span className={styles.statusText}>{status}</span>
|
||||
</div>
|
||||
<p className={styles.message}>{message}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.visualizerContainer}>
|
||||
<VoicePrint frequencies={frequencies} isActive={isRecording} />
|
||||
</div>
|
||||
|
||||
<div className={styles.controlsContainer}>
|
||||
<div className={styles.trainingControls}>
|
||||
<h3>训练声纹</h3>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => startRecording(true)}
|
||||
disabled={isRecording}
|
||||
>
|
||||
录制训练音频
|
||||
</button>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={clearTrainedData}
|
||||
disabled={!isTrained}
|
||||
>
|
||||
清除训练数据
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.recognitionControls}>
|
||||
<h3>声纹识别</h3>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => startRecording(false)}
|
||||
disabled={isRecording || !isTrained}
|
||||
>
|
||||
开始录音
|
||||
</button>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
stopRecording();
|
||||
recognizeVoice();
|
||||
}}
|
||||
disabled={!isRecording}
|
||||
>
|
||||
停止并识别
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === VoiceRecognitionStatus.MATCHED ||
|
||||
status === VoiceRecognitionStatus.NOT_MATCHED ? (
|
||||
<div className={styles.resultContainer}>
|
||||
<div className={styles.scoreBar}>
|
||||
<div
|
||||
className={styles.scoreIndicator}
|
||||
style={{ width: `${Math.max(0, matchScore * 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className={styles.scoreValue}>
|
||||
相似度: {(matchScore * 100).toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TensorFlow;
|
64
app/components/WechatAuthor.module.scss
Normal file
64
app/components/WechatAuthor.module.scss
Normal file
@ -0,0 +1,64 @@
|
||||
.container {
|
||||
position: relative;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid var(--gray);
|
||||
}
|
||||
|
||||
.avatarContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin-right: 10px;
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--black);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.userId {
|
||||
font-size: 12px;
|
||||
color: var(--black-50);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 120px;
|
||||
background-color: var(--white);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--card-shadow);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
padding: 10px 15px;
|
||||
font-size: 14px;
|
||||
color: var(--black);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray);
|
||||
}
|
||||
}
|
113
app/components/WechatAuthor.tsx
Normal file
113
app/components/WechatAuthor.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import styles from "./WechatAuthor.module.scss";
|
||||
import { Path } from "../constant";
|
||||
import { useAccessStore } from "../store";
|
||||
import { safeLocalStorage } from "../utils";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
|
||||
interface WechatUserInfo {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export function WechatAuthor() {
|
||||
const navigate = useNavigate();
|
||||
const accessStore = useAccessStore();
|
||||
const storage = safeLocalStorage();
|
||||
const [userInfo, setUserInfo] = useState<WechatUserInfo | null>(null);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
// 加载用户信息
|
||||
useEffect(() => {
|
||||
const userInfoStr = storage.getItem("wechat_user_info");
|
||||
if (userInfoStr) {
|
||||
try {
|
||||
const parsedInfo = JSON.parse(userInfoStr);
|
||||
setUserInfo(parsedInfo);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse user info", e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理登出
|
||||
const handleLogout = async () => {
|
||||
const confirmed = await showConfirm("确定要退出登录吗?");
|
||||
if (confirmed) {
|
||||
// 清除登录信息
|
||||
storage.removeItem("wechat_user_info");
|
||||
|
||||
// 更新访问状态
|
||||
accessStore.update((access) => {
|
||||
access.accessToken = "";
|
||||
access.wechatLoggedIn = false;
|
||||
});
|
||||
|
||||
// 跳转到登录页
|
||||
navigate(Path.Home);
|
||||
}
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
// 如果没有用户信息,显示登录按钮
|
||||
if (!accessStore.wechatLoggedIn) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={styles.loginPrompt}
|
||||
onClick={() => navigate(Path.Login)}
|
||||
>
|
||||
点击登录
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={styles.avatarContainer}
|
||||
onClick={() => setShowMenu(true)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setShowMenu(true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={userInfo?.avatar}
|
||||
alt={userInfo?.nickname}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.nickname}>{userInfo?.nickname}</div>
|
||||
<div className={styles.userId}>ID: {userInfo?.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMenu && (
|
||||
<div className={styles.menu} ref={menuRef}>
|
||||
<div className={styles.menuItem} onClick={handleLogout}>
|
||||
退出登录
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
195
app/components/WechatLogin.module.scss
Normal file
195
app/components/WechatLogin.module.scss
Normal file
@ -0,0 +1,195 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: var(--gray);
|
||||
}
|
||||
|
||||
.loginCard {
|
||||
width: 400px;
|
||||
background-color: var(--white);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--black-50);
|
||||
}
|
||||
}
|
||||
|
||||
.qrcodeContainer {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loadingWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
p {
|
||||
margin-top: 15px;
|
||||
color: var(--black-50);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.qrcodeWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .qrcodeOverlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.qrcode {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
border: 1px solid var(--gray);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.qrcodeOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
border-radius: 8px;
|
||||
|
||||
p {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.qrcodeHint {
|
||||
margin-top: 15px;
|
||||
color: var(--black-50);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statusWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
color: var(--black);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expireHint {
|
||||
font-size: 12px;
|
||||
color: var(--black-50);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.refreshButton {
|
||||
padding: 8px 20px;
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--gray);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
174
app/components/WechatLogin.tsx
Normal file
174
app/components/WechatLogin.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import styles from "./WechatLogin.module.scss";
|
||||
import LoadingIcon from "../icons/loading.svg";
|
||||
// import QRCodeImage from "../icons/wechat-qrcode-mock.svg"; // 假设有一个模拟的二维码SVG
|
||||
import SuccessIcon from "../icons/confirm.svg";
|
||||
import ErrorIcon from "../icons/close.svg";
|
||||
import Locale from "../locales";
|
||||
import { useAccessStore } from "../store";
|
||||
import { safeLocalStorage } from "../utils";
|
||||
|
||||
// 登录状态枚举
|
||||
enum LoginStatus {
|
||||
LOADING = "loading",
|
||||
READY = "ready",
|
||||
SCANNED = "scanned",
|
||||
CONFIRMED = "confirmed",
|
||||
SUCCESS = "success",
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
export function WechatLogin() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<LoginStatus>(LoginStatus.LOADING);
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const accessStore = useAccessStore();
|
||||
const storage = safeLocalStorage();
|
||||
|
||||
// 模拟登录流程
|
||||
useEffect(() => {
|
||||
// 初始加载
|
||||
const timer1 = setTimeout(() => {
|
||||
setStatus(LoginStatus.READY);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 模拟二维码扫描和确认过程
|
||||
const simulateLogin = () => {
|
||||
// 模拟扫码
|
||||
setStatus(LoginStatus.SCANNED);
|
||||
|
||||
// 模拟确认
|
||||
setTimeout(() => {
|
||||
setStatus(LoginStatus.CONFIRMED);
|
||||
|
||||
// 模拟登录成功
|
||||
setTimeout(() => {
|
||||
setStatus(LoginStatus.SUCCESS);
|
||||
|
||||
// 存储登录信息
|
||||
const mockUserInfo = {
|
||||
id: "wx_" + Math.floor(Math.random() * 1000000),
|
||||
nickname: "微信用户",
|
||||
avatar: "https://placekitten.com/100/100", // 模拟头像
|
||||
accessToken: "mock_token_" + Date.now(),
|
||||
};
|
||||
|
||||
storage.setItem("wechat_user_info", JSON.stringify(mockUserInfo));
|
||||
|
||||
// 更新访问状态
|
||||
accessStore.update((access) => {
|
||||
access.accessToken = mockUserInfo.accessToken;
|
||||
access.wechatLoggedIn = true;
|
||||
});
|
||||
|
||||
// 登录成功后跳转
|
||||
setTimeout(() => {
|
||||
navigate(Path.Chat);
|
||||
}, 2000);
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// 刷新二维码
|
||||
const refreshQRCode = () => {
|
||||
setStatus(LoginStatus.LOADING);
|
||||
setTimeout(() => {
|
||||
setStatus(LoginStatus.READY);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 处理登录错误
|
||||
const handleLoginError = () => {
|
||||
setStatus(LoginStatus.ERROR);
|
||||
setErrorMessage("登录失败,请稍后重试");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.loginCard}>
|
||||
<div className={styles.header}>
|
||||
<h2>{Locale.Auth.Title}</h2>
|
||||
<p className={styles.subtitle}>使用微信扫码登录</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.qrcodeContainer}>
|
||||
{status === LoginStatus.LOADING && (
|
||||
<div className={styles.loadingWrapper}>
|
||||
<LoadingIcon className={styles.loadingIcon} />
|
||||
<p>正在加载二维码...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === LoginStatus.READY && (
|
||||
<div className={styles.qrcodeWrapper} onClick={simulateLogin}>
|
||||
{/* <QRCodeImage className={styles.qrcode} /> */}
|
||||
<div className={styles.qrcodeOverlay}>
|
||||
<p>点击模拟扫码</p>
|
||||
</div>
|
||||
<p className={styles.qrcodeHint}>请使用微信扫描二维码登录</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === LoginStatus.SCANNED && (
|
||||
<div className={styles.statusWrapper}>
|
||||
<div className={styles.statusIcon}>
|
||||
<LoadingIcon className={styles.loadingIcon} />
|
||||
</div>
|
||||
<p className={styles.statusText}>已扫码,请在微信上确认</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === LoginStatus.CONFIRMED && (
|
||||
<div className={styles.statusWrapper}>
|
||||
<div className={styles.statusIcon}>
|
||||
<LoadingIcon className={styles.loadingIcon} />
|
||||
</div>
|
||||
<p className={styles.statusText}>已确认,正在登录...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === LoginStatus.SUCCESS && (
|
||||
<div className={styles.statusWrapper}>
|
||||
<div className={styles.statusIcon}>
|
||||
<SuccessIcon className={styles.successIcon} />
|
||||
</div>
|
||||
<p className={styles.statusText}>登录成功,正在跳转...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === LoginStatus.ERROR && (
|
||||
<div className={styles.statusWrapper}>
|
||||
<div className={styles.statusIcon}>
|
||||
<ErrorIcon className={styles.errorIcon} />
|
||||
</div>
|
||||
<p className={styles.statusText}>{errorMessage}</p>
|
||||
<button className={styles.refreshButton} onClick={refreshQRCode}>
|
||||
刷新二维码
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(status === LoginStatus.READY || status === LoginStatus.LOADING) && (
|
||||
<div className={styles.footer}>
|
||||
<p className={styles.expireHint}>二维码有效期为2分钟,请尽快扫码</p>
|
||||
<button
|
||||
className={styles.refreshButton}
|
||||
onClick={refreshQRCode}
|
||||
disabled={status === LoginStatus.LOADING}
|
||||
>
|
||||
刷新二维码
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
31
app/components/auth-wrapper.tsx
Normal file
31
app/components/auth-wrapper.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import { useAccessStore } from "../store";
|
||||
import { safeLocalStorage } from "../utils";
|
||||
|
||||
// 不需要登录就可以访问的路径
|
||||
const PUBLIC_PATHS = [Path.Home, Path.Login];
|
||||
|
||||
export function AuthWrapper({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const accessStore = useAccessStore();
|
||||
const storage = safeLocalStorage();
|
||||
|
||||
useEffect(() => {
|
||||
// 检查当前路径是否需要登录
|
||||
const isPublicPath = PUBLIC_PATHS.includes(location.pathname as Path);
|
||||
|
||||
// 检查是否已登录
|
||||
const userInfoStr = storage.getItem("wechat_user_info");
|
||||
const isLoggedIn = userInfoStr && accessStore.wechatLoggedIn;
|
||||
|
||||
// 如果需要登录但未登录,重定向到登录页
|
||||
if (!isPublicPath && !isLoggedIn) {
|
||||
navigate(Path.Login);
|
||||
}
|
||||
}, [location.pathname, navigate, accessStore.wechatLoggedIn]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
@ -98,6 +98,65 @@
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-action-voice {
|
||||
// display: inline-flex;
|
||||
// border-radius: 20px;
|
||||
// font-size: 12px;
|
||||
// background-color: var(--white);
|
||||
// color: var(--black);
|
||||
// border: var(--border-in-light);
|
||||
// padding: 4px 10px;
|
||||
// animation: slide-in ease 0.3s;
|
||||
// box-shadow: var(--card-shadow);
|
||||
// transition: width ease 0.3s;
|
||||
// align-items: center;
|
||||
// height: 16px;
|
||||
// width: var(--icon-width);
|
||||
// overflow: hidden;
|
||||
|
||||
display: inline-flex;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
border: var(--border-in-light);
|
||||
padding: 4px 10px;
|
||||
box-shadow: var(--card-shadow);
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
width: var(--full-width); /* 使用全宽度 */
|
||||
// .text {
|
||||
// white-space: nowrap;
|
||||
// padding-left: 5px;
|
||||
// opacity: 0;
|
||||
// transform: translateX(-5px);
|
||||
// transition: all ease 0.3s;
|
||||
// pointer-events: none;
|
||||
// }
|
||||
|
||||
// &:hover {
|
||||
// --delay: 0.5s;
|
||||
// width: var(--full-width);
|
||||
// transition-delay: var(--delay);
|
||||
|
||||
|
||||
// }
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
padding-left: 5px;
|
||||
opacity: 1; /* 确保文本始终可见 */
|
||||
transform: translateX(0); /* 移除初始偏移 */
|
||||
transition: none; /* 移除过渡效果 */
|
||||
}
|
||||
|
||||
.text,
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-toast {
|
||||
@ -561,6 +620,7 @@
|
||||
flex-direction: column;
|
||||
border-top: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
// height: 15vh; // 添加固定高度为视窗高度的15%
|
||||
|
||||
.chat-input-actions {
|
||||
.chat-input-action {
|
||||
|
@ -1,14 +1,19 @@
|
||||
//#region ignore head
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import React, {
|
||||
Fragment,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { toast, Toaster } from "react-hot-toast";
|
||||
import { debounce } from "lodash";
|
||||
// import { startVoiceDetection } from "../utils/voice-start";
|
||||
import SendWhiteIcon from "../icons/send-white.svg";
|
||||
import BrainIcon from "../icons/brain.svg";
|
||||
import RenameIcon from "../icons/rename.svg";
|
||||
@ -48,6 +53,7 @@ import PluginIcon from "../icons/plugin.svg";
|
||||
import ShortcutkeyIcon from "../icons/shortcutkey.svg";
|
||||
import McpToolIcon from "../icons/tool.svg";
|
||||
import HeadphoneIcon from "../icons/headphone.svg";
|
||||
import MenuIcon from "../icons/menu.svg";
|
||||
import {
|
||||
BOT_HELLO,
|
||||
ChatMessage,
|
||||
@ -125,6 +131,7 @@ import { getModelProvider } from "../utils/model";
|
||||
import { RealtimeChat } from "@/app/components/realtime-chat";
|
||||
import clsx from "clsx";
|
||||
import { getAvailableClientsCount, isMcpEnabled } from "../mcp/actions";
|
||||
import { InterviewOverlay } from "./interview-overlay";
|
||||
|
||||
const localStorage = safeLocalStorage();
|
||||
|
||||
@ -449,47 +456,58 @@ export function ChatAction(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useScrollToBottom(
|
||||
scrollRef: RefObject<HTMLDivElement>,
|
||||
detach: boolean = false,
|
||||
messages: ChatMessage[],
|
||||
) {
|
||||
// for auto-scroll
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const scrollDomToBottom = useCallback(() => {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
requestAnimationFrame(() => {
|
||||
setAutoScroll(true);
|
||||
dom.scrollTo(0, dom.scrollHeight);
|
||||
});
|
||||
}
|
||||
}, [scrollRef]);
|
||||
|
||||
// auto scroll
|
||||
useEffect(() => {
|
||||
if (autoScroll && !detach) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
export function ChatActionVoice(props: {
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState({
|
||||
full: 32,
|
||||
icon: 16,
|
||||
});
|
||||
|
||||
// auto scroll when messages length changes
|
||||
const lastMessagesLength = useRef(messages.length);
|
||||
useEffect(() => {
|
||||
if (messages.length > lastMessagesLength.current && !detach) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
lastMessagesLength.current = messages.length;
|
||||
}, [messages.length, detach, scrollDomToBottom]);
|
||||
function updateWidth() {
|
||||
if (!iconRef.current || !textRef.current) return;
|
||||
const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
|
||||
const textWidth = getWidth(textRef.current);
|
||||
const iconWidth = getWidth(iconRef.current);
|
||||
setWidth({
|
||||
full: textWidth + iconWidth,
|
||||
icon: iconWidth,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
scrollDomToBottom,
|
||||
};
|
||||
// 使用 useLayoutEffect 确保在 DOM 更新后同步执行
|
||||
useLayoutEffect(() => {
|
||||
console.log("执行了:updateWidth");
|
||||
updateWidth();
|
||||
}, [props.text, props.icon]); // 添加依赖项,确保在 text 或 icon 变化时重新计算宽度
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles["chat-input-action-voice"], "clickable")}
|
||||
onClick={() => {
|
||||
props.onClick();
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--icon-width": `${width.icon}px`,
|
||||
"--full-width": `${width.full}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div ref={iconRef} className={styles["icon"]}>
|
||||
{props.icon}
|
||||
</div>
|
||||
<div className={styles["text"]} ref={textRef}>
|
||||
{props.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
export function ChatActions(props: {
|
||||
uploadImage: () => void;
|
||||
@ -503,13 +521,14 @@ export function ChatActions(props: {
|
||||
setShowShortcutKeyModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setUserInput: (input: string) => void;
|
||||
setShowChatSidePanel: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowOverlay: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const config = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
const chatStore = useChatStore();
|
||||
const pluginStore = usePluginStore();
|
||||
const session = chatStore.currentSession();
|
||||
|
||||
// const showOverlayRef = useRef(showOverlay);
|
||||
// switch themes
|
||||
const theme = config.theme;
|
||||
|
||||
@ -834,6 +853,15 @@ export function ChatActions(props: {
|
||||
)}
|
||||
{!isMobileScreen && <MCPAction />}
|
||||
</>
|
||||
|
||||
<ChatActionVoice
|
||||
onClick={() => {
|
||||
props.setShowOverlay(true);
|
||||
}}
|
||||
text="开始"
|
||||
icon={<MenuIcon />}
|
||||
/>
|
||||
|
||||
<div className={styles["chat-input-actions-end"]}>
|
||||
{config.realtimeConfig.enable && (
|
||||
<ChatAction
|
||||
@ -986,6 +1014,47 @@ export function ShortcutKeyModal(props: { onClose: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function useScrollToBottom(
|
||||
scrollRef: RefObject<HTMLDivElement>,
|
||||
detach: boolean = false,
|
||||
messages: ChatMessage[],
|
||||
) {
|
||||
// for auto-scroll
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const scrollDomToBottom = useCallback(() => {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
requestAnimationFrame(() => {
|
||||
setAutoScroll(true);
|
||||
dom.scrollTo(0, dom.scrollHeight);
|
||||
});
|
||||
}
|
||||
}, [scrollRef]);
|
||||
|
||||
// auto scroll
|
||||
useEffect(() => {
|
||||
if (autoScroll && !detach) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// auto scroll when messages length changes
|
||||
const lastMessagesLength = useRef(messages.length);
|
||||
useEffect(() => {
|
||||
if (messages.length > lastMessagesLength.current && !detach) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
lastMessagesLength.current = messages.length;
|
||||
}, [messages.length, detach, scrollDomToBottom]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
scrollDomToBottom,
|
||||
};
|
||||
}
|
||||
|
||||
function _Chat() {
|
||||
type RenderMessage = ChatMessage & { preview?: boolean };
|
||||
|
||||
@ -996,7 +1065,9 @@ function _Chat() {
|
||||
const fontFamily = config.fontFamily;
|
||||
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
|
||||
// 使用状态来控制 InterviewOverlay 的显示和隐藏
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
const showOverlayRef = useRef(showOverlay);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [userInput, setUserInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -1020,7 +1091,11 @@ function _Chat() {
|
||||
}, [scrollRef?.current?.scrollHeight]);
|
||||
|
||||
const isTyping = userInput !== "";
|
||||
|
||||
// 当子组件传回文本时更新 userInput
|
||||
const handleTextUpdate = (text: string) => {
|
||||
// console.log(`传入的文本:${text}`);
|
||||
setUserInput(text);
|
||||
};
|
||||
// if user is typing, should auto scroll to bottom
|
||||
// if user is not typing, should auto scroll to bottom only if already at bottom
|
||||
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
|
||||
@ -1679,9 +1754,28 @@ function _Chat() {
|
||||
|
||||
const [showChatSidePanel, setShowChatSidePanel] = useState(false);
|
||||
|
||||
const toastShow = (text: string): void => {
|
||||
console.log(`toastShow value is: ${text}`);
|
||||
|
||||
if (text.length <= 3) {
|
||||
console.log("弹出toas啊弹啊");
|
||||
toast("没有监听到任何文本!!!", {
|
||||
icon: "⚠️", // 自定义图标
|
||||
style: {
|
||||
background: "#fff3cd", // 背景色
|
||||
color: "#856404", // 文字颜色
|
||||
border: "1px solid #ffeeba", // 边框
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
doSubmit(text);
|
||||
};
|
||||
const toastShowDebounce = debounce(toastShow, 500);
|
||||
return (
|
||||
<>
|
||||
<div className={styles.chat} key={session.id}>
|
||||
{/* 聊天窗口头部 */}
|
||||
<div className="window-header" data-tauri-drag-region>
|
||||
{isMobileScreen && (
|
||||
<div className="window-actions">
|
||||
@ -1768,6 +1862,7 @@ function _Chat() {
|
||||
setShowModal={setShowPromptModal}
|
||||
/>
|
||||
</div>
|
||||
{/* 聊天消息区域 */}
|
||||
<div className={styles["chat-main"]}>
|
||||
<div className={styles["chat-body-container"]}>
|
||||
<div
|
||||
@ -1781,7 +1876,6 @@ function _Chat() {
|
||||
}}
|
||||
>
|
||||
{messages
|
||||
// TODO
|
||||
// .filter((m) => !m.isMcpResponse)
|
||||
.map((message, i) => {
|
||||
const isUser = message.role === "user";
|
||||
@ -1966,6 +2060,7 @@ function _Chat() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles["chat-message-item"]}>
|
||||
<Markdown
|
||||
key={message.streaming ? "loading" : "done"}
|
||||
@ -2021,6 +2116,7 @@ function _Chat() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message?.audio_url && (
|
||||
<div className={styles["chat-message-audio"]}>
|
||||
<audio src={message.audio_url} controls />
|
||||
@ -2039,6 +2135,7 @@ function _Chat() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 消息输入区域 */}
|
||||
<div className={styles["chat-input-panel"]}>
|
||||
<PromptHints
|
||||
prompts={promptHints}
|
||||
@ -2067,6 +2164,7 @@ function _Chat() {
|
||||
setShowShortcutKeyModal={setShowShortcutKeyModal}
|
||||
setUserInput={setUserInput}
|
||||
setShowChatSidePanel={setShowChatSidePanel}
|
||||
setShowOverlay={setShowOverlay}
|
||||
/>
|
||||
<label
|
||||
className={clsx(styles["chat-input-panel-inner"], {
|
||||
@ -2126,6 +2224,8 @@ function _Chat() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边面板 */}
|
||||
<div
|
||||
className={clsx(styles["chat-side-panel"], {
|
||||
[styles["mobile"]]: isMobileScreen,
|
||||
@ -2160,6 +2260,28 @@ function _Chat() {
|
||||
{showShortcutKeyModal && (
|
||||
<ShortcutKeyModal onClose={() => setShowShortcutKeyModal(false)} />
|
||||
)}
|
||||
{/* 当状态为 true 时,加载 InterviewOverlay 组件 */}
|
||||
{showOverlay && (
|
||||
<InterviewOverlay
|
||||
onClose={() => {
|
||||
setShowOverlay(false);
|
||||
}}
|
||||
onTextUpdate={handleTextUpdate}
|
||||
submitMessage={toastShowDebounce}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 全局的 Toaster 组件,可以设置默认位置和样式 */}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "#fff3cd",
|
||||
color: "#856404",
|
||||
border: "1px solid #ffeeba",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,20 +1,26 @@
|
||||
@mixin container {
|
||||
background-color: var(--white);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
// 移除边框
|
||||
// border: var(--border-in-light);
|
||||
// 移除圆角
|
||||
// border-radius: 20px;
|
||||
// 移除阴影
|
||||
// box-shadow: var(--shadow);
|
||||
color: var(--black);
|
||||
background-color: var(--white);
|
||||
min-width: 600px;
|
||||
min-height: 370px;
|
||||
max-width: 1200px;
|
||||
|
||||
// 修改最小宽度和高度
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
width: var(--window-width);
|
||||
height: var(--window-height);
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
@ -24,13 +30,13 @@
|
||||
@media only screen and (min-width: 600px) {
|
||||
.tight-container {
|
||||
--window-width: 100vw;
|
||||
--window-height: var(--full-height);
|
||||
--window-height: 100vh;
|
||||
--window-content-width: calc(100% - var(--sidebar-width));
|
||||
|
||||
@include container();
|
||||
|
||||
max-width: 100vw;
|
||||
max-height: var(--full-height);
|
||||
max-height: 100vh;
|
||||
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
@ -107,10 +113,10 @@
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.container {
|
||||
min-height: unset;
|
||||
min-width: unset;
|
||||
max-height: unset;
|
||||
min-width: unset;
|
||||
min-height: 100vh;
|
||||
min-width: 100vw;
|
||||
max-height: 100vh;
|
||||
max-width: 100vw;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
@ -30,6 +30,7 @@ import { type ClientApi, getClientApi } from "../client/api";
|
||||
import { useAccessStore } from "../store";
|
||||
import clsx from "clsx";
|
||||
import { initializeMcpSystem, isMcpEnabled } from "../mcp/actions";
|
||||
import LoginPage from "../pages/login";
|
||||
|
||||
export function Loading(props: { noLogo?: boolean }) {
|
||||
return (
|
||||
@ -82,6 +83,11 @@ const McpMarketPage = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
// const InterviewPage = dynamic(
|
||||
// async ()=>(await import("./interview-overlay")).InterviewOverlay,{
|
||||
// loading: ()=> <Loading noLogo/>
|
||||
// }
|
||||
// )
|
||||
export function useSwitchTheme() {
|
||||
const config = useAppConfig();
|
||||
|
||||
@ -193,6 +199,7 @@ function Screen() {
|
||||
})}
|
||||
/>
|
||||
<WindowContent>
|
||||
{/* <AuthWrapper></AuthWrapper> 只有登录时才可以路由到其他页面,相当于拦截器*/}
|
||||
<Routes>
|
||||
<Route path={Path.Home} element={<Chat />} />
|
||||
<Route path={Path.NewChat} element={<NewChat />} />
|
||||
@ -202,6 +209,8 @@ function Screen() {
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
<Route path={Path.McpMarket} element={<McpMarketPage />} />
|
||||
<Route path={Path.Login} element={<LoginPage />} />
|
||||
{/* <Route path={Path.Interview} element={<InterviewPage/>}/> */}
|
||||
</Routes>
|
||||
</WindowContent>
|
||||
</>
|
||||
|
163
app/components/interview-overlay.scss
Normal file
163
app/components/interview-overlay.scss
Normal file
@ -0,0 +1,163 @@
|
||||
.interview-overlay {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 33vw;
|
||||
height: 85vh;
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
color: #ffffff;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
&.dragging {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 1rem;
|
||||
width: fit-content;
|
||||
|
||||
.indicator-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
|
||||
&.listening {
|
||||
background-color: #4caf50;
|
||||
box-shadow: 0 0 10px #4caf50;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
&.not-listening {
|
||||
background-color: #ff6b6b;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff6b6b;
|
||||
margin-bottom: 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.transcript-display {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 0.5rem;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex: 1;
|
||||
color: white;
|
||||
border: none;
|
||||
|
||||
&.pause-button {
|
||||
background-color: #ff9800;
|
||||
|
||||
&:hover {
|
||||
background-color: #f57c00;
|
||||
}
|
||||
|
||||
&.paused {
|
||||
background-color: #4caf50;
|
||||
|
||||
&:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.stop-button {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
&:hover {
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
|
||||
&.clear-button {
|
||||
background-color: transparent;
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
|
||||
}
|
224
app/components/interview-overlay.tsx
Normal file
224
app/components/interview-overlay.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import StopIcon from "../icons/pause.svg";
|
||||
import SpeechRecognition, {
|
||||
useSpeechRecognition,
|
||||
} from "react-speech-recognition";
|
||||
import "./interview-overlay.scss";
|
||||
|
||||
interface InterviewOverlayProps {
|
||||
onClose: () => void;
|
||||
onTextUpdate: (text: string) => void;
|
||||
submitMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
export const InterviewOverlay: React.FC<InterviewOverlayProps> = ({
|
||||
onClose,
|
||||
onTextUpdate,
|
||||
submitMessage,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(true);
|
||||
// const [countdown, setCountdown] = useState(20);
|
||||
// const countdownRef = useRef(countdown);
|
||||
// const intervalIdRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// 添加暂停状态
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
// 添加宽度状态和拖动状态
|
||||
const [width, setWidth] = useState("33vw");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isDraggingRef = useRef(isDragging);
|
||||
const dragStartXRef = useRef(0);
|
||||
const initialWidthRef = useRef(0);
|
||||
|
||||
// 使用 react-speech-recognition 的钩子
|
||||
const {
|
||||
transcript,
|
||||
listening,
|
||||
resetTranscript,
|
||||
browserSupportsSpeechRecognition,
|
||||
isMicrophoneAvailable,
|
||||
} = useSpeechRecognition();
|
||||
|
||||
// 保存当前文本的引用,用于在倒计时结束时提交
|
||||
const transcriptRef = useRef(transcript);
|
||||
|
||||
useEffect(() => {
|
||||
transcriptRef.current = transcript;
|
||||
onTextUpdate(transcript);
|
||||
}, [transcript, onTextUpdate]);
|
||||
|
||||
// 检查浏览器是否支持语音识别
|
||||
useEffect(() => {
|
||||
if (!browserSupportsSpeechRecognition) {
|
||||
console.error("您的浏览器不支持语音识别功能");
|
||||
} else if (!isMicrophoneAvailable) {
|
||||
console.error("无法访问麦克风");
|
||||
}
|
||||
}, [browserSupportsSpeechRecognition, isMicrophoneAvailable]);
|
||||
|
||||
// 开始语音识别
|
||||
useEffect(() => {
|
||||
if (visible && !isPaused) {
|
||||
// 配置语音识别
|
||||
SpeechRecognition.startListening({
|
||||
continuous: true,
|
||||
language: "zh-CN",
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
SpeechRecognition.stopListening();
|
||||
};
|
||||
}, [visible, isPaused]);
|
||||
|
||||
const stopRecognition = () => {
|
||||
try {
|
||||
SpeechRecognition.stopListening();
|
||||
// 提交最终结果
|
||||
if (transcriptRef.current) {
|
||||
submitMessage(transcriptRef.current);
|
||||
}
|
||||
// 关闭overlay
|
||||
setVisible(false);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("停止语音识别失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加暂停/恢复功能
|
||||
const togglePause = () => {
|
||||
if (!isPaused) {
|
||||
// 使用更强制的中断方式
|
||||
SpeechRecognition.abortListening();
|
||||
// 然后再调用正常的停止方法确保完全停止
|
||||
setTimeout(() => {
|
||||
SpeechRecognition.stopListening();
|
||||
}, 0);
|
||||
|
||||
if (transcriptRef.current && transcriptRef.current.trim() !== "") {
|
||||
// 使用setTimeout将提交操作放到下一个事件循环,避免阻塞UI更新
|
||||
setTimeout(() => {
|
||||
submitMessage(transcriptRef.current);
|
||||
resetTranscript();
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
// 先确保停止当前可能存在的监听
|
||||
SpeechRecognition.abortListening();
|
||||
// 短暂延迟后重新启动监听
|
||||
setTimeout(() => {
|
||||
SpeechRecognition.startListening({
|
||||
continuous: true,
|
||||
language: "zh-CN",
|
||||
});
|
||||
// 重置文本
|
||||
resetTranscript();
|
||||
}, 0);
|
||||
}
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
// 添加拖动相关的事件处理函数
|
||||
const handleDragStart = (e: React.MouseEvent) => {
|
||||
setIsDragging(() => {
|
||||
isDraggingRef.current = true;
|
||||
return true;
|
||||
});
|
||||
dragStartXRef.current = e.clientX;
|
||||
initialWidthRef.current = parseInt(width);
|
||||
document.addEventListener("mousemove", handleDragMove);
|
||||
document.addEventListener("mouseup", handleDragEnd);
|
||||
};
|
||||
|
||||
const handleDragMove = (e: MouseEvent) => {
|
||||
if (isDraggingRef.current) {
|
||||
const deltaX = e.clientX - dragStartXRef.current;
|
||||
const newWidth = Math.max(
|
||||
15,
|
||||
Math.min(
|
||||
80,
|
||||
initialWidthRef.current - (deltaX / window.innerWidth) * 100,
|
||||
),
|
||||
);
|
||||
console.log(`mouse have moved Width:${newWidth}vw`);
|
||||
setWidth(`${newWidth}vw`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(() => {
|
||||
isDraggingRef.current = false;
|
||||
return false;
|
||||
});
|
||||
document.removeEventListener("mousemove", handleDragMove);
|
||||
document.removeEventListener("mouseup", handleDragEnd);
|
||||
};
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDragMove);
|
||||
document.removeEventListener("mouseup", handleDragEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`interview-overlay ${isDragging ? "dragging" : ""}`}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* 添加左侧拖动条 */}
|
||||
<div className="drag-handle" onMouseDown={handleDragStart} />
|
||||
|
||||
<div className="content-container">
|
||||
{/* 语音识别状态指示器 */}
|
||||
<div className="status-indicator">
|
||||
<div
|
||||
className={`indicator-dot ${
|
||||
listening ? "listening" : "not-listening"
|
||||
}`}
|
||||
/>
|
||||
<span className="status-text">
|
||||
{listening ? "正在监听..." : isPaused ? "已暂停" : "未监听"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{(!browserSupportsSpeechRecognition || !isMicrophoneAvailable) && (
|
||||
<div className="error-message">
|
||||
{!browserSupportsSpeechRecognition
|
||||
? "您的浏览器不支持语音识别功能,请使用Chrome浏览器"
|
||||
: "无法访问麦克风,请检查麦克风权限"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 识别文本显示区域 */}
|
||||
{transcript && <div className="transcript-display">{transcript}</div>}
|
||||
|
||||
{/* 按钮区域 */}
|
||||
<div className="button-container">
|
||||
{/* 暂停/恢复按钮 */}
|
||||
<button
|
||||
onClick={togglePause}
|
||||
className={`button pause-button ${isPaused ? "paused" : ""}`}
|
||||
>
|
||||
<span>{isPaused ? "▶️ 恢复监听" : "⏸️ 暂停并发送"}</span>
|
||||
</button>
|
||||
|
||||
<button onClick={stopRecognition} className="button stop-button">
|
||||
<StopIcon />
|
||||
<span>结束对话</span>
|
||||
</button>
|
||||
|
||||
<button onClick={resetTranscript} className="button clear-button">
|
||||
<span>🗑️ 清空</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -8,7 +8,6 @@ import GithubIcon from "../icons/github.svg";
|
||||
import ChatGptIcon from "../icons/chatgpt.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import MaskIcon from "../icons/mask.svg";
|
||||
import McpIcon from "../icons/mcp.svg";
|
||||
import DragIcon from "../icons/drag.svg";
|
||||
import DiscoveryIcon from "../icons/discovery.svg";
|
||||
@ -33,6 +32,8 @@ import { Selector, showConfirm } from "./ui-lib";
|
||||
import clsx from "clsx";
|
||||
import { isMcpEnabled } from "../mcp/actions";
|
||||
|
||||
import { WechatAuthor } from "./WechatAuthor";
|
||||
|
||||
const DISCOVERY = [
|
||||
{ name: Locale.Plugin.Name, path: Path.Plugins },
|
||||
{ name: "Stable Diffusion", path: Path.Sd },
|
||||
@ -224,6 +225,7 @@ export function SideBarTail(props: {
|
||||
);
|
||||
}
|
||||
|
||||
// 在侧边栏组件中添加WechatAuthor
|
||||
export function SideBar(props: { className?: string }) {
|
||||
useHotKey();
|
||||
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||
@ -249,6 +251,7 @@ export function SideBar(props: { className?: string }) {
|
||||
shouldNarrow={shouldNarrow}
|
||||
{...props}
|
||||
>
|
||||
<WechatAuthor /> {/* 添加到最顶部 */}
|
||||
<SideBarHeader
|
||||
title="NextChat"
|
||||
subTitle="Build your own AI assistant."
|
||||
@ -256,7 +259,7 @@ export function SideBar(props: { className?: string }) {
|
||||
shouldNarrow={shouldNarrow}
|
||||
>
|
||||
<div className={styles["sidebar-header-bar"]}>
|
||||
<IconButton
|
||||
{/* <IconButton
|
||||
icon={<MaskIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Mask.Name}
|
||||
className={styles["sidebar-bar-button"]}
|
||||
@ -268,7 +271,7 @@ export function SideBar(props: { className?: string }) {
|
||||
}
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
/> */}
|
||||
{mcpEnabled && (
|
||||
<IconButton
|
||||
icon={<McpIcon />}
|
||||
|
@ -39,19 +39,22 @@ export const SILICONFLOW_BASE_URL = "https://api.siliconflow.cn";
|
||||
export const CACHE_URL_PREFIX = "/api/cache";
|
||||
export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`;
|
||||
|
||||
// 添加到现有的Path枚举中
|
||||
export enum Path {
|
||||
Home = "/",
|
||||
Chat = "/chat",
|
||||
Settings = "/settings",
|
||||
NewChat = "/new-chat",
|
||||
Masks = "/masks",
|
||||
Plugins = "/plugins",
|
||||
Auth = "/auth",
|
||||
Login = "/login", // 添加登录路径
|
||||
Plugins = "/plugins",
|
||||
Sd = "/sd",
|
||||
SdNew = "/sd-new",
|
||||
Artifacts = "/artifacts",
|
||||
SearchChat = "/search-chat",
|
||||
McpMarket = "/mcp-market",
|
||||
Interview = "/interview",
|
||||
}
|
||||
|
||||
export enum ApiPath {
|
||||
@ -673,15 +676,15 @@ const siliconflowModels = [
|
||||
|
||||
let seq = 1000; // 内置的模型序号生成器从1000开始
|
||||
export const DEFAULT_MODELS = [
|
||||
...openaiModels.map((name) => ({
|
||||
...siliconflowModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++, // Global sequence sort(index)
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致
|
||||
id: "siliconflow",
|
||||
providerName: "SiliconFlow",
|
||||
providerType: "siliconflow",
|
||||
sorted: 1,
|
||||
},
|
||||
})),
|
||||
...openaiModels.map((name) => ({
|
||||
@ -816,15 +819,15 @@ export const DEFAULT_MODELS = [
|
||||
sorted: 13,
|
||||
},
|
||||
})),
|
||||
...siliconflowModels.map((name) => ({
|
||||
...openaiModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
sorted: seq++, // Global sequence sort(index)
|
||||
provider: {
|
||||
id: "siliconflow",
|
||||
providerName: "SiliconFlow",
|
||||
providerType: "siliconflow",
|
||||
sorted: 14,
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
sorted: 14, // 这里是固定的,确保顺序与之前内置的版本一致
|
||||
},
|
||||
})),
|
||||
] as const;
|
||||
|
51
app/icons/wechat-qrcode-mock.svg
Normal file
51
app/icons/wechat-qrcode-mock.svg
Normal file
@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="210" height="210" viewBox="0 0 21 21" style="border: 1px solid #eee;">
|
||||
<!-- 定义颜色(黑色和白色) -->
|
||||
<defs>
|
||||
<rect id="black" width="1" height="1" fill="#000" />
|
||||
<rect id="white" width="1" height="1" fill="#fff" stroke="#eee" stroke-width="0.1" />
|
||||
</defs>
|
||||
|
||||
<!-- 位置探测图形(左上、右上、左下) -->
|
||||
<!-- 左上探测图形(7x7) -->
|
||||
<g>
|
||||
<use xlink:href="#black" x="0" y="0" width="7" height="7" /> <!-- 外框 -->
|
||||
<use xlink:href="#white" x="1" y="1" width="5" height="5" /> <!-- 内框 -->
|
||||
<use xlink:href="#black" x="2" y="2" width="3" height="3" /> <!-- 中心 -->
|
||||
</g>
|
||||
<!-- 右上探测图形 -->
|
||||
<g>
|
||||
<use xlink:href="#black" x="14" y="0" width="7" height="7" />
|
||||
<use xlink:href="#white" x="15" y="1" width="5" height="5" />
|
||||
<use xlink:href="#black" x="16" y="2" width="3" height="3" />
|
||||
</g>
|
||||
<!-- 左下探测图形 -->
|
||||
<g>
|
||||
<use xlink:href="#black" x="0" y="14" width="7" height="7" />
|
||||
<use xlink:href="#white" x="1" y="15" width="5" height="5" />
|
||||
<use xlink:href="#black" x="2" y="16" width="3" height="3" />
|
||||
</g>
|
||||
|
||||
<!-- 定位图形(水平和垂直) -->
|
||||
<g>
|
||||
<!-- 水平定位线(第6、12、18行) -->
|
||||
<rect x="6" y="0" width="1" height="21" fill="#000" />
|
||||
<rect x="12" y="0" width="1" height="21" fill="#000" />
|
||||
<!-- 垂直定位线(第6、12、18列) -->
|
||||
<rect x="0" y="6" width="21" height="1" fill="#000" />
|
||||
<rect x="0" y="12" width="21" height="1" fill="#000" />
|
||||
</g>
|
||||
|
||||
<!-- 随机数据模块(非真实数据,模拟展示) -->
|
||||
<g>
|
||||
<!-- 生成随机黑白模块(排除已定义区域) -->
|
||||
<rect x="7" y="7" width="1" height="1" fill="#000" />
|
||||
<rect x="8" y="7" width="1" height="1" fill="#000" />
|
||||
<rect x="9" y="7" width="1" height="1" fill="#fff" />
|
||||
<rect x="10" y="7" width="1" height="1" fill="#000" />
|
||||
<!-- 省略中间模块,可根据需要扩展 -->
|
||||
<rect x="19" y="19" width="1" height="1" fill="#000" />
|
||||
</g>
|
||||
|
||||
<!-- 背景白色模块(覆盖未定义区域) -->
|
||||
<rect x="0" y="0" width="21" height="21" fill="#fff" stroke="#eee" stroke-width="0.1" />
|
||||
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
@ -77,9 +77,9 @@ export const ALL_LANG_OPTIONS: Record<Lang, string> = {
|
||||
};
|
||||
|
||||
const LANG_KEY = "lang";
|
||||
const DEFAULT_LANG = "en";
|
||||
const DEFAULT_LANG = "cn";
|
||||
|
||||
const fallbackLang = en;
|
||||
const fallbackLang = cn;
|
||||
const targetLang = ALL_LANGS[getLang()] as LocaleType;
|
||||
|
||||
// if target lang missing some fields, it will use fallback lang string
|
||||
|
23
app/pages/login.tsx
Normal file
23
app/pages/login.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { WechatLogin } from "../components/WechatLogin";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import { useAccessStore } from "../store";
|
||||
import { useEffect } from "react";
|
||||
import { safeLocalStorage } from "../utils";
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const accessStore = useAccessStore();
|
||||
const storage = safeLocalStorage();
|
||||
|
||||
// 检查是否已登录
|
||||
useEffect(() => {
|
||||
const userInfoStr = storage.getItem("wechat_user_info");
|
||||
if (userInfoStr && accessStore.wechatLoggedIn) {
|
||||
navigate(Path.Chat);
|
||||
}
|
||||
}, [navigate, accessStore.wechatLoggedIn]);
|
||||
|
||||
return <WechatLogin />;
|
||||
}
|
@ -142,6 +142,9 @@ const DEFAULT_ACCESS_STATE = {
|
||||
defaultModel: "",
|
||||
visionModels: "",
|
||||
|
||||
// 添加微信登录状态
|
||||
wechatLoggedIn: false,
|
||||
accessToken: "",
|
||||
// tts config
|
||||
edgeTTSVoiceName: "zh-CN-YunxiNeural",
|
||||
};
|
||||
|
@ -45,7 +45,7 @@ export const DEFAULT_CONFIG = {
|
||||
avatar: "1f603",
|
||||
fontSize: 14,
|
||||
fontFamily: "",
|
||||
theme: Theme.Auto as Theme,
|
||||
theme: Theme.Dark as Theme,
|
||||
tightBorder: !!config?.isApp,
|
||||
sendPreviewBubble: true,
|
||||
enableAutoGenerateTitle: true,
|
||||
|
0
app/utils/voice-start.ts
Normal file
0
app/utils/voice-start.ts
Normal file
@ -1,39 +0,0 @@
|
||||
# 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 @cloudflare/next-on-pages --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.
|
@ -1,38 +0,0 @@
|
||||
# Cloudflare Pages 導入ガイド
|
||||
|
||||
## 新規プロジェクトの作成方法
|
||||
GitHub でこのプロジェクトをフォークし、dash.cloudflare.com にログインして Pages にアクセスします。
|
||||
|
||||
1. "Create a project" をクリックする。
|
||||
2. "Connect to Git" を選択する。
|
||||
3. Cloudflare Pages を GitHub アカウントに接続します。
|
||||
4. フォークしたプロジェクトを選択します。
|
||||
5. "Begin setup" をクリックする。
|
||||
6. "Project name" と "Production branch" はデフォルト値を使用するか、必要に応じて変更してください。
|
||||
7. "Build Settings" で、"Framework presets" オプションを選択し、"Next.js" を選択します。
|
||||
8. node:buffer のバグのため、デフォルトの "Build command" は使用しないでください。代わりに、以下のコマンドを使用してください:
|
||||
```
|
||||
npx @cloudflare/next-on-pages --experimental-minify
|
||||
```
|
||||
9. "Build output directory" はデフォルト値を使用し、変更しない。
|
||||
10. "Root Directory" を変更しない。
|
||||
11. "Environment variables" は、">" をクリックし、"Add variable" をクリックします。そして以下の情報を入力します:
|
||||
- `NODE_VERSION=20.1`
|
||||
- `NEXT_TELEMETRY_DISABLE=1`
|
||||
- `OPENAI_API_KEY=your_own_API_key`
|
||||
- `YARN_VERSION=1.22.19`
|
||||
- `PHP_VERSION=7.4`
|
||||
|
||||
必要に応じて、以下の項目を入力してください:
|
||||
|
||||
- `CODE= Optional, access passwords, multiple passwords can be separated by commas`
|
||||
- `OPENAI_ORG_ID= Optional, specify the organization ID in OpenAI`
|
||||
- `HIDE_USER_API_KEY=1 Optional, do not allow users to enter their own API key`
|
||||
- `DISABLE_GPT4=1 Optional, do not allow users to use GPT-4`
|
||||
|
||||
12. "Save and Deploy" をクリックする。
|
||||
13. 互換性フラグを記入する必要があるため、"Cancel deployment" をクリックする。
|
||||
14. "Build settings" の "Functions" から "Compatibility flags" を見つける。
|
||||
15. "Configure Production compatibility flag" と "Configure Preview compatibility flag" の両方に "nodejs_compat "を記入する。
|
||||
16. "Deployments" に移動し、"Retry deployment" をクリックします。
|
||||
17. お楽しみください。
|
@ -1,39 +0,0 @@
|
||||
## Cloudflare 페이지 배포 가이드
|
||||
|
||||
## 새 프로젝트를 만드는 방법
|
||||
이 프로젝트를 Github에서 포크한 다음 dash.cloudflare.com에 로그인하고 페이지로 이동합니다.
|
||||
|
||||
1. "프로젝트 만들기"를 클릭합니다.
|
||||
2. "Git에 연결"을 선택합니다.
|
||||
3. Cloudflare 페이지를 GitHub 계정과 연결합니다.
|
||||
4. 포크한 프로젝트를 선택합니다.
|
||||
5. "설정 시작"을 클릭합니다.
|
||||
6. "프로젝트 이름" 및 "프로덕션 브랜치"의 기본값을 사용하거나 필요에 따라 변경합니다.
|
||||
7. "빌드 설정"에서 "프레임워크 프리셋" 옵션을 선택하고 "Next.js"를 선택합니다.
|
||||
8. node:buffer 버그로 인해 지금은 기본 "빌드 명령어"를 사용하지 마세요. 다음 명령을 사용하세요:
|
||||
```
|
||||
npx @cloudflare/next-on-pages --experimental-minify
|
||||
```
|
||||
9. "빌드 출력 디렉토리"의 경우 기본값을 사용하고 수정하지 마십시오.
|
||||
10. "루트 디렉토리"는 수정하지 마십시오.
|
||||
11. "환경 변수"의 경우 ">"를 클릭한 다음 "변수 추가"를 클릭합니다. 다음에 따라 정보를 입력합니다:
|
||||
|
||||
- node_version=20.1`.
|
||||
- next_telemetry_disable=1`.
|
||||
- `OPENAI_API_KEY=자신의 API 키`
|
||||
- ``yarn_version=1.22.19``
|
||||
- ``php_version=7.4``.
|
||||
|
||||
실제 필요에 따라 다음 옵션을 선택적으로 입력합니다:
|
||||
|
||||
- `CODE= 선택적으로 액세스 비밀번호를 입력하며 쉼표를 사용하여 여러 비밀번호를 구분할 수 있습니다`.
|
||||
- `OPENAI_ORG_ID= 선택 사항, OpenAI에서 조직 ID 지정`
|
||||
- `HIDE_USER_API_KEY=1 선택 사항, 사용자가 API 키를 입력하지 못하도록 합니다.
|
||||
- `DISABLE_GPT4=1 옵션, 사용자가 GPT-4를 사용하지 못하도록 설정` 12.
|
||||
|
||||
12. "저장 후 배포"를 클릭합니다.
|
||||
13. 호환성 플래그를 입력해야 하므로 "배포 취소"를 클릭합니다.
|
||||
14. "빌드 설정", "기능"으로 이동하여 "호환성 플래그"를 찾습니다.
|
||||
"프로덕션 호환성 플래그 구성" 및 "프리뷰 호환성 플래그 구성"에서 "nodejs_compat"를 입력합니다.
|
||||
16. "배포"로 이동하여 "배포 다시 시도"를 클릭합니다.
|
||||
17. 즐기세요!
|
205
docs/faq-es.md
205
docs/faq-es.md
@ -1,205 +0,0 @@
|
||||
# Preguntas frecuentes
|
||||
|
||||
## ¿Cómo puedo obtener ayuda rápidamente?
|
||||
|
||||
1. Pregunte a ChatGPT / Bing / Baidu / Google, etc.
|
||||
2. Pregunte a los internautas. Sírvase proporcionar información general sobre el problema y una descripción detallada del problema encontrado. Las preguntas de alta calidad facilitan la obtención de respuestas útiles.
|
||||
|
||||
# Problemas relacionados con la implementación
|
||||
|
||||
Referencia tutorial detallada para varios métodos de implementación: https://rptzik3toh.feishu.cn/docx/XtrdduHwXoSCGIxeFLlcEPsdn8b
|
||||
|
||||
## ¿Por qué la versión de implementación de Docker sigue solicitando actualizaciones?
|
||||
|
||||
La versión de Docker es equivalente a la versión estable, la última versión de Docker es siempre la misma que la última versión de lanzamiento, y la frecuencia de lanzamiento actual es de uno a dos días, por lo que la versión de Docker siempre se retrasará con respecto a la última confirmación de uno a dos días, lo que se espera.
|
||||
|
||||
## Cómo implementar en Vercel
|
||||
|
||||
1. Regístrese para obtener una cuenta de Github y bifurque el proyecto
|
||||
2. Regístrese en Vercel (se requiere verificación de teléfono móvil, puede usar un número chino) y conéctese a su cuenta de Github
|
||||
3. Cree un nuevo proyecto en Vercel, seleccione el proyecto que bifurcó en Github, complete las variables de entorno según sea necesario e inicie la implementación. Después de la implementación, puede acceder a su proyecto a través del nombre de dominio proporcionado por Vercel con una escalera.
|
||||
4. Si necesitas acceder sin muros en China: En tu sitio web de administración de dominios, agrega un registro CNAME para tu nombre de dominio que apunte a cname.vercel-dns.com. Después de eso, configure el acceso a su dominio en Vercel.
|
||||
|
||||
## Cómo modificar las variables de entorno de Vercel
|
||||
|
||||
* Vaya a la página de la consola de Vercel;
|
||||
* Seleccione su siguiente proyecto web chatgpt;
|
||||
* Haga clic en la opción Configuración en el encabezado de la página;
|
||||
* Busque la opción Variables de entorno en la barra lateral;
|
||||
* Modifique el valor correspondiente.
|
||||
|
||||
## ¿Qué es la variable de entorno CODE? ¿Es obligatorio configurar?
|
||||
|
||||
Esta es su contraseña de acceso personalizada, puede elegir:
|
||||
|
||||
1. Si no es así, elimine la variable de entorno. Precaución: Cualquier persona puede acceder a tu proyecto en este momento.
|
||||
2. Cuando implemente el proyecto, establezca la variable de entorno CODE (admite varias comas de contraseña separadas). Después de establecer la contraseña de acceso, debe ingresar la contraseña de acceso en la interfaz de configuración antes de poder usarla. Ver[Instrucciones relacionadas](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/README_CN.md#%E9%85%8D%E7%BD%AE%E9%A1%B5%E9%9D%A2%E8%AE%BF%E9%97%AE%E5%AF%86%E7%A0%81)
|
||||
|
||||
## ¿Por qué la versión que implementé no transmite respuestas?
|
||||
|
||||
> Debates relacionados:[#386](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/386)
|
||||
|
||||
Si utiliza el proxy inverso nginx, debe agregar el siguiente código al archivo de configuración:
|
||||
|
||||
# 不缓存,支持流式输出
|
||||
proxy_cache off; # 关闭缓存
|
||||
proxy_buffering off; # 关闭代理缓冲
|
||||
chunked_transfer_encoding on; # 开启分块传输编码
|
||||
tcp_nopush on; # 开启TCP NOPUSH选项,禁止Nagle算法
|
||||
tcp_nodelay on; # 开启TCP NODELAY选项,禁止延迟ACK算法
|
||||
keepalive_timeout 300; # 设定keep-alive超时时间为65秒
|
||||
|
||||
Si está implementando en Netlify y este problema aún está pendiente de resolución, tenga paciencia.
|
||||
|
||||
## Lo implementé, pero no puedo acceder a él
|
||||
|
||||
Marque para descartar los siguientes problemas:
|
||||
|
||||
* ¿Se ha iniciado el servicio?
|
||||
* ¿Los puertos están asignados correctamente?
|
||||
* ¿El firewall está abriendo puertos?
|
||||
* ¿Es transitable la ruta al servidor?
|
||||
* ¿Se resuelve correctamente el nombre de dominio?
|
||||
|
||||
## ¿Qué es un proxy y cómo lo uso?
|
||||
|
||||
Debido a las restricciones de IP de OpenAI, China y algunos otros países no pueden conectarse directamente a las API de OpenAI y necesitan pasar por un proxy. Puede usar un servidor proxy (proxy de reenvío) o un proxy inverso de API OpenAI ya configurado.
|
||||
|
||||
* Ejemplo de agente positivo: escalera científica de Internet. En el caso de la implementación de Docker, establezca la variable de entorno HTTP_PROXY en su dirección proxy (por ejemplo: 10.10.10.10:8002).
|
||||
* Ejemplo de proxy inverso: puede usar una dirección proxy creada por otra persona o configurarla de forma gratuita a través de Cloudflare. Establezca la variable de entorno del proyecto BASE_URL en su dirección proxy.
|
||||
|
||||
## ¿Se pueden implementar servidores domésticos?
|
||||
|
||||
Sí, pero hay que resolverlo:
|
||||
|
||||
* Requiere un proxy para conectarse a sitios como GitHub y openAI;
|
||||
* Si el servidor doméstico desea configurar la resolución de nombres de dominio, debe registrarse;
|
||||
* Las políticas nacionales restringen el acceso proxy a las aplicaciones relacionadas con Internet/ChatGPT y pueden bloquearse.
|
||||
|
||||
## ¿Por qué recibo un error de red después de la implementación de Docker?
|
||||
|
||||
Ver Discusión: https://github.com/Yidadaa/ChatGPT-Next-Web/issues/1569 para más detalles
|
||||
|
||||
# Problemas relacionados con el uso
|
||||
|
||||
## ¿Por qué sigues diciendo "Algo salió mal, inténtalo de nuevo más tarde"?
|
||||
|
||||
Puede haber muchas razones, por favor solucione los problemas en orden:
|
||||
|
||||
* Compruebe primero si la versión del código es la última versión, actualice a la última versión e inténtelo de nuevo;
|
||||
* Compruebe si la clave API está configurada correctamente y si el nombre de la variable de entorno debe estar en mayúsculas y subrayado;
|
||||
* Compruebe si la clave API está disponible;
|
||||
* Si aún no puede identificar el problema después de los pasos anteriores, envíe un nuevo problema en el campo de problema con el registro de tiempo de ejecución de Verbel o el registro de tiempo de ejecución de Docker.
|
||||
|
||||
## ¿Por qué la respuesta de ChatGPT es confusa?
|
||||
|
||||
Interfaz de configuración: uno de los elementos de configuración del modelo es `temperature`, si este valor es mayor que 1, entonces existe el riesgo de una respuesta confusa, simplemente vuelva a llamarlo a dentro de 1.
|
||||
|
||||
## Al usarlo, aparece "Ahora en un estado no autorizado, ingrese la contraseña de acceso en la pantalla de configuración"?
|
||||
|
||||
El proyecto establece la contraseña de acceso a través de la variable de entorno CODE. Cuando lo use por primera vez, debe ingresar el código de acceso en la configuración para usarlo.
|
||||
|
||||
## Use el mensaje "Excedió su cuota actual, ..."
|
||||
|
||||
Hay un problema con la API KEY. Saldo insuficiente.
|
||||
|
||||
# Problemas relacionados con el servicio de red
|
||||
|
||||
## ¿Qué es Cloudflare?
|
||||
|
||||
Cloudflare (CF) es un proveedor de servicios de red que proporciona CDN, administración de nombres de dominio, alojamiento de páginas estáticas, implementación de funciones de computación perimetral y más. Usos comunes: comprar y/o alojar su nombre de dominio (resolución, nombre de dominio dinámico, etc.), poner un CDN en su servidor (puede ocultar la IP de la pared), desplegar un sitio web (CF Pages). CF ofrece la mayoría de los servicios de forma gratuita.
|
||||
|
||||
## ¿Qué es Vercel?
|
||||
|
||||
Vercel es una plataforma global en la nube diseñada para ayudar a los desarrolladores a crear e implementar aplicaciones web modernas más rápido. Este proyecto, junto con muchas aplicaciones web, se puede implementar en Vercel de forma gratuita con un solo clic. Sin código, sin Linux, sin servidores, sin tarifas, sin agente API OpenAI. La desventaja es que necesita vincular el nombre de dominio para poder acceder a él sin muros en China.
|
||||
|
||||
## ¿Cómo obtengo un nombre de dominio?
|
||||
|
||||
1. Vaya al proveedor de nombres de dominio para registrarse, hay Namesilo (soporte Alipay), Cloudflare, etc. en el extranjero, y hay Wanwang en China;
|
||||
2. Proveedores de nombres de dominio gratuitos: eu.org (nombre de dominio de segundo nivel), etc.;
|
||||
3. Pídale a un amigo un nombre de dominio de segundo nivel gratuito.
|
||||
|
||||
## Cómo obtener un servidor
|
||||
|
||||
* Ejemplos de proveedores de servidores extranjeros: Amazon Cloud, Google Cloud, Vultr, Bandwagon, Hostdare, etc.
|
||||
Asuntos de servidores extranjeros: Las líneas de servidor afectan las velocidades de acceso nacional, se recomiendan los servidores de línea CN2 GIA y CN2. Si el servidor es de difícil acceso en China (pérdida grave de paquetes, etc.), puede intentar configurar un CDN (Cloudflare y otros proveedores).
|
||||
* Proveedores de servidores nacionales: Alibaba Cloud, Tencent, etc.;
|
||||
Asuntos de servidores nacionales: La resolución de nombres de dominio requiere la presentación de ICP; El ancho de banda del servidor doméstico es más caro; El acceso a sitios web extranjeros (Github, openAI, etc.) requiere un proxy.
|
||||
|
||||
## ¿En qué circunstancias debe grabarse el servidor?
|
||||
|
||||
Los sitios web que operan en China continental deben presentar de acuerdo con los requisitos reglamentarios. En la práctica, si el servidor está ubicado en China y hay resolución de nombres de dominio, el proveedor del servidor implementará los requisitos reglamentarios de presentación, de lo contrario el servicio se cerrará. Las reglas habituales son las siguientes:
|
||||
|ubicación del servidor|proveedor de nombres de dominio|si se requiere la presentación|
|
||||
|---|---|---|
|
||||
|Doméstico|Doméstico|Sí|
|
||||
|nacional|extranjero|sí|
|
||||
|extranjero|extranjero|no|
|
||||
|extranjero|nacional|normalmente no|
|
||||
|
||||
Después de cambiar de proveedor de servidores, debe transferir la presentación de ICP.
|
||||
|
||||
# Problemas relacionados con OpenAI
|
||||
|
||||
## ¿Cómo registro una cuenta OpenAI?
|
||||
|
||||
Vaya a chat.openai.com para registrarse. Es necesario:
|
||||
|
||||
* Una buena escalera (OpenAI admite direcciones IP nativas regionales)
|
||||
* Un buzón compatible (por ejemplo, Gmail o trabajo/escuela, no buzón de Outlook o QQ)
|
||||
* Cómo recibir autenticación por SMS (por ejemplo, sitio web de activación de SMS)
|
||||
|
||||
## ¿Cómo activo la API de OpenAI? ¿Cómo verifico mi saldo de API?
|
||||
|
||||
Dirección del sitio web oficial (se requiere escalera): https://platform.openai.com/account/usage
|
||||
Algunos internautas han construido un agente de consulta de saldo sin escalera, por favor pídales a los internautas que lo obtengan. Identifique si la fuente es confiable para evitar la fuga de la clave API.
|
||||
|
||||
## ¿Por qué mi cuenta OpenAI recién registrada no tiene un saldo API?
|
||||
|
||||
(Actualizado el 6 de abril) Las cuentas recién registradas suelen mostrar el saldo de la API después de 24 horas. Se otorga un saldo de $ 5 a una cuenta recién registrada.
|
||||
|
||||
## ¿Cómo puedo recargar la API de OpenAI?
|
||||
|
||||
OpenAI solo acepta tarjetas de crédito en regiones seleccionadas (no se pueden usar tarjetas de crédito chinas). Algunos ejemplos de avenidas son:
|
||||
|
||||
1. Depay tarjeta de crédito virtual
|
||||
2. Solicitar una tarjeta de crédito extranjera
|
||||
3. Encuentra a alguien para cobrar en línea
|
||||
|
||||
## ¿Cómo utilizo el acceso a la API de GPT-4?
|
||||
|
||||
* El acceso a la API para GPT-4 requiere una solicitud independiente. Ingrese a la cola de la solicitud completando su información en la lista de espera (prepare su ID de organización OpenAI): https://openai.com/waitlist/gpt-4-api
|
||||
Espere el mensaje de correo después.
|
||||
* Habilitar ChatGPT Plus no significa permisos GPT-4, y los dos no tienen nada que ver entre sí.
|
||||
|
||||
## Uso de la interfaz de Azure OpenAI
|
||||
|
||||
Por favor consulte:[#371](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/371)
|
||||
|
||||
## ¿Por qué mi token se agota tan rápido?
|
||||
|
||||
> Debates relacionados:[#518](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)
|
||||
|
||||
* Si tiene permisos de GPT 4 y usa las API de GPT 4 a diario, el monto de su factura aumentará rápidamente porque el precio de GPT 4 es aproximadamente 15 veces mayor que el de GPT 3.5;
|
||||
* Si está usando GPT 3.5 y no lo usa con mucha frecuencia y aún nota que su factura aumenta rápidamente, siga estos pasos para solucionar problemas ahora:
|
||||
* Vaya al sitio web oficial de OpenAI para verificar sus registros de consumo de API Key, si su token se consume cada hora y se consumen decenas de miles de tokens cada vez, entonces su clave debe haberse filtrado, elimine y regenere inmediatamente.**No verifique su saldo en un sitio web desordenado.**
|
||||
* Si su contraseña se acorta, como letras dentro de 5 dígitos, entonces el costo de voladura es muy bajo, se recomienda que busque en el registro de Docker para ver si alguien ha probado muchas combinaciones de contraseñas, palabra clave: got access code
|
||||
* A través de los dos métodos anteriores, puede localizar la razón por la cual su token se consume rápidamente:
|
||||
* Si el registro de consumo de OpenAI es anormal, pero no hay ningún problema con el registro de Docker, entonces la clave API se filtra;
|
||||
* Si el registro de Docker encuentra una gran cantidad de registros de código de acceso de obtención, entonces la contraseña ha sido destruida.
|
||||
|
||||
## ¿Cómo se facturan las API?
|
||||
|
||||
Instrucciones de facturación del sitio web de OpenAI: https://openai.com/pricing#language-models\
|
||||
OpenAI cobra en función del número de tokens, y 1,000 tokens generalmente representan 750 palabras en inglés o 500 caracteres chinos. Prompt y Completion cuentan los costos por separado.\
|
||||
|Modelo|Facturación de entrada de usuario (aviso)|Facturación de salida del modelo (finalización)|Número máximo de tokens por interacción|
|
||||
|----|----|----|----|
|
||||
|gpt-3.5|$0.002 / 1 mil tokens|$0.002 / 1 mil tokens|4096|
|
||||
|gpt-4|$0.03 / 1 mil tokens|$0.06 / 1 mil tokens|8192|
|
||||
|gpt-4-32K|$0.06 / 1 mil tokens|$0.12 / 1 mil tokens|32768|
|
||||
|
||||
## ¿Cuál es la diferencia entre los modelos GPT-3.5-TURBO y GPT3.5-TURBO-0301 (o GPT3.5-TURBO-MMDD)?
|
||||
|
||||
Descripción de la documentación oficial: https://platform.openai.com/docs/models/gpt-3-5
|
||||
|
||||
* GPT-3.5-Turbo es el último modelo y se actualiza constantemente.
|
||||
* GPT-3.5-turbo-0301 es una instantánea del modelo congelada el 1 de marzo, no cambiará y se espera que sea reemplazada por una nueva instantánea en 3 meses.
|
191
docs/faq-ja.md
191
docs/faq-ja.md
@ -1,191 +0,0 @@
|
||||
# よくある質問
|
||||
|
||||
## 早く助けを求めるには?
|
||||
|
||||
1. ChatGPT / Bing / Baidu / Google などに尋ねてください。
|
||||
2. オンラインの友達に聞く。背景情報と問題の詳細な説明を提供してください。質の高い質問ほど、有益な回答を得られる可能性が高くなります。
|
||||
|
||||
# デプロイメントに関する質問
|
||||
|
||||
## なぜ Docker のデプロイバージョンは常に更新を要求するのか
|
||||
|
||||
Docker のバージョンは安定版と同等であり、最新の Docker は常に最新のリリースバージョンと一致しています。現在、私たちのリリース頻度は1~2日に1回なので、Dockerのバージョンは常に最新のコミットから1~2日遅れており、これは予想されることです。
|
||||
|
||||
## Vercel での展開方法
|
||||
|
||||
1. GitHub アカウントを登録し、このプロジェクトをフォークする。
|
||||
2. Vercel を登録し(携帯電話認証が必要、中国の番号でも可)、GitHub アカウントを接続する。
|
||||
3. Vercel で新規プロジェクトを作成し、GitHub でフォークしたプロジェクトを選択し、必要な環境変数を入力し、デプロイを開始する。デプロイ後、Vercel が提供するドメインからプロジェクトにアクセスできます。(中国本土ではプロキシが必要)
|
||||
|
||||
- 中国で直接アクセスする必要がある場合: DNS プロバイダーで、cname.vercel-dns.com を指すドメイン名の CNAME レコードを追加します。その後、Vercel でドメインアクセスを設定してください。
|
||||
|
||||
## Vercel 環境変数の変更方法
|
||||
|
||||
- Vercel のコンソールページに入ります;
|
||||
- chatgpt-next-web プロジェクトを選択してください;
|
||||
- ページ上部の設定オプションをクリックしてください;
|
||||
- サイドバーで環境変数オプションを見つけます;
|
||||
- 必要に応じて対応する値を変更してください。
|
||||
|
||||
## 環境変数 CODE とは何ですか?設定する必要がありますか?
|
||||
|
||||
カスタムアクセスパスワードです:
|
||||
|
||||
1. 設定しないで、環境変数を削除する。この時、誰でもあなたのプロジェクトにアクセスすることができます。
|
||||
2. プロジェクトをデプロイするときに、環境変数 CODE を設定する(カンマ区切りで複数のパスワードをサポート)。アクセスパスワードを設定した後、ユーザーはそれを使用するために設定ページでアクセスパスワードを入力する必要があります。[関連手順](https://github.com/Yidadaa/ChatGPT-Next-Web#access-password)
|
||||
|
||||
## なぜ私がデプロイしたバージョンにはストリーミングレスポンスがないのでしょうか?
|
||||
|
||||
> 関連する議論: [#386](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/386)
|
||||
|
||||
nginx のリバースプロキシを使っている場合、設定ファイルに以下のコードを追加する必要があります:
|
||||
|
||||
```
|
||||
# キャッシュなし、ストリーミング出力をサポート
|
||||
proxy_cache off; # キャッシュをオフにする
|
||||
proxy_buffering off; # プロキシバッファリングをオフにする
|
||||
chunked_transfer_encoding on; # チャンク転送エンコーディングをオンにする
|
||||
tcp_nopush on; # TCP NOPUSH オプションをオンにし、Nagleアルゴリズムを無効にする
|
||||
tcp_nodelay on; # TCP NODELAY オプションをオンにし、遅延ACKアルゴリズムを無効にする
|
||||
keepalive_timeout 300; # keep-alive のタイムアウトを 65 秒に設定する
|
||||
```
|
||||
|
||||
netlify でデプロイしている場合、この問題はまだ解決待ちです。
|
||||
|
||||
## デプロイしましたが、アクセスできません。
|
||||
|
||||
以下の問題を確認し、トラブルシューティングを行ってください:
|
||||
|
||||
- サービスは開始されていますか?
|
||||
- ポートは正しくマッピングされていますか?
|
||||
- ファイアウォールのポートは開いていますか?
|
||||
- サーバーへのルートは問題ありませんか?
|
||||
- ドメイン名は正しく解決されていますか?
|
||||
|
||||
## "Error: Loading CSS chunk xxx failed..." と表示されることがあります。
|
||||
|
||||
Next.js では、最初のホワイトスクリーンの時間を短縮するために、デフォルトでチャンキングを有効にしています。技術的な詳細はこちらをご覧ください:
|
||||
|
||||
- https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading
|
||||
- https://stackoverflow.com/questions/55993890/how-can-i-disable-chunkcode-splitting-with-webpack4
|
||||
- https://github.com/vercel/next.js/issues/38507
|
||||
- https://stackoverflow.com/questions/55993890/how-can-i-disable-chunkcode-splitting-with-webpack4
|
||||
|
||||
ただし、Next.js は古いブラウザとの互換性に制限があるため、このエラーが発生することがあります。
|
||||
|
||||
ビルド時にチャンキングを無効にすることができます。
|
||||
|
||||
Vercel プラットフォームの場合は、環境変数に `DISABLE_CHUNK=1` を追加して再デプロイします。
|
||||
セルフデプロイのプロジェクトでは、ビルド時に `DISABLE_CHUNK=1 yarn build` を使用することができます。
|
||||
Docker ユーザーの場合、ビルドはパッケージング時にすでに完了しているため、この機能を無効にすることは現在サポートされていません。
|
||||
|
||||
この機能を無効にすると、ユーザーの最初の訪問時にすべてのリソースがロードされることに注意してください。その結果、ユーザーのネットワーク接続が悪い場合、ホワイト・スクリーンの時間が長くなり、ユーザーエクスペリエンスに影響を与える可能性があります。この点を考慮の上、ご判断ください。
|
||||
|
||||
# 使用法に関する質問
|
||||
|
||||
## なぜいつも "An error occurred, please try again later" と表示されるのですか?
|
||||
|
||||
様々な原因が考えられますので、以下の項目を順番にチェックしてみてください:
|
||||
|
||||
- まず、コードのバージョンが最新版かどうかを確認し、最新版にアップデートしてから再試行してください;
|
||||
- api キーが正しく設定されているか確認してください。環境変数名は大文字とアンダースコアでなければなりません;
|
||||
- api キーが使用可能かどうか確認する;
|
||||
- 上記のステップを踏んでも問題が解決しない場合は、issue エリアに新しい issue を投稿し、vercel のランタイムログまたは docker のランタイムログを添付してください。
|
||||
|
||||
## ChatGPT の返信が文字化けするのはなぜですか?
|
||||
|
||||
設定画面-機種設定の中に `temperature` という項目があります。この値が 1 より大きい場合、返信が文字化けすることがあります。1 以内に調整してください。
|
||||
|
||||
## 設定ページでアクセスパスワードを入力してください」と表示される。
|
||||
|
||||
プロジェクトでは環境変数 CODE でアクセスパスワードを設定しています。初めて使うときは、設定ページでアクセスコードを入力する必要があります。
|
||||
|
||||
## 使用すると、"You exceeded your current quota, ..." と表示される。
|
||||
|
||||
API KEY に問題があります。残高不足です。
|
||||
|
||||
## プロキシとは何ですか?
|
||||
|
||||
OpenAI の IP 制限により、中国をはじめとする一部の国や地域では、OpenAI API に直接接続することができず、プロキシを経由する必要があります。プロキシサーバ(フォワードプロキシ)を利用するか、事前に設定された OpenAI API リバースプロキシを利用します。
|
||||
|
||||
- フォワードプロキシの例: VPN ラダー。docker デプロイの場合は、環境変数 HTTP_PROXY にプロキシアドレス (http://address:port) を設定します。
|
||||
- リバースプロキシの例: 他人のプロキシアドレスを使うか、Cloudflare を通じて無料で設定できる。プロジェクトの環境変数 BASE_URL にプロキシアドレスを設定してください。
|
||||
|
||||
## 中国のサーバーにデプロイできますか?
|
||||
|
||||
可能ですが、対処すべき問題があります:
|
||||
|
||||
- GitHub や OpenAI などのウェブサイトに接続するにはプロキシが必要です;
|
||||
- GitHub や OpenAI のようなウェブサイトに接続するにはプロキシが必要です;
|
||||
- 中国の政策により、海外のウェブサイト/ChatGPT 関連アプリケーションへのプロキシアクセスが制限されており、ブロックされる可能性があります。
|
||||
|
||||
# ネットワークサービス関連の質問
|
||||
|
||||
## クラウドフレアとは何ですか?
|
||||
|
||||
Cloudflare(CF)は、CDN、ドメイン管理、静的ページホスティング、エッジコンピューティング機能展開などを提供するネットワークサービスプロバイダーです。一般的な使用例: メインの購入やホスティング(解決、ダイナミックドメインなど)、サーバーへの CDN の適用(ブロックされないように IP を隠すことができる)、ウェブサイト(CF Pages)の展開。CF はほとんどのサービスを無料で提供しています。
|
||||
|
||||
## Vercel とは?
|
||||
|
||||
Vercel はグローバルなクラウドプラットフォームで、開発者がモダンなウェブアプリケーションをより迅速に構築、デプロイできるように設計されています。このプロジェクトや多くのウェブアプリケーションは、ワンクリックで Vercel 上に無料でデプロイできます。コードを理解する必要も、Linux を理解する必要も、サーバーを持つ必要も、お金を払う必要も、OpenAI API プロキシを設定する必要もありません。欠点は、中国の制限なしにアクセスするためにドメイン名をバインドする必要があることだ。
|
||||
|
||||
## ドメイン名の取得方法
|
||||
|
||||
1. Namesilo(アリペイ対応)や Cloudflare(海外プロバイダー)、Wanwang(中国国内プロバイダー)などのドメインプロバイダーに登録する。
|
||||
2. 無料ドメインプロバイダー: eu.org(セカンドレベルドメイン)など。
|
||||
3. 無料セカンドレベルドメインを友人に頼む。
|
||||
|
||||
## サーバーの取得方法
|
||||
|
||||
- 海外サーバープロバイダーの例 Amazon Web Services、Google Cloud、Vultr、Bandwagon、Hostdare など。
|
||||
海外サーバーの注意点 サーバー回線は中国でのアクセス速度に影響するため、CN2 GIA、CN2 回線を推奨。もしサーバーが中国でアクセスしにくい場合(深刻なパケットロスなど)、CDN(Cloudflare のようなプロバイダーのもの)を使ってみるとよいでしょう。
|
||||
- 国内のサーバープロバイダー アリババクラウド、テンセントなど
|
||||
国内サーバーの注意点 ドメイン名の解決にはファイリングが必要。国内サーバーの帯域幅は比較的高い。海外のウェブサイト(GitHub、OpenAI など)へのアクセスにはプロキシが必要。
|
||||
|
||||
# OpenAI 関連の質問
|
||||
|
||||
## OpenAI のアカウントを登録するには?
|
||||
|
||||
chat.openai.com にアクセスして登録してください。以下のものが必要です:
|
||||
|
||||
- 優れた VPN (OpenAI はサポートされている地域のネイティブ IP アドレスしか許可しません)
|
||||
- サポートされているメール (例: Gmail や会社/学校のメール。Outlook や QQ のメールは不可)
|
||||
- SMS 認証を受ける方法(SMS-activate ウェブサイトなど)
|
||||
|
||||
## OpenAI API を有効にするには?API 残高の確認方法は?
|
||||
|
||||
公式ウェブサイト(VPN が必要): https://platform.openai.com/account/usage
|
||||
VPN なしで残高を確認するためにプロキシを設定しているユーザーもいます。API キーの漏洩を避けるため、信頼できる情報源であることを確認してください。
|
||||
|
||||
## OpenAI の新規アカウントに API 残高がないのはなぜですか?
|
||||
|
||||
(4月6日更新) 新規登録アカウントは通常 24 時間以内に API 残高が表示されます。現在、新規アカウントには 5 ドルの残高が与えられています。
|
||||
|
||||
## OpenAI API へのチャージ方法を教えてください。
|
||||
|
||||
OpenAI では、指定された地域のクレジットカードのみご利用いただけます(中国のクレジットカードはご利用いただけません)。お住まいの地域のクレジットカードに対応していない場合は、以下の方法があります:
|
||||
|
||||
1. Depay バーチャルクレジットカード
|
||||
2. 海外のクレジットカードを申し込む
|
||||
3. オンラインでトップアップしてくれる人を探す
|
||||
|
||||
## GPT-4 API にアクセスするには?
|
||||
|
||||
(4月6日更新) GPT-4 API へのアクセスには別途申請が必要です。以下のアドレスにアクセスし、ウェイティングリストに参加するための情報を入力してください(OpenAI の組織 ID をご用意ください): https://openai.com/waitlist/gpt-4-api
|
||||
その後、メールの更新をお待ちください。
|
||||
|
||||
## Azure OpenAI インターフェースの使い方
|
||||
|
||||
次を参照: [#371](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/371)
|
||||
|
||||
## トークンの消費が速いのはなぜですか?
|
||||
|
||||
> 関連する議論: [#518](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)
|
||||
|
||||
- GPT-4 にアクセスし、GPT-4 の API を定期的に使用している場合、GPT-4 の価格は GPT-3.5 の約 15 倍であるため、請求額が急激に増加します;
|
||||
- GPT-3.5 を使用しており、頻繁に使用していないにもかかわらず、請求額が急速に増加している場合は、以下の手順で直ちにトラブルシューティングを行ってください:
|
||||
- OpenAI のウェブサイトで API キーの消費記録を確認してください。トークンが 1 時間ごとに消費され、毎回数万トークンが消費される場合は、キーが流出している可能性があります。すぐに削除して再生成してください。**適当なサイトで残高を確認しないでください。**
|
||||
- パスワードが 5 文字以下など短い場合、ブルートフォースによるコストは非常に低くなります。誰かが大量のパスワードの組み合わせを試したかどうかを確認するために、docker のログを検索することを推奨する。キーワード:アクセスコードの取得
|
||||
- これら 2 つの方法を実行することで、トークンが急速に消費された原因を突き止めることができます:
|
||||
- OpenAI の消費記録に異常があるが、Docker ログに問題がない場合、API キーが流出したことを意味します;
|
||||
- Docker ログにアクセスコード取得のブルートフォース試行回数が多い場合は、パスワードがクラックされています。
|
230
docs/faq-ko.md
230
docs/faq-ko.md
@ -1,230 +0,0 @@
|
||||
# 자주 묻는 질문
|
||||
|
||||
## 어떻게 빠르게 도움을 받을 수 있나요?
|
||||
|
||||
1. ChatGPT / Bing / Baidu / Google 등에 질문합니다.
|
||||
2. 인터넷 사용자에게 질문합니다. 문제의 배경 정보와 자세한 문제 설명을 제공하세요. 질 좋은 질문은 유용한 답변을 쉽게 받을 수 있습니다.
|
||||
|
||||
# 배포 관련 질문
|
||||
|
||||
각종 배포 방법에 대한 자세한 튜토리얼 참조: [링크](https://rptzik3toh.feishu.cn/docx/XtrdduHwXoSCGIxeFLlcEPsdn8b)
|
||||
|
||||
## 왜 Docker 배포 버전이 계속 업데이트 알림을 주나요?
|
||||
|
||||
Docker 버전은 사실상 안정된 버전과 같습니다. latest Docker는 항상 latest release version과 일치합니다. 현재 우리의 발행 빈도는 하루 또는 이틀에 한 번이므로 Docker 버전은 항상 최신 커밋보다 하루나 이틀 뒤처집니다. 이것은 예상된 것입니다.
|
||||
|
||||
## Vercel에서 어떻게 배포하나요?
|
||||
|
||||
1. Github 계정을 등록하고, 이 프로젝트를 포크합니다.
|
||||
2. Vercel을 등록합니다(휴대폰 인증 필요, 중국 번호 사용 가능), Github 계정을 연결합니다.
|
||||
3. Vercel에서 새 프로젝트를 생성하고, Github에서 포크한 프로젝트를 선택합니다. 환경 변수를 필요에 따라 입력한 후 배포를 시작합니다. 배포 후에는 VPN이 있는 환경에서 Vercel이 제공하는 도메인으로 프로젝트에 접근할 수 있습니다.
|
||||
4. 중국에서 방화벽 없이 접근하려면: 도메인 관리 사이트에서 도메인의 CNAME 레코드를 추가하고, cname.vercel-dns.com을 가리키게 합니다. 그런 다음 Vercel에서 도메인 접근을 설정합니다.
|
||||
|
||||
## Vercel 환경 변수를 어떻게 수정하나요?
|
||||
|
||||
- Vercel의 제어판 페이지로 이동합니다.
|
||||
- NextChat 프로젝트를 선택합니다.
|
||||
- 페이지 상단의 Settings 옵션을 클릭합니다.
|
||||
- 사이드바의 Environment Variables 옵션을 찾습니다.
|
||||
- 해당 값을 수정합니다.
|
||||
|
||||
## 환경 변수 CODE는 무엇이며, 반드시 설정해야 하나요?
|
||||
|
||||
이것은 당신이 사용자 정의한 접근 비밀번호입니다. 다음 중 하나를 선택할 수 있습니다:
|
||||
|
||||
1. 설정하지 않습니다. 해당 환경 변수를 삭제합니다. 주의: 이 경우 누구나 프로젝트에 접근할 수 있습니다.
|
||||
2. 프로젝트를 배포할 때 환경 변수 CODE를 설정합니다(여러 비밀번호는 쉼표로 구분). 접근 비밀번호를 설정하면 사용자는 설정 페이지에서 접근 비밀번호를 입력해야만 사용할 수 있습니다. [관련 설명 참조](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/README_CN.md#%E9%85%8D%E7%BD%AE%E9%A1%B5%E9%9D%A2%E8%AE%BF%E9%97%AE%E5%AF%86%E7%A0%81)
|
||||
|
||||
## 왜 내 배포 버전에 스트리밍 응답이 없나요?
|
||||
|
||||
> 관련 토론: [#386](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/386)
|
||||
|
||||
nginx 리버스 프록시를 사용하는 경우, 설정 파일에 다음 코드를 추가해야 합니다:
|
||||
|
||||
```nginx
|
||||
# 캐시하지 않고, 스트리밍 출력 지원
|
||||
proxy_cache off; # 캐시 비활성화
|
||||
proxy_buffering off; # 프록시 버퍼링 비활성화
|
||||
chunked_transfer_encoding on; # 청크 전송 인코딩 활성화
|
||||
tcp_nopush on; # TCP NOPUSH 옵션 활성화, Nagle 알고리즘 금지
|
||||
tcp_nodelay on; # TCP NODELAY 옵션 활성화, 지연 ACK 알고리즘 금지
|
||||
keepalive_timeout 300; # keep-alive 타임아웃을 65초로 설정
|
||||
```
|
||||
|
||||
netlify에서 배포하는 경우, 이 문제는 아직 해결되지 않았습니다. 기다려 주십시오.
|
||||
|
||||
## 배포했지만 액세스할 수 없는 경우.
|
||||
|
||||
다음의 사항들을 확인해보세요:
|
||||
|
||||
- 서비스가 배포 중인가요?
|
||||
- 포트가 올바르게 매핑되었나요?
|
||||
- 방화벽에서 포트가 열렸나요?
|
||||
- 서버 경로가 유효한가요?
|
||||
- 도메인 이름이 올바른가요?
|
||||
|
||||
## 프록시란 무엇이며 어떻게 사용하나요?
|
||||
|
||||
중국 및 일부 국가에서는 OpenAI의 IP 제한으로 인해 OpenAI API에 직접 연결할 수 없으며 프록시를 거쳐야 합니다. 프록시 서버(정방향 프록시)를 사용하거나 OpenAI API에 대해 설정된 역방향 프록시를 사용할 수 있습니다.
|
||||
|
||||
- 정방향 프록시 예: 사이언티픽 인터넷 래더. 도커 배포의 경우 환경 변수 HTTP_PROXY를 프록시 주소(예: 10.10.10.10:8002)로 설정합니다.
|
||||
- 역방향 프록시 예: 다른 사람이 구축한 프록시 주소를 사용하거나 Cloudflare를 통해 무료로 설정할 수 있습니다. 프로젝트 환경 변수 BASE_URL을 프록시 주소로 설정합니다.
|
||||
|
||||
## 국내 서버를 배포할 수 있나요?
|
||||
|
||||
예. 하지만 해결해야 할 문제가 있습니다:
|
||||
|
||||
- github 및 openAI와 같은 사이트에 연결하려면 프록시가 필요합니다;
|
||||
- 도메인 이름 확인을 설정하려면 국내 서버를 신청해야 합니다;
|
||||
- 국내 정책에 따라 프록시가 엑스트라넷/ChatGPT 관련 애플리케이션에 액세스하지 못하도록 제한되어 차단될 수 있습니다.
|
||||
|
||||
## 도커 배포 후 네트워크 오류가 발생하는 이유는 무엇인가요?
|
||||
|
||||
https://github.com/Yidadaa/ChatGPT-Next-Web/issues/1569 에서 토론을 참조하세요.
|
||||
|
||||
## 사용 관련 문제
|
||||
|
||||
## "문제가 발생했습니다, 나중에 다시 시도하세요"라는 메시지가 계속 뜨는 이유는 무엇인가요?
|
||||
|
||||
여러 가지 이유가 있을 수 있으니 순서대로 확인해 주세요:
|
||||
|
||||
- 코드 버전이 최신 버전인지 확인하고, 최신 버전으로 업데이트한 후 다시 시도해 주세요;
|
||||
- API 키가 올바르게 설정되었는지 확인해주세요. 환경 변수 이름은 모두 대문자이며 밑줄이 있어야 합니다;
|
||||
- API 키가 사용 가능한지 확인해 주세요;
|
||||
- 위 단계를 수행한 후에도 문제를 확인할 수 없는 경우, 이슈 영역에 신규 이슈를 제출하고 버셀의 런타임 로그 또는 도커 런타임 로그를 첨부해 주시기 바랍니다.
|
||||
|
||||
## ChatGPT 응답이 왜곡되는 이유는 무엇인가요?
|
||||
|
||||
설정 - 모델 설정 섹션에 '온도'에 대한 값이 있는데, 이 값이 1보다 크면 응답이 왜곡될 수 있으니 1 이내로 다시 설정해 주세요.
|
||||
|
||||
## "권한이 없는 상태입니다, 설정 페이지에서 액세스 비밀번호를 입력하세요"?
|
||||
|
||||
프로젝트에서 환경 변수 CODE에 접근 비밀번호를 설정했습니다. 처음 사용할 때는 설정 페이지에서 액세스 코드를 입력해야 합니다.
|
||||
|
||||
## 사용 시 "현재 할당량을 초과했습니다, ..."라는 메시지가 표시됩니다.
|
||||
|
||||
API 키에 문제가 있습니다. 잔액이 부족합니다.
|
||||
|
||||
## "오류: CSS 청크 xxx를 로드하지 못했습니다..."와 함께 사용.
|
||||
|
||||
첫 번째 화이트 스크린 시간을 줄이기 위해 청크 컴파일이 기본적으로 활성화되어 있으며, 기술 원칙은 아래를 참조하세요:
|
||||
|
||||
- https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading
|
||||
- https://stackoverflow.com/questions/55993890/how-can-i-disable-chunkcode-splitting-with-webpack4
|
||||
- https://github.com/vercel/next.js/issues/38507
|
||||
- https://stackoverflow.com/questions/55993890/how-can-i-disable-chunkcode-splitting-with-webpack4
|
||||
|
||||
그러나 NextJS는 호환성이 좋지 않아 구형 브라우저에서 이 오류가 발생할 수 있으므로 컴파일 시 청크 컴파일을 비활성화할 수 있습니다.
|
||||
|
||||
버셀 플랫폼의 경우 환경 변수에 `DISABLE_CHUNK=1`을 추가하고 다시 배포합니다;
|
||||
자체 컴파일 및 배포한 프로젝트의 경우, 빌드 시 `DISABLE_CHUNK=1 yarn build`를 사용하여 빌드합니다;
|
||||
Docker 사용자의 경우, Docker가 프로젝트를 패키징할 때 이미 빌드하기 때문에 이 기능을 해제하는 것은 지원되지 않습니다.
|
||||
|
||||
이 기능을 끄면 사용자가 웹사이트를 처음 방문할 때 모든 리소스를 로드하므로 인터넷 연결 상태가 좋지 않은 경우 흰색 화면이 길게 표시되어 사용자 경험에 영향을 줄 수 있으므로 사용자가 직접 고려하시기 바랍니다.
|
||||
|
||||
"## NotFoundError: '노드': 노드....에서 'removeChild'를 실행하지 못했습니다." 오류가 발생했습니다.
|
||||
브라우저의 자체 자동 번역 기능을 비활성화하고 모든 자동 번역 플러그인을 닫아주세요.
|
||||
|
||||
## 웹 서비스 관련 문제
|
||||
|
||||
## 클라우드플레어란 무엇인가요?
|
||||
|
||||
Cloudflare(CF)는 CDN, 도메인 관리, 정적 페이지 호스팅, 엣지 컴퓨팅 기능 배포 등을 제공하는 웹 서비스 제공업체입니다. 일반적인 용도: 도메인 구매 및/또는 호스팅(리졸브, 동적 도메인 등), 서버에 CDN 설치(벽에서 IP를 숨기는 기능), 웹사이트 배포(CF 페이지). CF는 이러한 서비스 대부분을 무료로 제공합니다.
|
||||
|
||||
## Vercel이란 무엇인가요?
|
||||
|
||||
Vercel은 개발자가 최신 웹 애플리케이션을 더 빠르게 빌드하고 배포할 수 있도록 설계된 글로벌 클라우드 플랫폼입니다. 이 프로젝트와 많은 웹 애플리케이션을 클릭 한 번으로 Vercel에 무료로 배포할 수 있습니다. 코드, 리눅스, 서버, 수수료가 필요 없고 OpenAI API 프록시를 설정할 필요도 없습니다. 단점은 중국에서 장벽 없이 액세스하려면 도메인 이름을 바인딩해야 한다는 것입니다.
|
||||
|
||||
## 도메인 네임은 어떻게 얻나요?
|
||||
|
||||
1) 도메인 네임 공급업체로 이동하여 해외에서는 Namesilo(알리페이 지원), 클라우드플레어 등, 중국에서는 월드와이드웹과 같은 도메인 네임을 등록합니다. 2) 무료 도메인 네임 공급업체: 예: eBay;
|
||||
2. 무료 도메인 네임 제공업체: eu.org(두 번째 레벨 도메인 네임) 등..;
|
||||
3. 친구에게 무료 2단계 도메인 네임을 요청합니다.
|
||||
|
||||
## 서버를 얻는 방법
|
||||
|
||||
- 외국 서버 제공업체의 예: 아마존 클라우드, 구글 클라우드, 벌터, 밴드왜건, 호스트데어 등;
|
||||
해외 서버 문제: 서버 라인은 해당 국가의 액세스 속도에 영향을 미치므로 CN2 GIA 및 CN2 라인 서버를 권장합니다. 국내 서버의 접속에 문제가 있는 경우(심각한 패킷 손실 등) CDN(Cloudflare 및 기타 제공 업체)을 설정해 볼 수 있습니다.
|
||||
- 국내 서버 제공업체: 알리윈, 텐센트 등;
|
||||
국내 서버 문제: 도메인 이름 확인을 신청해야 하며, 국내 서버 대역폭이 더 비싸고, 해외 사이트(Github, openAI 등)에 액세스하려면 프록시가 필요합니다.
|
||||
|
||||
## 서버는 언제 신청해야 하나요?
|
||||
|
||||
중국 본토에서 운영되는 웹사이트는 규제 요건에 따라 신고해야 합니다. 실제로 서버가 중국에 있고 도메인 네임 레졸루션이 있는 경우 서버 제공업체가 규제 신고 요건을 시행하며, 그렇지 않으면 서비스가 종료됩니다. 일반적인 규칙은 다음과 같습니다:
|
||||
|서버 위치|도메인 네임 공급자|파일링 필요 여부|
|
||||
|---|---|---|
|
||||
|국내|국내|예
|
||||
|국내|외국|예
|
||||
|외국|외국인|아니요
|
||||
|외국|국내|일반적으로 아니요|
|
||||
|
||||
서버 공급자를 전환한 후 파일링을 전환해야 합니다.
|
||||
|
||||
## OpenAI 관련 질문
|
||||
|
||||
## OpenAI 계정은 어떻게 가입하나요?
|
||||
|
||||
chat.openai.com으로 이동하여 등록하세요. 다음이 필요합니다:
|
||||
|
||||
- 유효한 래더(OpenAI는 지역별 기본 IP 주소를 지원합니다)
|
||||
- 지원되는 이메일 주소(예: Outlook이나 qq가 아닌 Gmail 또는 회사/학교 이메일)
|
||||
- SMS 인증을 받을 수 있는 방법(예: SMS 활성화 웹사이트)
|
||||
|
||||
## OpenAI API는 어떻게 열 수 있나요? API 잔액은 어떻게 확인하나요?
|
||||
|
||||
공식 웹사이트 주소(래더 필요): https://platform.openai.com/account/usage
|
||||
일부 사용자는 래더 없이 잔액 조회 에이전트를 구축한 경우가 있으니, 해당 사용자에게 요청해 주시기 바랍니다. API 키 유출을 방지하기 위해 신뢰할 수 있는 소스인지 확인하시기 바랍니다.
|
||||
|
||||
## 새로 등록한 OpenAI 계정에 API 잔액이 없는 이유는 무엇인가요?
|
||||
|
||||
(4월 6일 업데이트) 새로 등록된 계정은 일반적으로 24시간 후에 API 잔액이 표시됩니다. 현재 새로 등록된 계정에는 $5의 잔액이 표시됩니다.
|
||||
|
||||
## OpenAI API를 충전하려면 어떻게 해야 하나요?
|
||||
|
||||
OpenAI는 특정 지역의 신용카드만 사용할 수 있습니다(중국 신용카드는 사용할 수 없음). 충전 방법의 몇 가지 예는 다음과 같습니다:
|
||||
|
||||
1. 가상 신용카드로 결제하기
|
||||
2. 해외 신용카드 신청
|
||||
3. 온라인에서 신용카드를 충전할 사람 찾기
|
||||
|
||||
## GPT-4 API 액세스는 어떻게 사용하나요?
|
||||
|
||||
- GPT-4 API 액세스는 별도의 신청이 필요합니다. 다음 주소로 이동하여 정보를 입력하여 신청 대기열 대기자 명단에 들어가세요(OpenAI 조직 ID를 준비하세요): https://openai.com/waitlist/gpt-4-api.
|
||||
그런 다음 이메일 메시지를 기다립니다.
|
||||
- ChatGPT Plus를 사용하도록 설정했다고 해서 GPT-4 권한이 있는 것은 아니며, 서로 관련이 없습니다.
|
||||
|
||||
## Azure OpenAI 인터페이스 사용 방법
|
||||
|
||||
참조: [#371](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/371)
|
||||
|
||||
## 내 토큰이 왜 이렇게 빨리 소모되나요?
|
||||
|
||||
> 관련 토론: [#518](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)
|
||||
|
||||
- GPT 4에 액세스 권한이 있고 매일 GPT 4 API를 사용하는 경우, GPT 4 가격이 GPT 3.5의 약 15배이기 때문에 청구 금액이 급격히 증가합니다;
|
||||
- GPT 3.5를 자주 사용하지 않는데도 요금이 급격하게 증가하는 경우 아래 단계를 따라 확인하시기 바랍니다:
|
||||
- 오픈아이 공식 웹사이트로 이동하여 API 키 소비 기록을 확인하고, 매 시간마다 토큰이 소비되고 매번 수만 개의 토큰이 소비된다면 키가 유출된 것이므로 즉시 삭제하고 재생성하시기 바랍니다. 즉시 키를 삭제하고 다시 생성하시기 바랍니다. 지저분한 웹사이트에서 잔액을 확인하지 마세요. **
|
||||
- 비밀번호 설정이 5자리 이내의 문자와 같이 매우 짧으면 블라스팅 비용이 매우 낮습니다. 도커의 로그 기록을 검색하여 누군가 많은 수의 비밀번호 조합을 시도했는지 확인하는 것이 좋습니다. 키워드: 액세스 코드를 얻었습니다.
|
||||
- 이 두 가지 방법을 사용하면 토큰이 소비되는 이유를 빠르게 찾을 수 있습니다:
|
||||
- 오픈아이 소비 기록은 비정상적이지만 도커 로그는 정상이라면 API 키가 유출되고 있다는 뜻입니다;
|
||||
- 도커 로그에서 액세스 코드 버스트 레코드가 많이 발견되면 비밀번호가 버스트된 것입니다.
|
||||
|
||||
|
||||
## API의 가격은 어떻게 청구되나요?
|
||||
|
||||
OpenAI의 청구 지침은 https://openai.com/pricing#language-models 에서 확인할 수 있습니다.
|
||||
OpenAI는 토큰 수에 따라 요금을 청구하며, 일반적으로 1000토큰은 영어 단어 750개 또는 중국어 문자 500개를 나타냅니다. 입력(프롬프트)과 출력(완료)은 별도로 청구됩니다.
|
||||
|
||||
|모델|사용자 입력(프롬프트) 청구 |모델 출력(완료) 청구 |인터랙션당 최대 토큰 수 |
|
||||
|----|----|----|----|
|
||||
|GPT-3.5-TURBO|$0.0015 / 1천 토큰|$0.002 / 1천 토큰|4096|
|
||||
|GPT-3.5-TURBO-16K|$0.003 / 1천 토큰|$0.004 / 1천 토큰|16384| |GPT-4|$0.004 / 1천 토큰|16384
|
||||
|GPT-3.5-TURBO-16K|$0.003 / 1천 토큰|$0.004 / 1천 토큰|16384| |GPT-4|$0.03 / 1천 토큰|$0.06 / 1천 토큰|8192
|
||||
|GPT-4-32K|$0.06 / 1천 토큰|$0.12 / 1천 토큰|32768|
|
||||
|
||||
## gpt-3.5-터보와 gpt3.5-터보-0301(또는 gpt3.5-터보-mmdd) 모델의 차이점은 무엇인가요?
|
||||
|
||||
공식 문서 설명: https://platform.openai.com/docs/models/gpt-3-5
|
||||
|
||||
- GPT-3.5-TURBO는 최신 모델이며 지속적으로 업데이트될 예정입니다.
|
||||
- gpt-3.5-turbo-0301은 3월 1일에 고정된 모델의 스냅샷으로, 변경되지 않으며 3개월 후에 새로운 스냅샷으로 대체될 예정입니다.
|
@ -1,31 +0,0 @@
|
||||
# Sincronizzare i Log delle Chat con UpStash
|
||||
## Prerequisiti
|
||||
- Account GitHub
|
||||
- Server ChatGPT-Next-Web di propria configurazione
|
||||
- [UpStash](https://upstash.com)
|
||||
|
||||
## Per iniziare
|
||||
1. Registrarsi per un account UpStash.
|
||||
2. Creare un database.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
3. Trovare l'API REST e copiare UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN (⚠Importante⚠: Non condividere il token!)
|
||||
|
||||

|
||||
|
||||
4. Copiare UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN nella configurazione di sincronizzazione, quindi fare clic su **Verifica la Disponibilità**.
|
||||
|
||||

|
||||
|
||||
Se tutto è in ordine, hai completato con successo questa fase.
|
||||
|
||||

|
||||
|
||||
5. Successo!
|
||||
|
||||

|
@ -1,31 +0,0 @@
|
||||
# UpStashを使用してチャットログを同期する
|
||||
## 事前準備
|
||||
- GitHubアカウント
|
||||
- 自分自身でChatGPT-Next-Webのサーバーをセットアップしていること
|
||||
- [UpStash](https://upstash.com)
|
||||
|
||||
## 始める
|
||||
1. UpStashアカウントを登録します。
|
||||
2. データベースを作成します。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
3. REST APIを見つけ、UPSTASH_REDIS_REST_URLとUPSTASH_REDIS_REST_TOKENをコピーします(⚠重要⚠:トークンを共有しないでください!)
|
||||
|
||||

|
||||
|
||||
4. UPSTASH_REDIS_REST_URLとUPSTASH_REDIS_REST_TOKENを同期設定にコピーし、次に「可用性を確認」をクリックします。
|
||||
|
||||

|
||||
|
||||
すべてが正常であれば、このステップは成功です。
|
||||
|
||||

|
||||
|
||||
5. 成功!
|
||||
|
||||

|
@ -1,31 +0,0 @@
|
||||
# UpStash를 사용하여 채팅 기록 동기화
|
||||
## 사전 준비물
|
||||
- GitHub 계정
|
||||
- 자체 ChatGPT-Next-Web 서버 설정
|
||||
- [UpStash](https://upstash.com)
|
||||
|
||||
## 시작하기
|
||||
1. UpStash 계정 등록
|
||||
2. 데이터베이스 생성
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
3. REST API를 찾아 UPSTASH_REDIS_REST_URL 및 UPSTASH_REDIS_REST_TOKEN을 복사합니다 (⚠주의⚠: 토큰을 공유하지 마십시오!)
|
||||
|
||||

|
||||
|
||||
4. UPSTASH_REDIS_REST_URL 및 UPSTASH_REDIS_REST_TOKEN을 동기화 구성에 복사한 다음 **가용성 확인**을 클릭합니다.
|
||||
|
||||

|
||||
|
||||
모든 것이 정상인 경우,이 단계를 성공적으로 완료했습니다.
|
||||
|
||||

|
||||
|
||||
5. 성공!
|
||||
|
||||

|
18550
package-lock.json
generated
Normal file
18550
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -6,7 +6,7 @@
|
||||
"mask": "npx tsx app/masks/build.ts",
|
||||
"mask:watch": "npx watch \"yarn mask\" app/masks",
|
||||
"dev": "concurrently -r \"yarn run mask:watch\" \"next dev\"",
|
||||
"build": "yarn mask && cross-env BUILD_MODE=standalone next build",
|
||||
"build": "rimraf .next && rimraf next-env.d.ts && yarn mask && cross-env BUILD_MODE=standalone next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"export": "yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build",
|
||||
@ -26,15 +26,18 @@
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"@next/third-parties": "^14.1.0",
|
||||
"@svgr/webpack": "^6.5.1",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@vercel/analytics": "^0.1.11",
|
||||
"@vercel/speed-insights": "^1.0.2",
|
||||
"axios": "^1.7.5",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"emoji-picker-react": "^4.9.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"html-to-image": "^1.11.11",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"markdown-to-txt": "^2.0.1",
|
||||
"mermaid": "^10.6.1",
|
||||
@ -44,8 +47,10 @@
|
||||
"openapi-client-axios": "^7.5.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-speech-recognition": "^4.0.0",
|
||||
"rehype-highlight": "^6.0.0",
|
||||
"rehype-katex": "^6.0.3",
|
||||
"remark-breaks": "^3.0.2",
|
||||
@ -54,6 +59,7 @@
|
||||
"rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz",
|
||||
"sass": "^1.59.2",
|
||||
"spark-md5": "^3.0.2",
|
||||
"tencentcloud-sdk-nodejs-cvm": "^4.1.2",
|
||||
"use-debounce": "^9.0.4",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^4.3.8"
|
||||
@ -64,6 +70,7 @@
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@ -71,6 +78,7 @@
|
||||
"@types/react": "^18.2.70",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-katex": "^3.0.0",
|
||||
"@types/react-speech-recognition": "^3.9.6",
|
||||
"@types/spark-md5": "^3.0.4",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^7.0.3",
|
||||
@ -84,6 +92,7 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"lint-staged": "^13.2.2",
|
||||
"prettier": "^3.0.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.16.0",
|
||||
"typescript": "5.2.2",
|
||||
|
1
tencentcloud-speech-sdk-js
Submodule
1
tencentcloud-speech-sdk-js
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 34950220a00cd1fef7f4c0a17755804c9d929f87
|
@ -3,6 +3,7 @@
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
Loading…
Reference in New Issue
Block a user