first commit

This commit is contained in:
龙澳
2026-03-23 14:32:49 +08:00
commit 92bc02142d
10 changed files with 2351 additions and 0 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
VITE_API_BASE=http://127.0.0.1:9000
VITE_AUTH_BASE=http://127.0.0.1:9100
VITE_LOCAL_AUTH_ONLY=false
VITE_SENDER_ID=web-user
VITE_CHAT_ID=web-user
VITE_API_TOKEN=

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

37
README.md Normal file
View File

@@ -0,0 +1,37 @@
# web-chat-frontend
A minimal Vue chat client for nanobot WebChannel.
Now supports phone + password login before entering chat.
## Setup
```bash
cd web-chat-frontend
npm install
cp .env.example .env
npm run dev
```
By default it connects to `http://127.0.0.1:9000`.
## Environment Variables
- `VITE_API_BASE`: WebChannel base URL
- `VITE_AUTH_BASE`: Standalone auth service base URL
- `VITE_SENDER_ID`: sender id passed to WebChannel `/message`
- `VITE_CHAT_ID`: chat id used for SSE stream and routing
- `VITE_API_TOKEN`: fixed bearer token for WebChannel auth
## Endpoints Expected From WebChannel
- `POST /message`
- `GET /events/{chat_id}` (SSE)
- `GET /history/{chat_id}`
- `GET /health`
## Endpoints Expected From Auth Service
- `POST /auth/register`
- `POST /auth/login`
- `GET /auth/me`

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nanobot web chat</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1324
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "web-chat-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"vite": "^6.3.0"
}
}

581
src/App.vue Normal file
View File

@@ -0,0 +1,581 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const apiBase = ref(import.meta.env.VITE_API_BASE || 'http://127.0.0.1:9000')
const authBase = ref(import.meta.env.VITE_AUTH_BASE || apiBase.value)
const localAuthOnly = ref(String(import.meta.env.VITE_LOCAL_AUTH_ONLY || 'false').toLowerCase() === 'true')
const senderId = ref(import.meta.env.VITE_SENDER_ID || 'web-user')
const chatId = ref(import.meta.env.VITE_CHAT_ID || 'web-user')
const apiToken = ref(import.meta.env.VITE_API_TOKEN || '')
const loginPhone = ref('')
const loginPassword = ref('')
const verificationCode = ref('')
const authLoading = ref(false)
const authError = ref('')
const authMode = ref('login')
const currentUser = ref(null)
const registerPending = ref(false)
const registerRequestId = ref(0)
const registerStatusMessage = ref('')
const codeCountdown = ref(0)
const text = ref('')
const messages = ref([])
const sending = ref(false)
const streamState = ref('disconnected')
const messagesEl = ref(null)
let eventSource = null
let historyPollTimer = null
let registerPollTimer = null
let codeCountdownTimer = null
const seenMessages = new Set()
const canSend = computed(() => text.value.trim().length > 0 && !sending.value)
const isLoggedIn = computed(() => !!currentUser.value && (localAuthOnly.value || !!apiToken.value))
function messageId() {
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
return globalThis.crypto.randomUUID()
}
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function messageSignature(role, content, kind, at) {
return `${role}|${kind}|${at}|${content}`
}
function pushMessage(role, content, kind = 'message') {
const at = new Date().toLocaleTimeString()
const sig = messageSignature(role, content, kind, at)
if (seenMessages.has(sig)) return
seenMessages.add(sig)
messages.value.push({
id: messageId(),
role,
content,
kind,
at,
})
}
function scrollToBottom(smooth = true) {
nextTick(() => {
if (!messagesEl.value) return
messagesEl.value.scrollTo({
top: messagesEl.value.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
})
})
}
function authHeaders() {
if (localAuthOnly.value) return {}
const token = apiToken.value.trim()
return token ? { Authorization: `Bearer ${token}` } : {}
}
function saveSession(token, user) {
apiToken.value = token
currentUser.value = user
senderId.value = user.phone
chatId.value = user.phone
localStorage.setItem('nb_web_token', token)
localStorage.setItem('nb_web_user', JSON.stringify(user))
}
function clearSession() {
apiToken.value = ''
currentUser.value = null
localStorage.removeItem('nb_web_token')
localStorage.removeItem('nb_web_user')
messages.value = []
seenMessages.clear()
if (eventSource) {
eventSource.close()
eventSource = null
}
if (historyPollTimer) {
clearInterval(historyPollTimer)
historyPollTimer = null
}
if (registerPollTimer) {
clearInterval(registerPollTimer)
registerPollTimer = null
}
if (codeCountdownTimer) {
clearInterval(codeCountdownTimer)
codeCountdownTimer = null
}
registerPending.value = false
registerRequestId.value = 0
registerStatusMessage.value = ''
codeCountdown.value = 0
streamState.value = 'disconnected'
}
function startCodeCountdown() {
if (codeCountdownTimer) {
clearInterval(codeCountdownTimer)
codeCountdownTimer = null
}
codeCountdown.value = 120
codeCountdownTimer = setInterval(() => {
if (codeCountdown.value <= 1) {
codeCountdown.value = 0
if (codeCountdownTimer) {
clearInterval(codeCountdownTimer)
codeCountdownTimer = null
}
return
}
codeCountdown.value -= 1
}, 1000)
}
function startRegisterStatusPolling() {
if (registerPollTimer) {
clearInterval(registerPollTimer)
registerPollTimer = null
}
if (!registerRequestId.value) return
registerPollTimer = setInterval(async () => {
try {
const resp = await fetch(`${authBase.value}/auth/register/status/${registerRequestId.value}`)
const payload = await resp.json().catch(() => ({}))
if (!resp.ok || !payload.ok) return
const status = String(payload.status || '')
if (status === 'pending') {
registerStatusMessage.value = '待后端验证'
return
}
if (status === 'approved') {
if (registerPollTimer) {
clearInterval(registerPollTimer)
registerPollTimer = null
}
registerPending.value = false
registerStatusMessage.value = '注册成功,正在登录...'
codeCountdown.value = 0
if (codeCountdownTimer) {
clearInterval(codeCountdownTimer)
codeCountdownTimer = null
}
await login()
return
}
if (status === 'rejected') {
if (registerPollTimer) {
clearInterval(registerPollTimer)
registerPollTimer = null
}
registerPending.value = false
registerStatusMessage.value = payload.review_note || '注册被拒绝'
}
} catch (_) {
// Keep polling for transient network errors.
}
}, 2000)
}
function startHistoryPolling() {
if (historyPollTimer) return
historyPollTimer = setInterval(() => {
if (isLoggedIn.value && streamState.value !== 'connected') {
loadHistory()
}
}, 5000)
}
async function callAuth(endpoint) {
if (localAuthOnly.value) {
authError.value = ''
const phone = loginPhone.value.trim()
const password = loginPassword.value
if (!/^\d{6,20}$/.test(phone)) {
authError.value = 'phone must be 6-20 digits'
return false
}
if (password.length < 1) {
authError.value = 'password is required'
return false
}
saveSession('', { id: 1, phone })
loginPassword.value = ''
await loadHistory()
connectStream()
startHistoryPolling()
return true
}
authLoading.value = true
authError.value = ''
try {
const resp = await fetch(`${authBase.value}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: loginPhone.value.trim(),
password: loginPassword.value,
}),
})
const payload = await resp.json().catch(() => ({}))
if (!resp.ok || !payload.ok) {
authError.value = payload.error || 'auth failed'
return false
}
saveSession(payload.access_token, payload.user)
loginPassword.value = ''
await loadHistory()
connectStream()
startHistoryPolling()
return true
} catch (err) {
authError.value = String(err)
return false
} finally {
authLoading.value = false
}
}
async function login() {
await callAuth('/auth/login')
}
async function register() {
if (localAuthOnly.value) {
await callAuth('/auth/register')
return
}
authLoading.value = true
authError.value = ''
registerStatusMessage.value = ''
try {
const resp = await fetch(`${authBase.value}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: loginPhone.value.trim(),
password: loginPassword.value,
verification_code: verificationCode.value.trim(),
}),
})
const payload = await resp.json().catch(() => ({}))
if (!resp.ok || !payload.ok) {
authError.value = payload.detail || payload.error || 'register failed'
return
}
registerPending.value = true
registerRequestId.value = Number(payload.request_id || 0)
registerStatusMessage.value = '待后端验证'
startCodeCountdown()
startRegisterStatusPolling()
} catch (err) {
authError.value = String(err)
} finally {
authLoading.value = false
}
}
async function restoreSession() {
const token = localStorage.getItem('nb_web_token') || apiToken.value
const userRaw = localStorage.getItem('nb_web_user')
if (!token && !localAuthOnly.value) return
apiToken.value = token || ''
if (userRaw) {
try {
currentUser.value = JSON.parse(userRaw)
senderId.value = currentUser.value.phone
chatId.value = currentUser.value.phone
} catch (_) {
localStorage.removeItem('nb_web_user')
}
}
if (localAuthOnly.value) {
if (!currentUser.value) return
await loadHistory()
connectStream()
startHistoryPolling()
return
}
try {
const resp = await fetch(`${authBase.value}/auth/me`, {
headers: {
...authHeaders(),
},
})
const payload = await resp.json().catch(() => ({}))
if (!resp.ok || !payload.ok || !payload.user) {
clearSession()
return
}
saveSession(token, payload.user)
await loadHistory()
connectStream()
startHistoryPolling()
} catch (_) {
clearSession()
}
}
async function loadHistory() {
try {
const resp = await fetch(`${apiBase.value}/history/${encodeURIComponent(chatId.value)}`, {
headers: {
...authHeaders(),
},
})
if (!resp.ok) return
const payload = await resp.json()
const history = Array.isArray(payload.messages) ? payload.messages : []
const nextMessages = history.map((m) => ({
id: messageId(),
role: m.role || 'assistant',
content: m.content || '',
kind: m.type || 'message',
at: m.at || '-',
}))
const currentFingerprint = messages.value
.map((m) => `${m.role}|${m.kind}|${m.at}|${m.content}`)
.join('\n')
const nextFingerprint = nextMessages
.map((m) => `${m.role}|${m.kind}|${m.at}|${m.content}`)
.join('\n')
if (currentFingerprint !== nextFingerprint) {
seenMessages.clear()
for (const m of nextMessages) {
seenMessages.add(messageSignature(m.role, m.content, m.kind, m.at))
}
messages.value = nextMessages
scrollToBottom(false)
}
} catch (_) {
// Keep UI responsive even if history endpoint is unreachable.
}
}
function connectStream() {
if (eventSource) {
eventSource.close()
eventSource = null
}
streamState.value = 'connecting'
const token = encodeURIComponent(apiToken.value.trim())
const suffix = token ? `?token=${token}` : ''
const url = `${apiBase.value}/events/${encodeURIComponent(chatId.value)}${suffix}`
eventSource = new EventSource(url)
eventSource.onopen = () => {
streamState.value = 'connected'
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (!data || typeof data.content !== 'string') return
const role = data.role || 'assistant'
const kind = data.type || 'message'
const at = data.at || new Date().toLocaleTimeString()
const sig = messageSignature(role, data.content, kind, at)
if (seenMessages.has(sig)) return
seenMessages.add(sig)
messages.value.push({
id: messageId(),
role,
content: data.content,
kind,
at,
})
} catch (_) {
// Ignore malformed payload and keep the stream alive.
}
}
eventSource.onerror = () => {
streamState.value = 'reconnecting'
}
}
async function sendMessage() {
const content = text.value.trim()
if (!content) return
sending.value = true
pushMessage('user', content)
try {
const resp = await fetch(`${apiBase.value}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({
sender: senderId.value,
chat_id: chatId.value,
text: content,
}),
})
if (!resp.ok) {
const detail = await resp.text()
pushMessage('system', `Send failed: ${detail || resp.statusText}`, 'error')
} else {
setTimeout(() => {
loadHistory()
}, 800)
}
} catch (err) {
pushMessage('system', `Send failed: ${String(err)}`, 'error')
} finally {
text.value = ''
sending.value = false
}
}
onMounted(async () => {
await restoreSession()
})
watch(
() => messages.value.length,
(curr, prev) => {
if (curr > prev) {
scrollToBottom(true)
}
},
)
onBeforeUnmount(() => {
if (eventSource) eventSource.close()
if (historyPollTimer) clearInterval(historyPollTimer)
if (registerPollTimer) clearInterval(registerPollTimer)
if (codeCountdownTimer) clearInterval(codeCountdownTimer)
})
</script>
<template>
<main class="screen">
<section v-if="!isLoggedIn" class="auth-shell">
<header class="top-bar">
<div>
<h1>nanobot login</h1>
<p>Use phone + password to access your chat workspace</p>
</div>
</header>
<div class="auth-form">
<label>
phone
<input v-model="loginPhone" placeholder="e.g. 13800000000" />
</label>
<label>
password
<input v-model="loginPassword" type="password" placeholder="at least 6 chars" />
</label>
<label v-if="!localAuthOnly">
verification code
<input v-model="verificationCode" placeholder="请输入验证码" />
</label>
<div class="auth-actions">
<button :disabled="authLoading" @click="login">Login</button>
<button class="reconnect" :disabled="authLoading" @click="register">Register</button>
</div>
<p class="auth-error" v-if="authError">{{ authError }}</p>
<p class="auth-error" v-if="registerPending || registerStatusMessage">
{{ registerStatusMessage || '待后端验证' }}
</p>
<p class="auth-error" v-if="registerPending && codeCountdown > 0">
验证码倒计时{{ codeCountdown }}s
</p>
</div>
</section>
<section v-else class="chat-shell">
<div class="workspace">
<aside class="workspace-sidebar">
<div class="sidebar-brand">
<h2>nanobot</h2>
<p>AI workspace</p>
</div>
<div class="sidebar-card">
<span class="card-label">Account</span>
<strong>{{ currentUser?.phone }}</strong>
<span class="badge" :class="streamState">{{ streamState }}</span>
<div class="sidebar-actions">
<button class="reconnect" @click="connectStream">Reconnect</button>
<button class="reconnect" @click="clearSession">Logout</button>
</div>
</div>
<div class="config-panel">
<label>
API
<input v-model="apiBase" placeholder="http://127.0.0.1:9000" />
</label>
<label v-if="!localAuthOnly">
AUTH API
<input v-model="authBase" placeholder="http://127.0.0.1:9100" />
</label>
<label>
sender
<input v-model="senderId" placeholder="web-user" />
</label>
<label>
chat
<input v-model="chatId" placeholder="web-user" />
</label>
<label v-if="!localAuthOnly">
token
<input v-model="apiToken" placeholder="Bearer token" readonly />
</label>
</div>
</aside>
<section class="workspace-main">
<header class="main-head">
<h1>智能对话</h1>
<p> nanobot 的实时会话</p>
</header>
<div class="messages" ref="messagesEl">
<article
v-for="msg in messages"
:key="msg.id"
class="bubble"
:class="[msg.role, msg.kind]"
>
<p>{{ msg.content }}</p>
<small>{{ msg.role }} · {{ msg.at }}</small>
</article>
</div>
<footer class="composer">
<textarea
v-model="text"
rows="2"
placeholder="给 nanobot 发送消息"
@keydown.enter.exact.prevent="sendMessage"
/>
<button :disabled="!canSend" @click="sendMessage">发送</button>
</footer>
</section>
</div>
</section>
</main>
</template>

5
src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

355
src/style.css Normal file
View File

@@ -0,0 +1,355 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--bg: #eef3fb;
--bg-soft: #f6f8fd;
--panel: rgba(255, 255, 255, 0.84);
--panel-strong: #ffffff;
--line: #d9e3f1;
--ink: #1c2d43;
--ink-soft: #6c7d94;
--brand: #2b63d9;
--brand-soft: #eaf1ff;
--danger: #bf3f3f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: 'Noto Sans SC', sans-serif;
background:
radial-gradient(1200px 700px at 0% 0%, #ffffff, transparent 70%),
linear-gradient(180deg, #f8faff 0%, #eef3fb 55%, #e9f0fb 100%);
}
.screen {
min-height: 100vh;
padding: 1.2rem;
display: grid;
place-items: center;
}
.chat-shell {
width: min(1280px, 100%);
min-height: calc(100vh - 2.4rem);
border-radius: 22px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 24px 44px rgba(34, 68, 120, 0.12);
overflow: hidden;
}
.workspace {
min-height: calc(100vh - 2.4rem);
display: grid;
grid-template-columns: 320px 1fr;
}
.workspace-sidebar {
border-right: 1px solid var(--line);
background: linear-gradient(180deg, #f8fbff, #f1f5fc 75%);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.sidebar-brand h2 {
margin: 0;
font-size: 1.15rem;
}
.sidebar-brand p {
margin: 0.28rem 0 0;
color: var(--ink-soft);
font-size: 0.88rem;
}
.sidebar-card {
border: 1px solid var(--line);
background: var(--panel-strong);
border-radius: 14px;
padding: 0.8rem;
display: grid;
gap: 0.42rem;
}
.card-label {
font-size: 0.78rem;
color: var(--ink-soft);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sidebar-actions {
display: flex;
gap: 0.45rem;
}
.config-panel {
border: 1px solid var(--line);
background: var(--panel-strong);
border-radius: 14px;
padding: 0.75rem;
display: grid;
gap: 0.55rem;
}
.config-panel label {
display: grid;
gap: 0.2rem;
font-size: 0.78rem;
color: var(--ink-soft);
text-transform: uppercase;
font-family: 'JetBrains Mono', monospace;
}
.workspace-main {
display: grid;
grid-template-rows: auto 1fr auto;
background: rgba(255, 255, 255, 0.58);
}
.main-head {
padding: 1rem 1.2rem 0.7rem;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.72);
}
.main-head h1 {
margin: 0;
font-size: 1.08rem;
}
.main-head p {
margin: 0.28rem 0 0;
color: var(--ink-soft);
font-size: 0.88rem;
}
.auth-shell {
width: min(540px, 100%);
border-radius: 20px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 20px 34px rgba(42, 77, 132, 0.12);
overflow: hidden;
animation: rise 460ms ease-out;
}
.auth-form {
padding: 1rem 1.1rem 1.2rem;
display: grid;
gap: 0.7rem;
}
.auth-actions {
display: flex;
gap: 0.55rem;
}
.auth-error {
margin: 0;
color: var(--danger);
font-family: 'JetBrains Mono', monospace;
font-size: 0.82rem;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(18px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.top-bar {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: center;
padding: 1rem 1.1rem 0.7rem;
border-bottom: 1px solid var(--line);
}
.top-bar h1 {
margin: 0;
font-size: 1.2rem;
text-transform: lowercase;
}
.top-bar p {
margin: 0.25rem 0 0;
color: var(--ink-soft);
font-size: 0.92rem;
}
.badge {
border-radius: 999px;
padding: 0.38rem 0.68rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.77rem;
border: 1px solid var(--line);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.badge.connected {
background: #e7f6ef;
color: #177b56;
}
.badge.connecting,
.badge.reconnecting {
background: var(--brand-soft);
color: var(--brand);
}
.badge.disconnected {
background: #ffecec;
color: var(--danger);
}
input,
textarea {
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
padding: 0.62rem 0.72rem;
color: var(--ink);
font-family: inherit;
}
input:focus,
textarea:focus {
outline: none;
border-color: #90afe5;
box-shadow: 0 0 0 3px rgba(89, 132, 220, 0.15);
}
.messages {
overflow: auto;
padding: 1rem 1.15rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bubble {
max-width: min(72ch, 82%);
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.72rem 0.82rem;
background: #ffffff;
animation: fadeIn 220ms ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bubble.user {
align-self: flex-end;
background: linear-gradient(135deg, #e8f0ff, #f0f5ff);
border-color: #bfd1f6;
}
.bubble.assistant {
align-self: flex-start;
background: #fff;
}
.bubble.system,
.bubble.error {
align-self: center;
background: #fff3f3;
}
.bubble p {
margin: 0;
white-space: pre-wrap;
line-height: 1.45;
}
.bubble small {
margin-top: 0.35rem;
display: block;
color: var(--ink-soft);
font-family: 'JetBrains Mono', monospace;
font-size: 0.76rem;
}
.composer {
border-top: 1px solid var(--line);
padding: 0.85rem 1.15rem 1.15rem;
display: grid;
gap: 0.65rem;
grid-template-columns: 1fr auto;
background: rgba(255, 255, 255, 0.8);
}
button {
border: 1px solid transparent;
border-radius: 10px;
background: linear-gradient(120deg, #3d71e5, #2b63d9);
color: #fff;
font-weight: 700;
cursor: pointer;
padding: 0.64rem 0.95rem;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.reconnect {
background: #fff;
color: var(--ink);
border: 1px solid var(--line);
font-weight: 500;
}
@media (max-width: 820px) {
.screen {
padding: 0;
}
.chat-shell {
min-height: 100vh;
border-radius: 0;
width: 100%;
}
.workspace {
grid-template-columns: 1fr;
}
.workspace-sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.composer {
grid-template-columns: 1fr;
}
.bubble {
max-width: 100%;
}
}

10
vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
host: true,
},
})