feat: add browser environment support with access token authentication

Enable the web client to run in plain browser environments alongside Telegram Mini App support:

- Server: Add CLI_API_TOKEN authentication path as alternative to Telegram initData
- Client: Add useAuthSource hook to detect and manage Telegram vs browser auth sources
- Client: Add usePlatform hook for platform abstraction with graceful haptic feedback degradation
- Client: Add LoginPrompt component for browser access token login
- Client: Extend useTheme to fall back to system prefers-color-scheme in browser
- Client: Migrate all direct HapticFeedback calls to use usePlatform hook
This commit is contained in:
weishu
2025-12-18 12:45:49 +08:00
parent 5fe1256e11
commit cf2b96b566
14 changed files with 552 additions and 96 deletions
@@ -14,7 +14,7 @@ import type { Suggestion } from '@/hooks/useActiveSuggestions'
import { useActiveWord } from '@/hooks/useActiveWord'
import { useActiveSuggestions } from '@/hooks/useActiveSuggestions'
import { applySuggestion } from '@/utils/applySuggestion'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { usePlatform } from '@/hooks/usePlatform'
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
import { StatusBar } from '@/components/AssistantChat/StatusBar'
@@ -95,6 +95,7 @@ export function HappyComposer(props: {
})
}, [composerText])
const { haptic: platformHaptic } = usePlatform()
const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes)
const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions(
activeWord,
@@ -103,15 +104,14 @@ export function HappyComposer(props: {
)
const haptic = useCallback((type: 'light' | 'success' | 'error' = 'light') => {
const tg = getTelegramWebApp()
if (type === 'light') {
tg?.HapticFeedback?.impactOccurred('light')
platformHaptic.impact('light')
} else if (type === 'success') {
tg?.HapticFeedback?.notificationOccurred('success')
platformHaptic.notification('success')
} else {
tg?.HapticFeedback?.notificationOccurred('error')
platformHaptic.notification('error')
}
}, [])
}, [platformHaptic])
const handleSuggestionSelect = useCallback((index: number) => {
const suggestion = suggestions[index]
+113
View File
@@ -0,0 +1,113 @@
import type { Themes } from 'react-shiki/web'
import { useMemo, useState } from 'react'
import { useShikiHighlighter } from 'react-shiki/web'
import { usePlatform } from '@/hooks/usePlatform'
const SHIKI_THEMES: Themes = {
light: 'github-light',
dark: 'github-dark',
}
function normalizeLanguage(language?: string): string {
const raw = language?.trim()
if (!raw) return 'text'
const cleaned = raw.startsWith('language-') ? raw.slice('language-'.length) : raw
const canonical = cleaned.toLowerCase()
if (canonical === 'text' || canonical === 'plaintext' || canonical === 'txt') return 'text'
return cleaned
}
function safeCopyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text)
}
return Promise.reject(new Error('Clipboard API not available'))
}
function CopyIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
}
function CheckIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
export function CodeBlock(props: {
code: string
language?: string
showCopyButton?: boolean
}) {
const { haptic } = usePlatform()
const showCopyButton = props.showCopyButton ?? true
const normalizedLanguage = useMemo(() => normalizeLanguage(props.language), [props.language])
const [copied, setCopied] = useState(false)
const highlighted = useShikiHighlighter(props.code, normalizedLanguage, SHIKI_THEMES, {
delay: 75,
structure: 'inline',
})
const handleCopy = async () => {
try {
await safeCopyToClipboard(props.code)
haptic.notification('success')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
haptic.notification('error')
}
}
return (
<div className="relative overflow-hidden rounded-md bg-[var(--app-code-bg)]">
{showCopyButton ? (
<button
type="button"
onClick={handleCopy}
className="absolute right-1.5 top-1.5 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title="Copy"
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
) : null}
<pre className="overflow-auto p-2 pr-8 text-xs">
<code className="shiki font-mono">
{highlighted ?? props.code}
</code>
</pre>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useCallback, useState } from 'react'
import { ApiClient } from '@/api/client'
type LoginPromptProps = {
onLogin: (token: string) => void
error?: string | null
}
export function LoginPrompt(props: LoginPromptProps) {
const [accessToken, setAccessToken] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
const trimmedToken = accessToken.trim()
if (!trimmedToken) {
setError('Please enter an access token')
return
}
setIsLoading(true)
setError(null)
try {
// Validate the token by attempting to authenticate
const client = new ApiClient('')
await client.authenticate({ accessToken: trimmedToken })
// If successful, pass the token to parent
props.onLogin(trimmedToken)
} catch (e) {
setError(e instanceof Error ? e.message : 'Authentication failed')
} finally {
setIsLoading(false)
}
}, [accessToken, props])
const displayError = error || props.error
return (
<div className="h-full flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<div className="text-2xl font-semibold">Happy</div>
<div className="text-sm text-[var(--app-hint)]">
Enter your access token to continue
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<input
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="Access Token"
autoComplete="current-password"
disabled={isLoading}
className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent disabled:opacity-50"
/>
</div>
{displayError && (
<div className="text-sm text-red-500 text-center">
{displayError}
</div>
)}
<button
type="submit"
disabled={isLoading || !accessToken.trim()}
className="w-full py-2.5 rounded-lg bg-[var(--app-button)] text-[var(--app-button-text)] font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
{/* Help text */}
<div className="text-xs text-[var(--app-hint)] text-center">
Use the CLI_API_TOKEN from your server configuration
</div>
</div>
</div>
)
}
+8 -7
View File
@@ -10,7 +10,7 @@ import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
import { HappyThread } from '@/components/AssistantChat/HappyThread'
import { useHappyRuntime } from '@/lib/assistant-runtime'
import { SessionHeader } from '@/components/SessionHeader'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { usePlatform } from '@/hooks/usePlatform'
export function SessionChat(props: {
api: ApiClient
@@ -27,6 +27,7 @@ export function SessionChat(props: {
onSend: (text: string) => void
onRetryMessage?: (localId: string) => void
}) {
const { haptic } = usePlatform()
const controlsDisabled = !props.session.active
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
@@ -63,25 +64,25 @@ export function SessionChat(props: {
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
try {
await props.api.setPermissionMode(props.session.id, mode as 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan')
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
props.onRefresh()
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
console.error('Failed to set permission mode:', e)
}
}, [props.api, props.session.id, props.onRefresh])
}, [props.api, props.session.id, props.onRefresh, haptic])
// Model mode change handler
const handleModelModeChange = useCallback(async (mode: ModelMode) => {
try {
await props.api.setModelMode(props.session.id, mode as 'default' | 'sonnet' | 'opus')
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
props.onRefresh()
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
console.error('Failed to set model mode:', e)
}
}, [props.api, props.session.id, props.onRefresh])
}, [props.api, props.session.id, props.onRefresh, haptic])
// Abort handler
const handleAbort = useCallback(async () => {
+5 -4
View File
@@ -3,7 +3,7 @@ import type { ApiClient } from '@/api/client'
import type { Machine } from '@/types/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { usePlatform } from '@/hooks/usePlatform'
function getMachineTitle(machine: Machine | null): string {
if (!machine) return 'Machine'
@@ -19,6 +19,7 @@ export function SpawnSession(props: {
onSuccess: (sessionId: string) => void
onCancel: () => void
}) {
const { haptic } = usePlatform()
const [directory, setDirectory] = useState('')
const [isWorking, setIsWorking] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -34,14 +35,14 @@ export function SpawnSession(props: {
try {
const result = await props.api.spawnSession(props.machineId, trimmed)
if (result.type === 'success') {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
props.onSuccess(result.sessionId)
return
}
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
setError(result.message)
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
setError(e instanceof Error ? e.message : 'Failed to spawn session')
} finally {
setIsWorking(false)
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import type { ChatToolCall, ToolPermission } from '@/chat/types'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { usePlatform } from '@/hooks/usePlatform'
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
@@ -108,6 +108,7 @@ export function PermissionFooter(props: {
disabled: boolean
onDone: () => void
}) {
const { haptic } = usePlatform()
const permission = props.tool.permission
const [loading, setLoading] = useState<'allow' | 'deny' | 'abort' | null>(null)
const [loadingAllEdits, setLoadingAllEdits] = useState(false)
@@ -121,15 +122,15 @@ export function PermissionFooter(props: {
const summary = formatPermissionSummary(permission, props.tool.name, props.tool.input, codex)
const isPending = permission.status === 'pending'
const run = async (action: () => Promise<void>, haptic: 'success' | 'error') => {
const run = async (action: () => Promise<void>, hapticType: 'success' | 'error') => {
if (props.disabled) return
setError(null)
try {
await action()
getTelegramWebApp()?.HapticFeedback?.notificationOccurred(haptic)
haptic.notification(hapticType)
props.onDone()
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
setError(e instanceof Error ? e.message : 'Request failed')
}
}
@@ -8,7 +8,7 @@ import {
type CodeHeaderProps,
} from '@assistant-ui/react-markdown'
import remarkGfm from 'remark-gfm'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { getPlatform } from '@/hooks/usePlatform'
import { cn } from '@/lib/utils'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
@@ -66,13 +66,14 @@ function CodeHeader(props: CodeHeaderProps) {
const language = props.language && props.language !== 'unknown' ? props.language : ''
const handleCopy = async () => {
const { haptic } = getPlatform()
try {
await safeCopyToClipboard(props.code)
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
}
}