mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user