Compare commits
2 Commits
bee523cd4e
...
66f94f0fcf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66f94f0fcf | ||
|
|
9260f4dc1e |
BIN
PWA/public/1.jpg
Normal file
BIN
PWA/public/1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 437 KiB |
@@ -8,41 +8,86 @@ export type Message = {
|
|||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const API = 'http://localhost:3001/api/chat'
|
const API = '/api/chat'
|
||||||
const STORAGE_KEY = 'shenyan-messages'
|
const HISTORY = '/api/chat-history'
|
||||||
|
const STORAGE_KEY = 'shenyan-chat-messages'
|
||||||
|
const MAX_MSGS = 200
|
||||||
|
|
||||||
function loadMessages(): Message[] {
|
function loadLocal(): Message[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
return raw ? JSON.parse(raw) : []
|
return raw ? JSON.parse(raw) : []
|
||||||
} catch {
|
} catch { return [] }
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextId = (loadMessages().reduce((max, m) => Math.max(max, m.id), 0) || 0) + 1
|
function saveLocal(msgs: Message[]) {
|
||||||
|
const trimmed = msgs.length > MAX_MSGS ? msgs.slice(-MAX_MSGS) : msgs
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRemote(): Promise<Message[] | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(HISTORY)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
if (Array.isArray(data) && data.length > 0) return data
|
||||||
|
return null
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRemote(msgs: Message[]) {
|
||||||
|
try {
|
||||||
|
const trimmed = msgs.length > MAX_MSGS ? msgs.slice(-MAX_MSGS) : msgs
|
||||||
|
await fetch(HISTORY, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(trimmed),
|
||||||
|
})
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [messages, setMessages] = useState<Message[]>(loadMessages)
|
const [messages, setMessages] = useState<Message[]>(loadLocal)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const didLoad = useRef(false)
|
const [synced, setSynced] = useState(false)
|
||||||
|
const nextId = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!didLoad.current) { didLoad.current = true; return }
|
(async () => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages))
|
const remote = await loadRemote()
|
||||||
}, [messages])
|
if (remote && remote.length > 0) {
|
||||||
|
setMessages(remote)
|
||||||
|
nextId.current = Math.max(...remote.map(m => m.id)) + 1
|
||||||
|
} else {
|
||||||
|
const local = loadLocal()
|
||||||
|
if (local.length > 0) {
|
||||||
|
nextId.current = Math.max(...local.map(m => m.id)) + 1
|
||||||
|
saveRemote(local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSynced(true)
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!synced || messages.length === 0) return
|
||||||
|
saveLocal(messages)
|
||||||
|
saveRemote(messages)
|
||||||
|
}, [messages, synced])
|
||||||
|
|
||||||
const handleSend = async (text: string) => {
|
const handleSend = async (text: string) => {
|
||||||
const userMsg: Message = { id: nextId++, role: 'user', text }
|
const userMsg: Message = { id: nextId.current++, role: 'user', text }
|
||||||
const updated = [...messages, userMsg]
|
setMessages((prev) => [...prev, userMsg])
|
||||||
setMessages(updated)
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiMessages = updated.map((m) => ({
|
const apiMessages = [
|
||||||
|
...messages.map((m) => ({
|
||||||
role: m.role === 'user' ? 'user' : 'assistant',
|
role: m.role === 'user' ? 'user' : 'assistant',
|
||||||
content: m.text,
|
content: m.text,
|
||||||
}))
|
})),
|
||||||
|
{ role: 'user', content: userMsg.text },
|
||||||
|
]
|
||||||
|
|
||||||
const res = await fetch(API, {
|
const res = await fetch(API, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -52,9 +97,9 @@ export default function App() {
|
|||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const reply = data.choices?.[0]?.message?.content || '(no response)'
|
const reply = data.choices?.[0]?.message?.content || '(no response)'
|
||||||
setMessages((prev) => [...prev, { id: nextId++, role: 'shenyan', text: reply }])
|
setMessages((prev) => [...prev, { id: nextId.current++, role: 'shenyan', text: reply }])
|
||||||
} catch {
|
} catch {
|
||||||
setMessages((prev) => [...prev, { id: nextId++, role: 'shenyan', text: '连接失败' }])
|
setMessages((prev) => [...prev, { id: nextId.current++, role: 'shenyan', text: '连接失败' }])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -66,9 +111,17 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-black">
|
<div
|
||||||
|
className="flex flex-col h-full"
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'url(/1.jpg)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundAttachment: 'fixed',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<header className="flex items-center justify-center pt-3 pb-2 flex-shrink-0 relative">
|
<header className="flex items-center justify-center pt-3 pb-2 flex-shrink-0 relative">
|
||||||
<span className="text-sm tracking-wide" style={{ color: '#999' }}>沈晏</span>
|
<span className="tracking-wide" style={{ color: '#999', fontSize: '4.35vw' }}>沈晏</span>
|
||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
|
|||||||
@@ -9,18 +9,23 @@ export default function ChatArea({ messages }: { messages: Message[] }) {
|
|||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto no-scrollbar px-4">
|
<div className="flex-1 overflow-y-auto no-scrollbar flex justify-center">
|
||||||
<div className="w-full py-4 space-y-3">
|
<div className="py-4 space-y-3" style={{ width: '90vw', maxWidth: 600 }}>
|
||||||
{messages.map((m) => (
|
{messages.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="max-w-[80%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed"
|
className="max-w-[80%] rounded-2xl text-sm leading-relaxed"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: m.role === 'user' ? '#fff' : 'rgba(255,255,255,0.3)',
|
backgroundColor: m.role === 'user' ? '#ffffffa1' : 'rgba(255,255,255,0.3)',
|
||||||
color: '#888',
|
color: '#ffffff',
|
||||||
|
border: m.role === 'shenyan' ? '1px solid rgba(255,255,255,0.4)' : 'none',
|
||||||
|
padding: 12,
|
||||||
|
fontSize: '4.5vw',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{m.text}
|
{m.text}
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ export default function InputArea({ onSend, disabled }: { onSend: (text: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 px-4 pb-6 pt-3" style={{ height: '25vw' }}>
|
<div
|
||||||
|
className="flex-shrink-0 flex justify-center"
|
||||||
|
style={{ padding: 17, backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)' }}
|
||||||
|
>
|
||||||
|
<div style={{ height: '25vw', width: '90vw', maxWidth: 600 }}>
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="输入"
|
placeholder="输入"
|
||||||
@@ -24,17 +28,18 @@ export default function InputArea({ onSend, disabled }: { onSend: (text: string)
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full h-full px-4 pt-2.5 pb-10 rounded-xl text-sm outline-none resize-none"
|
className="w-full h-full rounded-xl outline-none resize-none"
|
||||||
style={{ backgroundColor: '#2a2a2a', color: disabled ? '#555' : '#888' }}
|
style={{ backgroundColor: '#2a2a2a', color: disabled ? '#555' : '#888', fontSize: '4.35vw', padding: '4.11vw' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="absolute rounded-lg text-xs px-3 py-1"
|
className="absolute rounded-lg px-3 py-1"
|
||||||
style={{ right: 12, bottom: 12, backgroundColor: '#3a3a3a', color: '#999' }}
|
style={{ right: '4.11vw', bottom: '4.11vw', backgroundColor: '#3a3a3a', color: '#999', fontSize: '3.38vw' }}
|
||||||
>
|
>
|
||||||
发送
|
发送
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user