initial web UI: Preact + Vite, 9P filesystem identity
Dark teal theme, polling-based session management, markdown
rendering for assistant messages. Sidebar shows /s/ tree;
chat header shows /s/{id}/chat path. New session modal fetches
available backends from the httpgw.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2515540822
commit
8938409f72
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.local
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ollie</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "ollie-webui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"marked": "^9.1.6",
|
||||||
|
"preact": "^10.25.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.9.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'preact/hooks'
|
||||||
|
import { Sidebar } from './components/Sidebar'
|
||||||
|
import { ChatPane } from './components/ChatPane'
|
||||||
|
import { NewSession } from './components/NewSession'
|
||||||
|
import {
|
||||||
|
listSessions,
|
||||||
|
getSessionCfg,
|
||||||
|
getChatLines,
|
||||||
|
sendPrompt,
|
||||||
|
sendCtl,
|
||||||
|
createSession,
|
||||||
|
} from './lib/api'
|
||||||
|
import type { IdxEntry } from './lib/api'
|
||||||
|
import { parseChat } from './lib/chat'
|
||||||
|
import type { ChatMessage } from './lib/chat'
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [sessions, setSessions] = useState<IdxEntry[]>([])
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [cfg, setCfg] = useState<Record<string, string> | null>(null)
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
|
||||||
|
// Session list — poll every 5s.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
const poll = () => listSessions().then(s => { if (alive) setSessions(s) }).catch(() => {})
|
||||||
|
poll()
|
||||||
|
const id = setInterval(poll, 5000)
|
||||||
|
return () => { alive = false; clearInterval(id) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Active session — poll every 2s.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) { setCfg(null); setMessages([]); return }
|
||||||
|
let alive = true
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const [c, lines] = await Promise.all([
|
||||||
|
getSessionCfg(selectedId),
|
||||||
|
getChatLines(selectedId),
|
||||||
|
])
|
||||||
|
if (!alive) return
|
||||||
|
setCfg(c)
|
||||||
|
setMessages(parseChat(lines))
|
||||||
|
} catch {
|
||||||
|
// session may have been removed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll()
|
||||||
|
const id = setInterval(poll, 2000)
|
||||||
|
return () => { alive = false; clearInterval(id) }
|
||||||
|
}, [selectedId])
|
||||||
|
|
||||||
|
const handleSend = useCallback((text: string) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
// fire-and-forget; the 9P write blocks until the turn completes server-side
|
||||||
|
sendPrompt(selectedId, text).catch(() => {})
|
||||||
|
}, [selectedId])
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
if (!selectedId) return
|
||||||
|
sendCtl(selectedId, 'stop').catch(() => {})
|
||||||
|
}, [selectedId])
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async (params: {
|
||||||
|
cwd: string; backend: string; model: string; agent: string
|
||||||
|
}) => {
|
||||||
|
await createSession(params)
|
||||||
|
setShowNew(false)
|
||||||
|
const s = await listSessions()
|
||||||
|
setSessions(s)
|
||||||
|
// Select the session most likely just created (last in list).
|
||||||
|
if (s.length > 0) setSelectedId(s[s.length - 1].id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="layout">
|
||||||
|
<Sidebar
|
||||||
|
sessions={sessions}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onNew={() => setShowNew(true)}
|
||||||
|
/>
|
||||||
|
<ChatPane
|
||||||
|
session={sessions.find(s => s.id === selectedId) ?? null}
|
||||||
|
cfg={cfg}
|
||||||
|
messages={messages}
|
||||||
|
onSend={handleSend}
|
||||||
|
onStop={handleStop}
|
||||||
|
/>
|
||||||
|
{showNew && (
|
||||||
|
<NewSession onClose={() => setShowNew(false)} onCreate={handleCreate} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import type { IdxEntry } from '../lib/api'
|
||||||
|
import type { ChatMessage } from '../lib/chat'
|
||||||
|
import { shortId, truncate } from '../lib/chat'
|
||||||
|
import { PromptBar } from './PromptBar'
|
||||||
|
|
||||||
|
marked.use({ gfm: true, breaks: false })
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
session: IdxEntry | null
|
||||||
|
cfg: Record<string, string> | null
|
||||||
|
messages: ChatMessage[]
|
||||||
|
onSend: (text: string) => void
|
||||||
|
onStop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateClass(state: string) {
|
||||||
|
if (state === 'running') return 'running'
|
||||||
|
if (state === 'idle' || state === 'waiting') return 'idle'
|
||||||
|
return 'stopped'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [atBottom, setAtBottom] = useState(true)
|
||||||
|
|
||||||
|
const isRunning = cfg?.state === 'running'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (atBottom && listRef.current) {
|
||||||
|
listRef.current.scrollTop = listRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [messages.length, isRunning, atBottom])
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const el = listRef.current
|
||||||
|
if (!el) return
|
||||||
|
setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || !cfg) {
|
||||||
|
return (
|
||||||
|
<main class="main">
|
||||||
|
<Welcome />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = shortId(session.id)
|
||||||
|
const state = cfg.state ?? 'stopped'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main class="main">
|
||||||
|
<header class="chat-header">
|
||||||
|
<div class="chat-path">
|
||||||
|
/s/<span class="path-hi">{sid}</span>/chat
|
||||||
|
</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<div class={`state-dot ${stateClass(state)}`} style="margin-right: 2px;" />
|
||||||
|
<span class="badge accent">{cfg.backend ?? session.backend}</span>
|
||||||
|
<span class="badge">{truncate(cfg.model ?? session.model, 24)}</span>
|
||||||
|
{cfg.agent && cfg.agent !== 'default' && (
|
||||||
|
<span class="badge warn">{cfg.agent}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="message-list" ref={listRef} onScroll={handleScroll}>
|
||||||
|
{messages.length === 0 && !isRunning && (
|
||||||
|
<div class="message-empty">/s/{sid}/chat</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} class={`message ${msg.role}`}>
|
||||||
|
<div class="msg-bubble">
|
||||||
|
{msg.role === 'user' ? (
|
||||||
|
<div class="user-text">{msg.content}</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
class="md"
|
||||||
|
dangerouslySetInnerHTML={{ __html: marked.parse(msg.content) as string }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isRunning && (
|
||||||
|
<div class="typing">
|
||||||
|
<div class="typing-dot" />
|
||||||
|
<div class="typing-dot" />
|
||||||
|
<div class="typing-dot" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PromptBar running={isRunning} onSend={onSend} onStop={onStop} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Welcome() {
|
||||||
|
const tree: [string, string][] = [
|
||||||
|
['/s/', 'sessions'],
|
||||||
|
['/m/', 'memory'],
|
||||||
|
['/t/', 'tools'],
|
||||||
|
['/a/', 'agents'],
|
||||||
|
['/sk/', 'skills'],
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="welcome">
|
||||||
|
<div class="welcome-wordmark">ollie</div>
|
||||||
|
<div class="welcome-mount">~/mnt/ollie</div>
|
||||||
|
<div class="welcome-tree">
|
||||||
|
<div class="welcome-tree-title">9p filesystem</div>
|
||||||
|
{tree.map(([path, desc]) => (
|
||||||
|
<div class="tree-row" key={path}>
|
||||||
|
<span class="tree-path">{path}</span>
|
||||||
|
<span class="tree-sep">·</span>
|
||||||
|
<span class="tree-desc">{desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="welcome-hint">select a session or create one</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { useState, useEffect } from 'preact/hooks'
|
||||||
|
import { getBackends } from '../lib/api'
|
||||||
|
|
||||||
|
interface CreateParams {
|
||||||
|
cwd: string
|
||||||
|
backend: string
|
||||||
|
model: string
|
||||||
|
agent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void
|
||||||
|
onCreate: (p: CreateParams) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENTS = ['default', 'coding', 'devops', 'greybeard', 'qa', 'reviewer', 'writer']
|
||||||
|
|
||||||
|
const DEFAULT_MODELS: Record<string, string> = {
|
||||||
|
anthropic: 'claude-opus-4-5',
|
||||||
|
openai: 'gpt-4o',
|
||||||
|
ollama: 'llama3',
|
||||||
|
codewhisperer: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||||
|
copilot: 'gpt-4o',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewSession({ onClose, onCreate }: Props) {
|
||||||
|
const [cwd, setCwd] = useState('~')
|
||||||
|
const [backend, setBackend] = useState('anthropic')
|
||||||
|
const [model, setModel] = useState(DEFAULT_MODELS['anthropic'])
|
||||||
|
const [agent, setAgent] = useState('default')
|
||||||
|
const [backends, setBackends] = useState<string[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBackends().then(bs => {
|
||||||
|
if (bs.length > 0) {
|
||||||
|
setBackends(bs)
|
||||||
|
setBackend(bs[0])
|
||||||
|
setModel(DEFAULT_MODELS[bs[0]] ?? '')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBackend = (b: string) => {
|
||||||
|
setBackend(b)
|
||||||
|
setModel(DEFAULT_MODELS[b] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!cwd.trim() || !model.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await onCreate({ cwd: cwd.trim(), backend, model: model.trim(), agent })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOverlay = (e: MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="overlay" onClick={handleOverlay}>
|
||||||
|
<div class="modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div>
|
||||||
|
<div class="modal-eyebrow">POST /s/new</div>
|
||||||
|
<div class="modal-title">New session</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style="display:flex;flex-direction:column;gap:18px;">
|
||||||
|
<div class="field">
|
||||||
|
<label>working directory</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cwd}
|
||||||
|
onInput={e => setCwd((e.target as HTMLInputElement).value)}
|
||||||
|
placeholder="~/src/myproject"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>backend</label>
|
||||||
|
<select
|
||||||
|
value={backend}
|
||||||
|
onChange={e => handleBackend((e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
{(backends.length > 0 ? backends : ['anthropic', 'openai', 'ollama']).map(b => (
|
||||||
|
<option key={b} value={b}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>agent</label>
|
||||||
|
<select
|
||||||
|
value={agent}
|
||||||
|
onChange={e => setAgent((e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
{AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>model</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={model}
|
||||||
|
onInput={e => setModel((e.target as HTMLInputElement).value)}
|
||||||
|
placeholder="model name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" onClick={onClose}>cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={loading || !cwd.trim() || !model.trim()}
|
||||||
|
>
|
||||||
|
{loading ? 'creating…' : 'create session'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useState, useRef } from 'preact/hooks'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
running: boolean
|
||||||
|
onSend: (text: string) => void
|
||||||
|
onStop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptBar({ running, onSend, onStop }: Props) {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const text = value.trim()
|
||||||
|
if (!text || running) return
|
||||||
|
onSend(text)
|
||||||
|
setValue('')
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.style.height = 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = (e: Event) => {
|
||||||
|
const el = e.target as HTMLTextAreaElement
|
||||||
|
setValue(el.value)
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="prompt-bar">
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
class="prompt-textarea"
|
||||||
|
placeholder={running ? 'ollie is thinking…' : 'message (enter ↵ · shift+enter for newline)'}
|
||||||
|
value={value}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={running}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
{running ? (
|
||||||
|
<button class="btn btn-danger" onClick={onStop} title="Stop">
|
||||||
|
◼ stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!value.trim()}
|
||||||
|
style="padding: 10px 16px; font-size: 16px;"
|
||||||
|
title="Send (Enter)"
|
||||||
|
>
|
||||||
|
↵
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { IdxEntry } from '../lib/api'
|
||||||
|
import { shortId, truncate } from '../lib/chat'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessions: IdxEntry[]
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onNew: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateClass(state: string) {
|
||||||
|
if (state === 'running') return 'running'
|
||||||
|
if (state === 'idle' || state === 'waiting') return 'idle'
|
||||||
|
return 'stopped'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ sessions, selectedId, onSelect, onNew }: Props) {
|
||||||
|
return (
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M8 1L15 8L8 15L1 8Z" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 4.5L11.5 8L8 11.5L4.5 8Z" fill="currentColor" opacity="0.45"/>
|
||||||
|
</svg>
|
||||||
|
ollie
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost" style="padding: 5px 10px; font-size: 12px; gap: 4px;" onClick={onNew}>
|
||||||
|
<span style="font-size: 14px; line-height: 1; margin-top: -1px;">+</span> new
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dir-label">/s/</div>
|
||||||
|
|
||||||
|
<div class="session-list">
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<div class="session-empty">no sessions</div>
|
||||||
|
)}
|
||||||
|
{sessions.map(s => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
class={`session-card${s.id === selectedId ? ' selected' : ''}`}
|
||||||
|
onClick={() => onSelect(s.id)}
|
||||||
|
>
|
||||||
|
<div class="session-card-row">
|
||||||
|
<div class={`state-dot ${stateClass(s.state)}`} />
|
||||||
|
<div class="session-id-label">{shortId(s.id)}</div>
|
||||||
|
<div class="session-state-label">{s.state}</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-cwd" title={s.cwd}>{s.cwd}</div>
|
||||||
|
<div class="session-badges">
|
||||||
|
<span class="badge accent">{s.backend}</span>
|
||||||
|
<span class="badge">{truncate(s.model, 22)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
export interface IdxEntry {
|
||||||
|
id: string
|
||||||
|
state: string
|
||||||
|
cwd: string
|
||||||
|
backend: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessions(): Promise<IdxEntry[]> {
|
||||||
|
const r = await fetch('/s/idx')
|
||||||
|
if (!r.ok) return []
|
||||||
|
const data = await r.json()
|
||||||
|
return Array.isArray(data) ? (data as IdxEntry[]) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionCfg(id: string): Promise<Record<string, string>> {
|
||||||
|
const r = await fetch(`/s/${id}/cfg`)
|
||||||
|
if (!r.ok) throw new Error(`cfg ${r.status}`)
|
||||||
|
const data = await r.json()
|
||||||
|
return (data && typeof data === 'object' && !Array.isArray(data))
|
||||||
|
? (data as Record<string, string>)
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gateway strips empty lines, returning string[] or {"chat":"single line"}.
|
||||||
|
export async function getChatLines(id: string): Promise<string[]> {
|
||||||
|
const r = await fetch(`/s/${id}/chat`)
|
||||||
|
if (!r.ok) return []
|
||||||
|
const data = await r.json()
|
||||||
|
if (Array.isArray(data)) return data as string[]
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
const v = Object.values(data as Record<string, unknown>)[0]
|
||||||
|
return typeof v === 'string' && v ? [v] : []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPrompt(id: string, prompt: string): Promise<void> {
|
||||||
|
await fetch(`/s/${id}/prompt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendCtl(id: string, ctl: string): Promise<void> {
|
||||||
|
await fetch(`/s/${id}/ctl`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ctl }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(params: {
|
||||||
|
cwd: string
|
||||||
|
backend: string
|
||||||
|
model: string
|
||||||
|
agent: string
|
||||||
|
}): Promise<void> {
|
||||||
|
await fetch('/s/new', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBackends(): Promise<string[]> {
|
||||||
|
const r = await fetch('/backends')
|
||||||
|
if (!r.ok) return []
|
||||||
|
const data = await r.json()
|
||||||
|
return Array.isArray(data) ? (data as string[]) : []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the ollie chat log format (lines returned by the gateway).
|
||||||
|
// Lines starting with "user: " or "assistant: " begin a new message;
|
||||||
|
// subsequent lines are appended to the current message content.
|
||||||
|
export function parseChat(lines: string[]): ChatMessage[] {
|
||||||
|
const messages: ChatMessage[] = []
|
||||||
|
let cur: ChatMessage | null = null
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('user: ')) {
|
||||||
|
if (cur) messages.push(cur)
|
||||||
|
cur = { role: 'user', content: line.slice(6) }
|
||||||
|
} else if (line.startsWith('assistant: ')) {
|
||||||
|
if (cur) messages.push(cur)
|
||||||
|
cur = { role: 'assistant', content: line.slice(11) }
|
||||||
|
} else if (cur) {
|
||||||
|
cur.content += '\n' + line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) messages.push(cur)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortId(id: string): string {
|
||||||
|
return id.length > 12 ? id.slice(0, 12) : id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(s: string, max: number): string {
|
||||||
|
return s.length > max ? s.slice(0, max - 1) + '…' : s
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { render } from 'preact'
|
||||||
|
import { App } from './app'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('app')!)
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
/* ---- Design system ---- */
|
||||||
|
:root {
|
||||||
|
--bg: #0d0d13;
|
||||||
|
--bg-elevated: #14141c;
|
||||||
|
--bg-soft: #1c1c28;
|
||||||
|
--bg-card: #191924;
|
||||||
|
--border: #272736;
|
||||||
|
--border-focus: #383850;
|
||||||
|
|
||||||
|
--text: #dddde8;
|
||||||
|
--text-dim: #7c7c9a;
|
||||||
|
--text-faint: #3e3e54;
|
||||||
|
|
||||||
|
--accent: #22d3ee;
|
||||||
|
--accent-soft: rgba(34, 211, 238, 0.07);
|
||||||
|
--accent-glow: rgba(34, 211, 238, 0.18);
|
||||||
|
--accent-fg: #061419;
|
||||||
|
|
||||||
|
--running: #34d399;
|
||||||
|
--running-glow: rgba(52, 211, 153, 0.35);
|
||||||
|
--idle: #64748b;
|
||||||
|
--stopped: #334155;
|
||||||
|
--danger: #fb7185;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
|
||||||
|
--mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', ui-monospace, monospace;
|
||||||
|
--sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
|
||||||
|
--r-sm: 4px;
|
||||||
|
--r-md: 8px;
|
||||||
|
--r-lg: 12px;
|
||||||
|
--r-xl: 18px;
|
||||||
|
|
||||||
|
--sidebar: 272px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Reset ---- */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body, #app { height: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
button { font-family: inherit; cursor: pointer; }
|
||||||
|
input, select, textarea { font-family: inherit; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-focus); border-radius: 99px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #50506e; }
|
||||||
|
|
||||||
|
/* ---- Layout ---- */
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar) 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sidebar ---- */
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 52px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo svg { opacity: 0.9; }
|
||||||
|
|
||||||
|
.dir-label {
|
||||||
|
padding: 14px 14px 6px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-empty {
|
||||||
|
padding: 16px 6px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Session Card ---- */
|
||||||
|
.session-card {
|
||||||
|
padding: 10px 10px 10px 12px;
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card.selected {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: rgba(34, 211, 238, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.state-dot.running {
|
||||||
|
background: var(--running);
|
||||||
|
box-shadow: 0 0 7px var(--running-glow);
|
||||||
|
animation: pulse-running 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.state-dot.idle { background: var(--idle); }
|
||||||
|
.state-dot.stopped { background: var(--stopped); }
|
||||||
|
|
||||||
|
@keyframes pulse-running {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 7px var(--running-glow); }
|
||||||
|
50% { opacity: 0.6; box-shadow: 0 0 3px var(--running-glow); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-id-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-state-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-cwd {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding-left: 15px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding-left: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Badges ---- */
|
||||||
|
.badge {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.badge.accent {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(34, 211, 238, 0.22);
|
||||||
|
background: rgba(34, 211, 238, 0.05);
|
||||||
|
}
|
||||||
|
.badge.warn {
|
||||||
|
color: var(--warning);
|
||||||
|
border-color: rgba(251, 191, 36, 0.22);
|
||||||
|
background: rgba(251, 191, 36, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Buttons ---- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.13s;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { background: #35ddf5; }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: rgba(251, 113, 133, 0.35);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) { background: rgba(251, 113, 133, 0.07); }
|
||||||
|
|
||||||
|
/* ---- Main area ---- */
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Welcome ---- */
|
||||||
|
.welcome {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-wordmark {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-mount {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-tree {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-xl);
|
||||||
|
padding: 22px 30px;
|
||||||
|
min-width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-tree-title {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-row:first-of-type { border-top-color: var(--border); margin-top: 0; }
|
||||||
|
|
||||||
|
.tree-path {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-sep {
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-desc {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-hint {
|
||||||
|
margin-top: 28px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Chat header ---- */
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 52px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 16px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-path {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-path .path-hi { color: var(--accent); }
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Messages ---- */
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 28px 24px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-empty {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: center;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.message.user { justify-content: flex-end; }
|
||||||
|
.message.assistant { justify-content: flex-start; }
|
||||||
|
|
||||||
|
.msg-bubble {
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .msg-bubble {
|
||||||
|
max-width: 72%;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 1px solid rgba(34, 211, 238, 0.16);
|
||||||
|
border-bottom-right-radius: var(--r-sm);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant .msg-bubble {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-left-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Markdown content ---- */
|
||||||
|
.md p { margin: 0 0 0.7em; }
|
||||||
|
.md p:last-child { margin-bottom: 0; }
|
||||||
|
.md h1,.md h2,.md h3,
|
||||||
|
.md h4,.md h5,.md h6 { margin: 1.1em 0 0.45em; line-height: 1.3; color: var(--text); }
|
||||||
|
.md h1 { font-size: 1.25em; }
|
||||||
|
.md h2 { font-size: 1.1em; }
|
||||||
|
.md h3 { font-size: 1em; font-weight: 600; }
|
||||||
|
.md ul,.md ol { margin: 0.4em 0; padding-left: 1.4em; }
|
||||||
|
.md li { margin: 0.2em 0; }
|
||||||
|
.md li > p { margin: 0; }
|
||||||
|
.md strong { color: var(--text); }
|
||||||
|
.md em { color: var(--text-dim); }
|
||||||
|
.md a { color: var(--accent); text-decoration: none; }
|
||||||
|
.md a:hover { text-decoration: underline; }
|
||||||
|
.md hr { border: none; border-top: 1px solid var(--border); margin: 1em 0; }
|
||||||
|
|
||||||
|
.md blockquote {
|
||||||
|
border-left: 3px solid var(--border-focus);
|
||||||
|
padding: 0.1em 0 0.1em 1em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md pre {
|
||||||
|
background: #07070f;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.7em 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md :not(pre) > code {
|
||||||
|
background: rgba(34, 211, 238, 0.08);
|
||||||
|
border: 1px solid rgba(34, 211, 238, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.7em 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.md th, .md td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.md th { background: var(--bg-soft); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- Typing indicator ---- */
|
||||||
|
.typing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
border-bottom-left-radius: var(--r-sm);
|
||||||
|
width: fit-content;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--running);
|
||||||
|
animation: tdot 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.typing-dot:nth-child(2) { animation-delay: 0.18s; }
|
||||||
|
.typing-dot:nth-child(3) { animation-delay: 0.36s; }
|
||||||
|
|
||||||
|
@keyframes tdot {
|
||||||
|
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||||
|
40% { transform: translateY(-6px); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Prompt bar ---- */
|
||||||
|
.prompt-bar {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 160px;
|
||||||
|
transition: border-color 0.13s;
|
||||||
|
}
|
||||||
|
.prompt-textarea:focus { border-color: rgba(34, 211, 238, 0.35); }
|
||||||
|
.prompt-textarea::placeholder { color: var(--text-faint); }
|
||||||
|
.prompt-textarea:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ---- Modal ---- */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-focus);
|
||||||
|
border-radius: var(--r-xl);
|
||||||
|
padding: 28px;
|
||||||
|
width: min(460px, 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-eyebrow {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 9px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.13s;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus { border-color: rgba(34, 211, 238, 0.35); }
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%233e3e54' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
padding-right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import preact from '@preact/preset-vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/s': 'http://localhost:8080',
|
||||||
|
'/backends': 'http://localhost:8080',
|
||||||
|
'/openapi.json': 'http://localhost:8080',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue