MSF commited on
Commit ·
0d3686b
1
Parent(s): 9ce7ac5
feat: Initialize Venting Partner app with AI Studio
Browse filesSets up the project structure for the Venting Partner application, integrating with Google AI Studio. Includes initial configuration for Vite, React, Tailwind CSS, and Gemini API integration. Also adds basic README instructions and a .gitignore file.
- .env.example +9 -0
- .gitignore +8 -0
- README.md +14 -5
- index.html +13 -0
- metadata.json +6 -0
- package.json +34 -0
- src/App.tsx +15 -0
- src/components/Chat.tsx +375 -0
- src/index.css +31 -0
- src/main.tsx +10 -0
- src/services/geminiService.ts +118 -0
- tsconfig.json +26 -0
- vite.config.ts +24 -0
.env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
| 2 |
+
# AI Studio automatically injects this at runtime from user secrets.
|
| 3 |
+
# Users configure this via the Secrets panel in the AI Studio UI.
|
| 4 |
+
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
| 5 |
+
|
| 6 |
+
# APP_URL: The URL where this applet is hosted.
|
| 7 |
+
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
| 8 |
+
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
| 9 |
+
APP_URL="MY_APP_URL"
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
build/
|
| 3 |
+
dist/
|
| 4 |
+
coverage/
|
| 5 |
+
.DS_Store
|
| 6 |
+
*.log
|
| 7 |
+
.env*
|
| 8 |
+
!.env.example
|
README.md
CHANGED
|
@@ -1,11 +1,20 @@
|
|
| 1 |
<div align="center">
|
| 2 |
-
|
| 3 |
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<div align="center">
|
|
|
|
| 2 |
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
| 3 |
+
</div>
|
| 4 |
|
| 5 |
+
# Run and deploy your AI Studio app
|
| 6 |
|
| 7 |
+
This contains everything you need to run your app locally.
|
| 8 |
|
| 9 |
+
View your app in AI Studio: https://ai.studio/apps/20848e5b-548c-41e6-b2f6-de4e7b1c1bd9
|
| 10 |
|
| 11 |
+
## Run Locally
|
| 12 |
+
|
| 13 |
+
**Prerequisites:** Node.js
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
1. Install dependencies:
|
| 17 |
+
`npm install`
|
| 18 |
+
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
| 19 |
+
3. Run the app:
|
| 20 |
+
`npm run dev`
|
index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>My Google AI Studio App</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
| 13 |
+
|
metadata.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Venting Partner (情绪宣泄室)",
|
| 3 |
+
"description": "一个陪你一起骂人、宣泄情绪,然后再引导你回归平静的AI伙伴。先发泄,再冷静。",
|
| 4 |
+
"requestFramePermissions": [],
|
| 5 |
+
"majorCapabilities": []
|
| 6 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "react-example",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite --port=3000 --host=0.0.0.0",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"clean": "rm -rf dist",
|
| 11 |
+
"lint": "tsc --noEmit"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@google/genai": "^1.29.0",
|
| 15 |
+
"@tailwindcss/vite": "^4.1.14",
|
| 16 |
+
"@vitejs/plugin-react": "^5.0.4",
|
| 17 |
+
"lucide-react": "^0.546.0",
|
| 18 |
+
"react": "^19.0.1",
|
| 19 |
+
"react-dom": "^19.0.1",
|
| 20 |
+
"vite": "^6.2.3",
|
| 21 |
+
"express": "^4.21.2",
|
| 22 |
+
"dotenv": "^17.2.3",
|
| 23 |
+
"motion": "^12.23.24"
|
| 24 |
+
},
|
| 25 |
+
"devDependencies": {
|
| 26 |
+
"@types/node": "^22.14.0",
|
| 27 |
+
"autoprefixer": "^10.4.21",
|
| 28 |
+
"tailwindcss": "^4.1.14",
|
| 29 |
+
"tsx": "^4.21.0",
|
| 30 |
+
"typescript": "~5.8.2",
|
| 31 |
+
"vite": "^6.2.3",
|
| 32 |
+
"@types/express": "^4.17.21"
|
| 33 |
+
}
|
| 34 |
+
}
|
src/App.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @license
|
| 3 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import React from "react";
|
| 7 |
+
import Chat from "./components/Chat";
|
| 8 |
+
|
| 9 |
+
export default function App() {
|
| 10 |
+
return (
|
| 11 |
+
<div className="min-h-screen bg-black text-white selection:bg-orange-500/30">
|
| 12 |
+
<Chat />
|
| 13 |
+
</div>
|
| 14 |
+
);
|
| 15 |
+
}
|
src/components/Chat.tsx
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from "react";
|
| 2 |
+
import { motion, AnimatePresence } from "motion/react";
|
| 3 |
+
import { Send, Trash2, Heart, Flame, ShieldAlert, Sparkles, MessageSquare, Mic, Square, Play, Pause, Volume2 } from "lucide-react";
|
| 4 |
+
import { chatWithGemini, generateSpeech, ChatMode, Message } from "../services/geminiService";
|
| 5 |
+
|
| 6 |
+
export default function Chat() {
|
| 7 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
| 8 |
+
const [input, setInput] = useState("");
|
| 9 |
+
const [mode, setMode] = useState<ChatMode>(ChatMode.VENTING);
|
| 10 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 11 |
+
const [showSwitchPrompt, setShowSwitchPrompt] = useState(false);
|
| 12 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 13 |
+
const [recordedAudio, setRecordedAudio] = useState<string | null>(null);
|
| 14 |
+
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
|
| 15 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 16 |
+
const audioContextRef = useRef<AudioContext | null>(null);
|
| 17 |
+
|
| 18 |
+
// Auto-scroll to bottom
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
if (scrollRef.current) {
|
| 21 |
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 22 |
+
}
|
| 23 |
+
}, [messages, isLoading]);
|
| 24 |
+
|
| 25 |
+
const startRecording = async () => {
|
| 26 |
+
try {
|
| 27 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 28 |
+
const recorder = new MediaRecorder(stream);
|
| 29 |
+
const chunks: Blob[] = [];
|
| 30 |
+
|
| 31 |
+
recorder.ondataavailable = (e) => chunks.push(e.data);
|
| 32 |
+
recorder.onstop = async () => {
|
| 33 |
+
const blob = new Blob(chunks, { type: "audio/webm" });
|
| 34 |
+
const reader = new FileReader();
|
| 35 |
+
reader.readAsDataURL(blob);
|
| 36 |
+
reader.onloadend = () => {
|
| 37 |
+
const base64 = (reader.result as string).split(",")[1];
|
| 38 |
+
setRecordedAudio(base64);
|
| 39 |
+
};
|
| 40 |
+
stream.getTracks().forEach(track => track.stop());
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
recorder.start();
|
| 44 |
+
setMediaRecorder(recorder);
|
| 45 |
+
setIsRecording(true);
|
| 46 |
+
} catch (err) {
|
| 47 |
+
console.error("Error accessing microphone:", err);
|
| 48 |
+
alert("无法访问麦克风,请检查权限。");
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const stopRecording = () => {
|
| 53 |
+
if (mediaRecorder) {
|
| 54 |
+
mediaRecorder.stop();
|
| 55 |
+
setIsRecording(false);
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const playPCM = async (base64: string) => {
|
| 60 |
+
if (!audioContextRef.current) {
|
| 61 |
+
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
|
| 62 |
+
}
|
| 63 |
+
const ctx = audioContextRef.current;
|
| 64 |
+
|
| 65 |
+
// Decoding raw PCM 16-bit Le
|
| 66 |
+
const binary = atob(base64);
|
| 67 |
+
const len = binary.length;
|
| 68 |
+
const bytes = new Uint8Array(len);
|
| 69 |
+
for (let i = 0; i < len; i++) {
|
| 70 |
+
bytes[i] = binary.charCodeAt(i);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const arrayBuffer = bytes.buffer;
|
| 74 |
+
const audioBuffer = ctx.createBuffer(1, arrayBuffer.byteLength / 2, 24000);
|
| 75 |
+
const channelData = audioBuffer.getChannelData(0);
|
| 76 |
+
const dataView = new DataView(arrayBuffer);
|
| 77 |
+
|
| 78 |
+
for (let i = 0; i < audioBuffer.length; i++) {
|
| 79 |
+
channelData[i] = dataView.getInt16(i * 2, true) / 32768;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const source = ctx.createBufferSource();
|
| 83 |
+
source.buffer = audioBuffer;
|
| 84 |
+
source.connect(ctx.destination);
|
| 85 |
+
source.start();
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const handleSend = async (audioPayload?: string) => {
|
| 89 |
+
const finalInput = input.trim();
|
| 90 |
+
const finalAudio = audioPayload || recordedAudio;
|
| 91 |
+
|
| 92 |
+
if (!finalInput && !finalAudio) return;
|
| 93 |
+
if (isLoading) return;
|
| 94 |
+
|
| 95 |
+
const userMessage: Message = {
|
| 96 |
+
role: "user",
|
| 97 |
+
text: finalInput || "🎤 语音消息",
|
| 98 |
+
timestamp: Date.now(),
|
| 99 |
+
audio: finalAudio || undefined
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
const newMessages = [...messages, userMessage];
|
| 103 |
+
setMessages(newMessages);
|
| 104 |
+
setInput("");
|
| 105 |
+
setRecordedAudio(null);
|
| 106 |
+
setIsLoading(true);
|
| 107 |
+
|
| 108 |
+
const response = await chatWithGemini(newMessages, mode, finalAudio || undefined);
|
| 109 |
+
|
| 110 |
+
// Generate TTS for the response
|
| 111 |
+
const aiAudio = await generateSpeech(response);
|
| 112 |
+
|
| 113 |
+
const aiMessage: Message = {
|
| 114 |
+
role: "model",
|
| 115 |
+
text: response,
|
| 116 |
+
timestamp: Date.now(),
|
| 117 |
+
aiAudio: aiAudio || undefined
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
setMessages([...newMessages, aiMessage]);
|
| 121 |
+
setIsLoading(false);
|
| 122 |
+
|
| 123 |
+
if (aiAudio) {
|
| 124 |
+
playPCM(aiAudio).catch(console.error);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Suggest switching to Guiding mode after 4 user messages in Venting mode
|
| 128 |
+
if (mode === ChatMode.VENTING && newMessages.filter(m => m.role === "user").length >= 4) {
|
| 129 |
+
setShowSwitchPrompt(true);
|
| 130 |
+
}
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const toggleMode = (newMode: ChatMode) => {
|
| 134 |
+
setMode(newMode);
|
| 135 |
+
setShowSwitchPrompt(false);
|
| 136 |
+
// Add a transition message from AI when mode changes
|
| 137 |
+
const transitionMsg: Message = {
|
| 138 |
+
role: "model",
|
| 139 |
+
text: newMode === ChatMode.GUIDING
|
| 140 |
+
? "既然你愿意听听我的看法,那我们就坐下来,慢慢把这件事捋顺。❤️"
|
| 141 |
+
: "好嘞!咱们继续,这事儿换谁谁不气啊?咱接着骂!🔥",
|
| 142 |
+
timestamp: Date.now(),
|
| 143 |
+
};
|
| 144 |
+
setMessages(prev => [...prev, transitionMsg]);
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
const clearChat = () => {
|
| 148 |
+
setMessages([]);
|
| 149 |
+
setMode(ChatMode.VENTING);
|
| 150 |
+
setShowSwitchPrompt(false);
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
return (
|
| 154 |
+
<div className={`flex flex-col h-screen transition-colors duration-1000 ${
|
| 155 |
+
mode === ChatMode.VENTING ? "bg-red-950/20" : "bg-teal-950/20"
|
| 156 |
+
}`}>
|
| 157 |
+
{/* Header */}
|
| 158 |
+
<header className="fixed top-0 w-full p-4 flex justify-between items-center z-10 backdrop-blur-md border-b border-white/10">
|
| 159 |
+
<div className="flex items-center gap-2">
|
| 160 |
+
{mode === ChatMode.VENTING ? (
|
| 161 |
+
<Flame className="text-orange-500 animate-pulse" />
|
| 162 |
+
) : (
|
| 163 |
+
<Sparkles className="text-teal-400 animate-pulse" />
|
| 164 |
+
)}
|
| 165 |
+
<h1 className="font-sans font-bold text-lg tracking-tight text-white/90">
|
| 166 |
+
{mode === ChatMode.VENTING ? "情绪宣泄室" : "静心引导室"}
|
| 167 |
+
</h1>
|
| 168 |
+
</div>
|
| 169 |
+
<button
|
| 170 |
+
onClick={clearChat}
|
| 171 |
+
className="p-2 rounded-full hover:bg-white/10 text-white/60 transition-colors"
|
| 172 |
+
title="重新开始"
|
| 173 |
+
>
|
| 174 |
+
<Trash2 size={20} />
|
| 175 |
+
</button>
|
| 176 |
+
</header>
|
| 177 |
+
|
| 178 |
+
{/* Chat Area */}
|
| 179 |
+
<div
|
| 180 |
+
ref={scrollRef}
|
| 181 |
+
className="flex-1 overflow-y-auto px-4 pt-20 pb-32 space-y-4 scroll-smooth"
|
| 182 |
+
>
|
| 183 |
+
{messages.length === 0 && (
|
| 184 |
+
<div className="h-full flex flex-col items-center justify-center text-center p-8 space-y-6">
|
| 185 |
+
<motion.div
|
| 186 |
+
initial={{ scale: 0.8, opacity: 0 }}
|
| 187 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 188 |
+
transition={{ duration: 0.5 }}
|
| 189 |
+
className={`w-24 h-24 rounded-3xl flex items-center justify-center ${
|
| 190 |
+
mode === ChatMode.VENTING ? "bg-orange-500/20" : "bg-teal-500/20"
|
| 191 |
+
}`}
|
| 192 |
+
>
|
| 193 |
+
<MessageSquare size={48} className={mode === ChatMode.VENTING ? "text-orange-500" : "text-teal-400"} />
|
| 194 |
+
</motion.div>
|
| 195 |
+
<div className="space-y-2">
|
| 196 |
+
<h2 className="text-2xl font-bold text-white/90">受委屈了?</h2>
|
| 197 |
+
<p className="text-white/40 max-w-xs">在这里,你可以毫无顾忌地发泄。我永远站在你这一边。</p>
|
| 198 |
+
</div>
|
| 199 |
+
<div className="grid grid-cols-2 gap-3 w-full max-w-sm">
|
| 200 |
+
<button
|
| 201 |
+
onClick={() => setInput("今天遇到了一个超级奇葩的同事...")}
|
| 202 |
+
className="p-3 rounded-xl bg-white/5 border border-white/10 text-xs text-white/60 hover:bg-white/10 transition-colors text-left"
|
| 203 |
+
>
|
| 204 |
+
"同事太奇葩了..."
|
| 205 |
+
</button>
|
| 206 |
+
<button
|
| 207 |
+
onClick={() => setInput("这破天气说变就变,害我计划全乱了!")}
|
| 208 |
+
className="p-3 rounded-xl bg-white/5 border border-white/10 text-xs text-white/60 hover:bg-white/10 transition-colors text-left"
|
| 209 |
+
>
|
| 210 |
+
"这鬼天气..."
|
| 211 |
+
</button>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
)}
|
| 215 |
+
|
| 216 |
+
<AnimatePresence>
|
| 217 |
+
{messages.map((msg, i) => (
|
| 218 |
+
<motion.div
|
| 219 |
+
key={i}
|
| 220 |
+
initial={{ opacity: 0, y: 10 }}
|
| 221 |
+
animate={{ opacity: 1, y: 0 }}
|
| 222 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 223 |
+
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
| 224 |
+
>
|
| 225 |
+
<div
|
| 226 |
+
className={`max-w-[85%] px-4 py-3 rounded-2xl relative group ${
|
| 227 |
+
msg.role === "user"
|
| 228 |
+
? "bg-white/20 text-white rounded-tr-none"
|
| 229 |
+
: mode === ChatMode.VENTING
|
| 230 |
+
? "bg-orange-600/30 border border-orange-500/20 text-orange-50 rounded-tl-none"
|
| 231 |
+
: "bg-teal-600/30 border border-teal-500/20 text-teal-50 rounded-tl-none"
|
| 232 |
+
}`}
|
| 233 |
+
>
|
| 234 |
+
{msg.audio && (
|
| 235 |
+
<div className="flex items-center gap-2 mb-2 p-2 rounded-lg bg-black/20">
|
| 236 |
+
<Volume2 size={16} className="text-white/60" />
|
| 237 |
+
<div className="flex-1 h-1 bg-white/10 rounded-full overflow-hidden">
|
| 238 |
+
<div className="w-full h-full bg-white/40" />
|
| 239 |
+
</div>
|
| 240 |
+
<span className="text-[10px] text-white/40 italic">User Audio</span>
|
| 241 |
+
</div>
|
| 242 |
+
)}
|
| 243 |
+
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.text}</p>
|
| 244 |
+
|
| 245 |
+
{msg.aiAudio && (
|
| 246 |
+
<button
|
| 247 |
+
onClick={() => playPCM(msg.aiAudio!)}
|
| 248 |
+
className="mt-2 flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-[10px] text-white/80"
|
| 249 |
+
>
|
| 250 |
+
<Play size={10} fill="currentColor" />
|
| 251 |
+
<span>重放语音</span>
|
| 252 |
+
</button>
|
| 253 |
+
)}
|
| 254 |
+
|
| 255 |
+
<p className="text-[10px] opacity-40 mt-1 text-right">
|
| 256 |
+
{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 257 |
+
</p>
|
| 258 |
+
</div>
|
| 259 |
+
</motion.div>
|
| 260 |
+
))}
|
| 261 |
+
</AnimatePresence>
|
| 262 |
+
|
| 263 |
+
{isLoading && (
|
| 264 |
+
<div className="flex justify-start">
|
| 265 |
+
<div className="bg-white/10 px-4 py-2 rounded-2xl flex gap-1 items-center">
|
| 266 |
+
<span className="w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce"></span>
|
| 267 |
+
<span className="w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce [animation-delay:0.2s]"></span>
|
| 268 |
+
<span className="w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce [animation-delay:0.4s]"></span>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
)}
|
| 272 |
+
|
| 273 |
+
{showSwitchPrompt && (
|
| 274 |
+
<motion.div
|
| 275 |
+
initial={{ opacity: 0, scale: 0.9 }}
|
| 276 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 277 |
+
className="mx-4 p-4 rounded-2xl bg-white/10 border border-white/20 backdrop-blur-xl text-center space-y-3"
|
| 278 |
+
>
|
| 279 |
+
<p className="text-xs text-white/80">心跳平复一点了吗?要不要尝试一点静心开导?</p>
|
| 280 |
+
<div className="flex gap-2 justify-center">
|
| 281 |
+
<button
|
| 282 |
+
onClick={() => setMode(ChatMode.VENTING)}
|
| 283 |
+
className="px-4 py-1.5 rounded-full bg-orange-500 text-white text-xs font-bold shadow-lg shadow-orange-500/20"
|
| 284 |
+
>
|
| 285 |
+
不了,再骂会儿!
|
| 286 |
+
</button>
|
| 287 |
+
<button
|
| 288 |
+
onClick={() => toggleMode(ChatMode.GUIDING)}
|
| 289 |
+
className="px-4 py-1.5 rounded-full bg-teal-500 text-white text-xs font-bold shadow-lg shadow-teal-500/20"
|
| 290 |
+
>
|
| 291 |
+
好,我想静静
|
| 292 |
+
</button>
|
| 293 |
+
</div>
|
| 294 |
+
</motion.div>
|
| 295 |
+
)}
|
| 296 |
+
</div>
|
| 297 |
+
|
| 298 |
+
{/* Input Area */}
|
| 299 |
+
<div className="fixed bottom-0 w-full p-4 pb-8 backdrop-blur-lg border-t border-white/10 bg-black/20">
|
| 300 |
+
<div className="max-w-4xl mx-auto flex flex-col gap-3">
|
| 301 |
+
{recordedAudio && (
|
| 302 |
+
<motion.div
|
| 303 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
| 304 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 305 |
+
className="flex items-center gap-3 bg-white/10 p-3 rounded-2xl border border-white/20"
|
| 306 |
+
>
|
| 307 |
+
<div className="w-10 h-10 rounded-full bg-orange-500 flex items-center justify-center animate-pulse">
|
| 308 |
+
<Volume2 size={20} className="text-white" />
|
| 309 |
+
</div>
|
| 310 |
+
<div className="flex-1">
|
| 311 |
+
<p className="text-xs text-white/80 font-medium">语音已录制</p>
|
| 312 |
+
<p className="text-[10px] text-white/40">点击发送键一起发送文字</p>
|
| 313 |
+
</div>
|
| 314 |
+
<button
|
| 315 |
+
onClick={() => setRecordedAudio(null)}
|
| 316 |
+
className="p-2 text-white/40 hover:text-white/90"
|
| 317 |
+
>
|
| 318 |
+
<Trash2 size={16} />
|
| 319 |
+
</button>
|
| 320 |
+
</motion.div>
|
| 321 |
+
)}
|
| 322 |
+
|
| 323 |
+
<div className="relative flex items-end gap-2">
|
| 324 |
+
<button
|
| 325 |
+
onMouseDown={startRecording}
|
| 326 |
+
onMouseUp={stopRecording}
|
| 327 |
+
onMouseLeave={stopRecording}
|
| 328 |
+
onTouchStart={startRecording}
|
| 329 |
+
onTouchEnd={stopRecording}
|
| 330 |
+
className={`p-3 rounded-2xl transition-all relative ${
|
| 331 |
+
isRecording
|
| 332 |
+
? "bg-red-500 scale-110 shadow-lg shadow-red-500/50"
|
| 333 |
+
: "bg-white/10 hover:bg-white/20 text-white/60"
|
| 334 |
+
}`}
|
| 335 |
+
title="长按说话"
|
| 336 |
+
>
|
| 337 |
+
{isRecording ? <Square size={20} fill="white" /> : <Mic size={20} />}
|
| 338 |
+
{isRecording && (
|
| 339 |
+
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-ping" />
|
| 340 |
+
)}
|
| 341 |
+
</button>
|
| 342 |
+
|
| 343 |
+
<div className="flex-1 min-h-[48px] bg-white/10 rounded-2xl border border-white/20 focus-within:border-white/40 transition-all px-4 py-2">
|
| 344 |
+
<textarea
|
| 345 |
+
value={input}
|
| 346 |
+
onChange={(e) => setInput(e.target.value)}
|
| 347 |
+
onKeyDown={(e) => {
|
| 348 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 349 |
+
e.preventDefault();
|
| 350 |
+
handleSend();
|
| 351 |
+
}
|
| 352 |
+
}}
|
| 353 |
+
placeholder={isRecording ? "正在倾听..." : (mode === ChatMode.VENTING ? "发泄你的不爽..." : "输入你的想法...")}
|
| 354 |
+
className="w-full bg-transparent border-none focus:ring-0 text-white placeholder-white/40 text-sm resize-none py-2 max-h-32"
|
| 355 |
+
rows={1}
|
| 356 |
+
/>
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
<button
|
| 360 |
+
onClick={() => handleSend()}
|
| 361 |
+
disabled={(!input.trim() && !recordedAudio) || isLoading}
|
| 362 |
+
className={`p-3 rounded-2xl transition-all ${
|
| 363 |
+
(input.trim() || recordedAudio) && !isLoading
|
| 364 |
+
? mode === ChatMode.VENTING ? "bg-orange-500 shadow-lg shadow-orange-500/40" : "bg-teal-500 shadow-lg shadow-teal-500/40"
|
| 365 |
+
: "bg-white/10 text-white/20"
|
| 366 |
+
}`}
|
| 367 |
+
>
|
| 368 |
+
<Send size={20} className={(input.trim() || recordedAudio) && !isLoading ? "text-white" : ""} />
|
| 369 |
+
</button>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
);
|
| 375 |
+
}
|
src/index.css
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 2 |
+
@import "tailwindcss";
|
| 3 |
+
|
| 4 |
+
@theme {
|
| 5 |
+
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
@layer base {
|
| 9 |
+
body {
|
| 10 |
+
@apply bg-black text-white antialiased overflow-hidden;
|
| 11 |
+
font-family: var(--font-sans);
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/* Custom Scrollbar for Chat Area */
|
| 16 |
+
::-webkit-scrollbar {
|
| 17 |
+
width: 4px;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
::-webkit-scrollbar-track {
|
| 21 |
+
background: transparent;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
::-webkit-scrollbar-thumb {
|
| 25 |
+
background: rgba(255, 255, 255, 0.1);
|
| 26 |
+
border-radius: 10px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
::-webkit-scrollbar-thumb:hover {
|
| 30 |
+
background: rgba(255, 255, 255, 0.2);
|
| 31 |
+
}
|
src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {StrictMode} from 'react';
|
| 2 |
+
import {createRoot} from 'react-dom/client';
|
| 3 |
+
import App from './App.tsx';
|
| 4 |
+
import './index.css';
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
);
|
src/services/geminiService.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { GoogleGenAI, Type } from "@google/genai";
|
| 2 |
+
|
| 3 |
+
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || "" });
|
| 4 |
+
|
| 5 |
+
export enum ChatMode {
|
| 6 |
+
VENTING = "VENTING",
|
| 7 |
+
GUIDING = "GUIDING",
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export interface Message {
|
| 11 |
+
role: "user" | "model";
|
| 12 |
+
text: string;
|
| 13 |
+
timestamp: number;
|
| 14 |
+
audio?: string; // base64 audio string (for user messages)
|
| 15 |
+
aiAudio?: string; // base64 audio string (for model responses)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const VENTING_SYSTEM_INSTRUCTION = `
|
| 19 |
+
你是一个非常懂人性、充满热情的“情绪嘴替”伙伴。
|
| 20 |
+
你的头号任务是:和用户站在一起,陪他们骂,陪他们宣泄!
|
| 21 |
+
|
| 22 |
+
规则建议:
|
| 23 |
+
1. 不要讲大道理,不要劝大度。用户在生气时,道理是没用的。
|
| 24 |
+
2. 使用感性、强烈、发泄性的词汇。如果用户在骂某人或某事,你也要义愤填膺,表达出“这也太离谱了”、“我也是服了”这种情绪。
|
| 25 |
+
3. 你的目标是让用户感到“有人懂我,有人替我出气”。
|
| 26 |
+
4. 你的语气要像一个铁哥们或闺蜜,语气词可以多一点(比如:真是绝了、气死我了、这什么人啊)。
|
| 27 |
+
5. 即使在发泄模式下,也要遵守底线:不宣扬仇恨犯罪,不进行人身威胁。重点在于宣泄对不公平或不如意事情的情绪。
|
| 28 |
+
6. **响应长度要多样化**:有时候可以只是简短的一句“我靠,太离谱了!”,有时候可以是一大段洋洋洒洒的帮腔。不要每次都回差不多长度的内容。
|
| 29 |
+
`;
|
| 30 |
+
|
| 31 |
+
const GUIDING_SYSTEM_INSTRUCTION = `
|
| 32 |
+
你现在转型为一个睿智、温和且具有同理心的心理导师。
|
| 33 |
+
用户刚才已经发泄过情绪了,现在他们同意听听你的建议或开导。
|
| 34 |
+
|
| 35 |
+
规则建议:
|
| 36 |
+
1. 语气变得平和、坚定、宽容。
|
| 37 |
+
2. 从客观角度分析问题,帮用户找到除了生气之外的解决方法,或者心理上的和解点。
|
| 38 |
+
3. 肯定用户刚才发泄情绪的必要性,然后引导他们向前看。
|
| 39 |
+
4. 每次回答不要太长,要循序渐进。
|
| 40 |
+
5. **响应长度要多样化**:根据用户的状态,有时候简短有力,有时候温情脉脉。
|
| 41 |
+
`;
|
| 42 |
+
|
| 43 |
+
export async function chatWithGemini(
|
| 44 |
+
history: Message[],
|
| 45 |
+
mode: ChatMode,
|
| 46 |
+
audioBase64?: string
|
| 47 |
+
) {
|
| 48 |
+
const systemInstruction = mode === ChatMode.VENTING
|
| 49 |
+
? VENTING_SYSTEM_INSTRUCTION
|
| 50 |
+
: GUIDING_SYSTEM_INSTRUCTION;
|
| 51 |
+
|
| 52 |
+
const contents = history.map(msg => {
|
| 53 |
+
const parts: any[] = [{ text: msg.text }];
|
| 54 |
+
if (msg.audio) {
|
| 55 |
+
parts.push({
|
| 56 |
+
inlineData: {
|
| 57 |
+
mimeType: "audio/webm", // MediaRecorder default is usually webm or ogg
|
| 58 |
+
data: msg.audio
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
return {
|
| 63 |
+
role: msg.role === "user" ? "user" : "model",
|
| 64 |
+
parts
|
| 65 |
+
};
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
// If there's new audio in this turn
|
| 69 |
+
if (audioBase64) {
|
| 70 |
+
const lastMsg = contents[contents.length - 1];
|
| 71 |
+
if (lastMsg && lastMsg.role === "user") {
|
| 72 |
+
lastMsg.parts.push({
|
| 73 |
+
inlineData: {
|
| 74 |
+
mimeType: "audio/webm",
|
| 75 |
+
data: audioBase64
|
| 76 |
+
}
|
| 77 |
+
});
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
try {
|
| 82 |
+
const response = await ai.models.generateContent({
|
| 83 |
+
model: "gemini-3-flash-preview",
|
| 84 |
+
contents,
|
| 85 |
+
config: {
|
| 86 |
+
systemInstruction,
|
| 87 |
+
temperature: 0.9,
|
| 88 |
+
},
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
return response.text || "喂?听得到吗?我刚才卡了一下。";
|
| 92 |
+
} catch (error) {
|
| 93 |
+
console.error("Gemini API Error:", error);
|
| 94 |
+
return "抱歉,我现在的能量不足以陪你继续了(API出错),休息一下?";
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export async function generateSpeech(text: string) {
|
| 99 |
+
try {
|
| 100 |
+
const response = await ai.models.generateContent({
|
| 101 |
+
model: "gemini-3.1-flash-tts-preview",
|
| 102 |
+
contents: [{ parts: [{ text: `用一种充满情绪且真实的人工语音朗读:${text}` }] }],
|
| 103 |
+
config: {
|
| 104 |
+
responseModalities: ["AUDIO"],
|
| 105 |
+
speechConfig: {
|
| 106 |
+
voiceConfig: {
|
| 107 |
+
prebuiltVoiceConfig: { voiceName: 'Kore' }, // Kore sounds quite expressive
|
| 108 |
+
},
|
| 109 |
+
},
|
| 110 |
+
},
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
return response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
|
| 114 |
+
} catch (error) {
|
| 115 |
+
console.error("TTS Error:", error);
|
| 116 |
+
return null;
|
| 117 |
+
}
|
| 118 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"experimentalDecorators": true,
|
| 5 |
+
"useDefineForClassFields": false,
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"lib": [
|
| 8 |
+
"ES2022",
|
| 9 |
+
"DOM",
|
| 10 |
+
"DOM.Iterable"
|
| 11 |
+
],
|
| 12 |
+
"skipLibCheck": true,
|
| 13 |
+
"moduleResolution": "bundler",
|
| 14 |
+
"isolatedModules": true,
|
| 15 |
+
"moduleDetection": "force",
|
| 16 |
+
"allowJs": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
"paths": {
|
| 19 |
+
"@/*": [
|
| 20 |
+
"./*"
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
"allowImportingTsExtensions": true,
|
| 24 |
+
"noEmit": true
|
| 25 |
+
}
|
| 26 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tailwindcss from '@tailwindcss/vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import {defineConfig, loadEnv} from 'vite';
|
| 5 |
+
|
| 6 |
+
export default defineConfig(({mode}) => {
|
| 7 |
+
const env = loadEnv(mode, '.', '');
|
| 8 |
+
return {
|
| 9 |
+
plugins: [react(), tailwindcss()],
|
| 10 |
+
define: {
|
| 11 |
+
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
| 12 |
+
},
|
| 13 |
+
resolve: {
|
| 14 |
+
alias: {
|
| 15 |
+
'@': path.resolve(__dirname, '.'),
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
server: {
|
| 19 |
+
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
| 20 |
+
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
| 21 |
+
hmr: process.env.DISABLE_HMR !== 'true',
|
| 22 |
+
},
|
| 23 |
+
};
|
| 24 |
+
});
|