MSF commited on
Commit
0d3686b
·
1 Parent(s): 9ce7ac5

feat: Initialize Venting Partner app with AI Studio

Browse files

Sets 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 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
- <h1>Built with AI Studio</h2>
6
 
7
- <p>The fastest path from prompt to production with Gemini.</p>
8
 
9
- <a href="https://aistudio.google.com/apps">Start building</a>
10
 
11
- </div>
 
 
 
 
 
 
 
 
 
 
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
+ });