import { isValidElement, useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeHighlight from 'rehype-highlight'
import rehypeKatex from 'rehype-katex'
import {
ArrowDown,
ArrowLeft,
BrainCircuit,
Bot,
Check,
ChevronDown,
ChevronRight,
Download,
Copy,
Eye,
EyeOff,
File,
FilePenLine,
FilePlus2,
FileText,
Folder,
FolderOpen,
GitBranch,
Info,
LogOut,
MessageSquare,
MoreHorizontal,
Paperclip,
Pencil,
Plus,
Search,
Save,
Send,
Settings2,
Square,
Terminal,
RefreshCw,
Trash2,
UserRound,
X,
} from 'lucide-react'
import type {
CurrentModel,
DirectoryResult,
DirectoryChoices,
FileResult,
ModelInfo,
Project,
SessionMeta,
SettingsInfo,
PiConfigFile,
StoredMessage,
TimelineItem,
Upload,
} from './types'
import { THINKING_LEVELS } from './types'
import './App.css'
let fallbackId = 0
const uid = () => {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
fallbackId += 1
return `${Date.now()}-${fallbackId}-${Math.random().toString(36).slice(2)}`
}
function sessionIdFromPath(pathname = window.location.pathname) {
const match = pathname.match(/^\/sessions\/([^/]+)\/?$/)
return match ? decodeURIComponent(match[1]) : null
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init)
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
const text = await response.text()
if (!text) return undefined as T
return JSON.parse(text) as T
}
function relativeTime(timestamp: number) {
const delta = Date.now() - timestamp
if (delta < 60_000) return '刚刚'
if (delta < 3_600_000) return `${Math.floor(delta / 60_000)} 分钟前`
if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)} 小时前`
return new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(timestamp)
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
}
function stringField(value: unknown, key: string) {
const field = asRecord(value)?.[key]
return typeof field === 'string' ? field : ''
}
function resultText(value: unknown): string {
if (typeof value === 'string') return value
const record = asRecord(value)
if (!record) return value == null ? '' : String(value)
if (Array.isArray(record.content)) {
const text = record.content.map((part) => stringField(part, 'text')).filter(Boolean).join('\n')
if (text) return text
}
const streams = [record.stdout, record.stderr].filter((part): part is string => typeof part === 'string' && Boolean(part))
if (streams.length) return streams.join('\n')
return JSON.stringify(value, null, 2)
}
async function copyText(text: string) {
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text)
const area = document.createElement('textarea')
area.value = text
area.style.position = 'fixed'
area.style.opacity = '0'
document.body.appendChild(area)
area.select()
document.execCommand('copy')
area.remove()
}
function MarkdownCodeBlock({ children }: { children?: ReactNode }) {
const [copied, setCopied] = useState(false)
const codeElement = isValidElement<{ children?: ReactNode; className?: string }>(children) ? children : null
const nodeText = (node: ReactNode): string => {
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (Array.isArray(node)) return node.map(nodeText).join('')
if (isValidElement<{ children?: ReactNode }>(node)) return nodeText(node.props.children)
return ''
}
const code = nodeText(codeElement?.props.children).replace(/\n$/, '')
const language = codeElement?.props.className?.match(/language-([^\s]+)/)?.[1]
const copy = () => {
void copyText(code).then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
})
}
return <div className="markdown-code-block">
<div className="code-block-toolbar"><span>{language ?? '代码'}</span><button type="button" onClick={copy} aria-label="复制代码" title={copied ? '已复制' : '复制代码'}>{copied ? <Check size={13} /> : <Copy size={13} />}<span>{copied ? '已复制' : '复制'}</span></button></div>
<pre>{children}</pre>
</div>
}
function AssistantMessage({ text, ts }: { text: string; ts?: number }) {
const [copied, setCopied] = useState(false)
const timestamp = ts ?? Date.now()
const date = new Date(timestamp)
const time = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(date)
return <div className="assistant-message-body">
<div className="message-content"><ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeHighlight, rehypeKatex]} components={{ pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock> }}>{text}</ReactMarkdown></div>
<div className="message-footer"><time dateTime={date.toISOString()} title={date.toLocaleString('zh-CN')}>{time}</time><button className="message-copy" onClick={() => { void copyText(text).then(() => { setCopied(true); window.setTimeout(() => setCopied(false), 1600) }) }} aria-label="复制模型输出" title={copied ? '已复制' : '复制'}>{copied ? <Check size={13} /> : <Copy size={13} />}</button></div>
</div>
}
function ToolCallCard({ item }: { item: Extract<TimelineItem, { kind: 'tool' }> }) {
const name = item.name.toLowerCase()
const input = asRecord(item.input)
const path = stringField(input, 'path')
const output = resultText(item.result)
const definitions = {
read: { label: '读取', icon: Eye },
write: { label: '写入', icon: FilePlus2 },
edit: { label: '编辑', icon: FilePenLine },
bash: { label: '执行', icon: Terminal },
}
const definition = definitions[name as keyof typeof definitions]
const Icon = definition?.icon ?? FileText
const edits = Array.isArray(input?.edits) ? input.edits : []
const summary = path || (name === 'bash' ? stringField(input, 'command').split('\n')[0] : '') || item.name || '工具调用'
return <details className={`tool-call tool-${name} ${item.isError ? 'error' : ''}`}>
<summary><span className="tool-icon"><Icon size={15} /></span><span className="tool-summary-copy"><strong>{definition?.label ?? item.name ?? '工具调用'}</strong><span title={summary}>{summary}</span></span><span className="tool-state">{item.result === undefined ? '运行中' : item.isError ? '失败' : '完成'}</span><ChevronDown size={14} /></summary>
<div className="tool-body">
{name === 'read' && <>{(input?.offset != null || input?.limit != null) && <div className="tool-meta">{input.offset != null ? `从第 ${String(input.offset)} 行` : '从开头'}{input.limit != null ? ` · 最多 ${String(input.limit)} 行` : ''}</div>}{output && <pre className="tool-output">{output}</pre>}</>}
{name === 'write' && <>{stringField(input, 'content') && <pre className="tool-code">{stringField(input, 'content')}</pre>}{output && <div className="tool-result">{output}</div>}</>}
{name === 'edit' && <>{edits.map((edit, index) => <div className="tool-diff" key={index}><pre className="diff-remove">{stringField(edit, 'oldText').split('\n').map((line) => `- ${line}`).join('\n')}</pre><pre className="diff-add">{stringField(edit, 'newText').split('\n').map((line) => `+ ${line}`).join('\n')}</pre></div>)}{output && <div className="tool-result">{output}</div>}</>}
{name === 'bash' && <><pre className="tool-command"><span>$ </span>{stringField(input, 'command')}</pre>{output && <pre className="tool-output">{output}</pre>}</>}
{!definition && <>{item.input !== undefined && <pre className="tool-code">{JSON.stringify(item.input, null, 2)}</pre>}{output && <pre className="tool-output">{output}</pre>}</>}
</div>
</details>
}
function SessionList({
username,
projects,
sessions,
activeId,
loading,
onSelect,
onCreateProject,
onCreateSession,
onSettings,
settingsActive,
connected,
onRename,
onDelete,
}: {
username: string
projects: Project[]
sessions: SessionMeta[]
activeId: string | null
loading: boolean
onSelect: (id: string) => void
onCreateProject: () => void
onCreateSession: (projectId: string) => void
onSettings: () => void
settingsActive: boolean
connected: boolean
onRename: (session: SessionMeta) => void
onDelete: (session: SessionMeta) => void
}) {
const [query, setQuery] = useState('')
const [menuId, setMenuId] = useState<string | null>(null)
const filtered = sessions.filter((session) => session.title.toLowerCase().includes(query.trim().toLowerCase()))
return (
<aside className="sidebar" aria-label="会话导航">
<div className="brand-row">
<div className="brand-mark"><BrainCircuit size={19} /></div>
<div className="brand-copy"><strong>xagent-pi</strong><span title={username}>{username ? `@${username}` : '正在连接账户...'}</span></div>
<button className="icon-button new-mobile" onClick={onCreateProject} aria-label="新项目" title="新项目"><Plus /></button>
</div>
<button className="new-chat" onClick={onCreateProject}><FolderOpen size={17} />新项目</button>
<label className="search-box">
<Search size={16} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索会话" aria-label="搜索会话" />
{query && <button onClick={() => setQuery('')} aria-label="清除搜索"><X size={14} /></button>}
</label>
<div className="session-scroll">
{loading ? (
<div className="session-skeletons" aria-label="正在加载会话">
{[0, 1, 2, 3].map((item) => <div className="session-skeleton" key={item} />)}
</div>
) : projects.length === 0 || (filtered.length === 0 && query) ? (
<div className="empty-list"><MessageSquare size={24} /><span>{query ? '没有匹配的会话' : '还没有对话'}</span></div>
) : (
projects.map((project) => {
const items = filtered.filter((session) => session.project_id === project.id)
if (query && items.length === 0) return null
return <section className="session-group project-group" key={project.id}>
<div className="project-heading" title={project.path}><span>{project.is_git ? <GitBranch size={14} /> : <Folder size={14} />}{project.name}</span><button onClick={() => onCreateSession(project.id)} aria-label={`在 ${project.name} 中新建对话`} title="新建对话"><Plus size={15} /></button></div>
{items.length === 0 && <div className="project-empty">暂无对话</div>}
{items.map((session) => (
<div className={`session-row ${activeId === session.id ? 'active' : ''}`} key={session.id}>
<button className="session-main" onClick={() => onSelect(session.id)}>
<span className="session-title">{session.title || '未命名对话'}</span>
<span className="session-time">{session.worktree ? `${session.worktree.branch} · ` : ''}{relativeTime(session.updated_at)}</span>
</button>
<button className="row-menu-button" onClick={() => setMenuId(menuId === session.id ? null : session.id)} aria-label="会话操作" title="会话操作"><MoreHorizontal size={17} /></button>
{menuId === session.id && (
<div className="row-menu">
<button onClick={() => { setMenuId(null); onRename(session) }}><Pencil size={15} />重命名</button>
<button className="danger" onClick={() => { setMenuId(null); onDelete(session) }}><Trash2 size={15} />删除</button>
</div>
)}
</div>
))}
</section>
})
)}
</div>
<button className={`sidebar-settings ${settingsActive ? 'active' : ''}`} onClick={onSettings}><Settings2 size={16} /><span>设置</span><span className={`brain-status ${connected ? '' : 'offline'}`}><span className="status-dot" />brain {connected ? '已连接' : '未连接'}</span></button>
</aside>
)
}
function Composer({
projectId,
disabled,
thinking,
models,
currentModel,
thinkingLevel,
onModel,
onThinking,
onSend,
onAbort,
}: {
projectId: string
disabled: boolean
thinking: boolean
models: ModelInfo[]
currentModel: CurrentModel | null
thinkingLevel: string
onModel: (model: CurrentModel) => void
onThinking: (level: string) => void
onSend: (text: string, uploads: Upload[]) => Promise<void>
onAbort: () => void
}) {
const [text, setText] = useState('')
const [uploads, setUploads] = useState<Upload[]>([])
const [uploading, setUploading] = useState(false)
const [sending, setSending] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const area = textareaRef.current
if (!area) return
area.style.height = '0px'
area.style.height = `${Math.min(area.scrollHeight, 180)}px`
}, [text])
const submit = async () => {
const clean = text.trim()
if ((!clean && uploads.length === 0) || sending || disabled) return
setSending(true)
try {
await onSend(clean, uploads)
setText('')
setUploads([])
} finally {
setSending(false)
}
}
const upload = async (file?: File) => {
if (!file) return
setUploading(true)
try {
const form = new FormData()
form.append('file', file)
const result = await request<Upload>(`/api/upload?project_id=${encodeURIComponent(projectId)}`, { method: 'POST', body: form })
setUploads((current) => [...current, result])
} finally {
setUploading(false)
}
}
const modelValue = currentModel ? `${currentModel.provider}|${currentModel.model_id}` : ''
const currentName = models.find((model) => `${model.provider}|${model.model_id}` === modelValue)?.name ?? currentModel?.model_id ?? '模型'
return (
<div className="composer-shell">
{settingsOpen && (
<div className="settings-popover">
<div className="popover-heading"><span>运行设置</span><button onClick={() => setSettingsOpen(false)} aria-label="关闭设置"><X size={16} /></button></div>
<label><span>模型</span><select value={modelValue} onChange={(event) => { const [provider, model_id] = event.target.value.split('|'); onModel({ provider, model_id }) }}><option value="" disabled>选择模型</option>{models.map((model) => <option key={`${model.provider}/${model.model_id}`} value={`${model.provider}|${model.model_id}`}>{model.name}</option>)}</select></label>
<label><span>思考强度</span><select value={thinkingLevel} onChange={(event) => onThinking(event.target.value)}>{THINKING_LEVELS.map((level) => <option value={level} key={level}>{level}</option>)}</select></label>
</div>
)}
{uploads.length > 0 && <div className="attachments">{uploads.map((item, index) => <div className="attachment" key={`${item.path}-${index}`}><FileText size={15} /><span>{item.name}</span><button onClick={() => setUploads((current) => current.filter((_, itemIndex) => itemIndex !== index))} aria-label={`移除 ${item.name}`}><X size={14} /></button></div>)}</div>}
<div className="composer">
<textarea
ref={textareaRef}
value={text}
disabled={disabled}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
void submit()
}
}}
placeholder="输入消息"
rows={1}
/>
<div className="composer-toolbar">
<div className="toolbar-group">
<label className={`icon-button ${uploading ? 'busy' : ''}`} title="添加附件" aria-label="添加附件"><Paperclip size={18} /><input type="file" hidden onChange={(event) => { void upload(event.target.files?.[0]); event.target.value = '' }} /></label>
<button className="model-button" onClick={() => setSettingsOpen((open) => !open)} title="模型与思考设置"><Settings2 size={16} /><span>{currentName}</span><ChevronDown size={13} /></button>
</div>
{thinking ? <button className="stop-button" onClick={onAbort} title="停止生成"><Square size={14} fill="currentColor" /><span>停止</span></button> : <button className="send-button" disabled={disabled || sending || (!text.trim() && uploads.length === 0)} onClick={() => void submit()} title="发送"><Send size={18} /></button>}
</div>
</div>
<div className="composer-note">Enter 发送,Shift + Enter 换行</div>
</div>
)
}
function formatBytes(size: number) {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
function FileBrowser({ open, projectId, sessionId, onClose }: { open: boolean; projectId: string; sessionId: string; onClose: () => void }) {
const [directory, setDirectory] = useState<DirectoryResult>({ path: '', absolute_path: '', entries: [] })
const [preview, setPreview] = useState<FileResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadDirectory = useCallback(async (path: string) => {
setLoading(true)
setError(null)
setPreview(null)
try {
setDirectory(await request<DirectoryResult>(`/api/files?path=${encodeURIComponent(path)}&project_id=${encodeURIComponent(projectId)}&session_id=${encodeURIComponent(sessionId)}`))
} catch {
setError('无法读取此目录')
} finally {
setLoading(false)
}
}, [projectId, sessionId])
const loadFile = async (path: string) => {
setLoading(true)
setError(null)
try {
setPreview(await request<FileResult>(`/api/file?path=${encodeURIComponent(path)}&project_id=${encodeURIComponent(projectId)}&session_id=${encodeURIComponent(sessionId)}`))
} catch {
setError('无法预览此文件,文件可能过大或不是文本格式')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (open) void loadDirectory('')
}, [open, loadDirectory])
if (!open) return null
const parts = directory.path.split('/').filter(Boolean)
const currentPath = preview
? `${directory.absolute_path.replace(/\/$/, '')}/${preview.name}`
: directory.absolute_path
const download = () => {
if (!preview) return
const url = URL.createObjectURL(new Blob([preview.content], { type: 'text/plain;charset=utf-8' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = preview.name
anchor.click()
URL.revokeObjectURL(url)
}
return <>
<button className="file-browser-backdrop" onClick={onClose} aria-label="关闭文件浏览器" />
<aside className="file-browser" aria-label="文件浏览器">
<header className="file-browser-header">
<div className="file-browser-title"><FolderOpen size={18} /><strong>文件</strong><code title={currentPath}>{currentPath}</code></div>
<div>
<button className="icon-button" onClick={() => void loadDirectory(directory.path)} title="刷新"><RefreshCw size={17} /></button>
<button className="icon-button" onClick={onClose} title="关闭"><X size={18} /></button>
</div>
</header>
<nav className="breadcrumbs" aria-label="当前路径">
<button onClick={() => void loadDirectory('')}>工作区</button>
{parts.map((part, index) => <span key={`${part}-${index}`}><ChevronRight size={13} /><button onClick={() => void loadDirectory(parts.slice(0, index + 1).join('/'))}>{part}</button></span>)}
</nav>
{error && <div className="file-error">{error}</div>}
{preview ? <div className="file-preview">
<div className="file-preview-heading"><button className="back-to-files" onClick={() => setPreview(null)}><ArrowLeft size={16} />返回</button><span>{preview.name}<small>{formatBytes(preview.size)}</small></span><button className="icon-button" onClick={download} title="下载文件"><Download size={17} /></button></div>
<pre>{preview.content}</pre>
</div> : <div className="file-list">
{directory.path && <button className="file-row" onClick={() => void loadDirectory(parts.slice(0, -1).join('/'))}><Folder size={18} /><span>..</span></button>}
{directory.entries.map((entry) => <button className="file-row" key={entry.path} onClick={() => entry.is_dir ? void loadDirectory(entry.path) : void loadFile(entry.path)}>{entry.is_dir ? (entry.is_git ? <GitBranch size={18} /> : <Folder size={18} />) : <File size={18} />}<span>{entry.name}</span>{entry.is_dir ? <ChevronRight size={15} /> : <small>{formatBytes(entry.size)}</small>}</button>)}
{!loading && directory.entries.length === 0 && <div className="files-empty">此目录为空</div>}
</div>}
{loading && <div className="files-loading"><span /><span /><span /></div>}
</aside>
</>
}
function Conversation({
session,
items,
loading,
thinking,
connected,
models,
currentModel,
thinkingLevel,
onBack,
onRename,
onModel,
onThinking,
onSend,
onAbort,
}: {
session: SessionMeta | null
items: TimelineItem[]
loading: boolean
thinking: boolean
connected: boolean
models: ModelInfo[]
currentModel: CurrentModel | null
thinkingLevel: string
onBack: () => void
onRename: () => void
onModel: (model: CurrentModel) => void
onThinking: (level: string) => void
onSend: (text: string, uploads: Upload[]) => Promise<void>
onAbort: () => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [showJump, setShowJump] = useState(false)
const [filesOpen, setFilesOpen] = useState(false)
const scrollToBottom = useCallback((smooth = true) => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
}, [])
useEffect(() => {
scrollToBottom(false)
}, [session?.id, scrollToBottom])
useEffect(() => {
if (!loading) requestAnimationFrame(() => scrollToBottom(false))
}, [loading, scrollToBottom])
useEffect(() => {
const node = scrollRef.current
if (!node || node.scrollHeight - node.scrollTop - node.clientHeight > 180) return
scrollToBottom()
}, [items, thinking, scrollToBottom])
if (!session) return <main className="conversation empty-conversation" aria-label="未选择会话" />
return (
<main className="conversation">
<header className="conversation-header">
<button className="icon-button mobile-back" onClick={onBack} aria-label="返回会话列表"><ArrowLeft /></button>
<div className="conversation-title"><button onClick={onRename} title="重命名会话">{session.title || '未命名对话'}<Pencil size={13} /></button><span><i className={connected ? 'online' : ''} />{connected ? (thinking ? '正在处理' : '在线') : '正在重新连接'}{session.worktree && <><GitBranch size={12} />{session.worktree.branch}</>}</span></div>
<button className="icon-button" onClick={() => setFilesOpen(true)} title="浏览文件" aria-label="浏览文件"><FolderOpen size={19} /></button>
<button className="icon-button header-menu" onClick={onRename} title="重命名会话" aria-label="重命名会话"><MoreHorizontal /></button>
</header>
{!connected && <div className="connection-banner">实时连接中断,正在尝试恢复...</div>}
<div className="timeline" ref={scrollRef} onScroll={(event) => { const node = event.currentTarget; setShowJump(node.scrollHeight - node.scrollTop - node.clientHeight > 260) }}>
<div className="thread">
{loading ? <div className="message-loading"><span /><span /><span /></div> : items.length === 0 ? <div className="thread-empty"><div className="empty-icon"><BrainCircuit size={25} /></div><strong>开始新的对话</strong><span>输入任务、问题,或附加文件。</span></div> : items.map((item) => {
if (item.kind === 'message') return <article className={`message ${item.role === 'user' ? 'user' : 'assistant'}`} key={item.id}>{item.role === 'assistant' ? <AssistantMessage text={item.text} ts={item.ts} /> : <div className="message-content"><p>{item.text}</p></div>}</article>
if (item.kind === 'reasoning') return <details className="reasoning" key={item.id}><summary><BrainCircuit size={15} />思考过程<ChevronDown size={14} /></summary><div>{item.text}</div></details>
return <ToolCallCard item={item} key={item.id} />
})}
{thinking && <div className="thinking-row"><span /><span /><span /><em>正在思考</em></div>}
</div>
{showJump && <button className="jump-bottom" onClick={() => scrollToBottom()} aria-label="滚动到底部" title="滚动到底部"><ArrowDown size={18} /></button>}
</div>
<Composer projectId={session.project_id} disabled={!connected} thinking={thinking} models={models} currentModel={currentModel} thinkingLevel={thinkingLevel} onModel={onModel} onThinking={onThinking} onSend={onSend} onAbort={onAbort} />
<FileBrowser open={filesOpen} projectId={session.project_id} sessionId={session.id} onClose={() => setFilesOpen(false)} />
</main>
)
}
function TextDialog({ title, initialValue, confirmLabel, destructive, onClose, onConfirm }: { title: string; initialValue: string; confirmLabel: string; destructive?: boolean; onClose: () => void; onConfirm: (value: string) => void }) {
const [value, setValue] = useState(initialValue)
return <div className="dialog-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><form className="dialog" onSubmit={(event) => { event.preventDefault(); onConfirm(value.trim()) }}><h2>{title}</h2>{destructive ? <p>此操作会永久删除本地会话及消息记录,无法撤销。</p> : <input autoFocus value={value} onChange={(event) => setValue(event.target.value)} maxLength={80} /> }<div className="dialog-actions"><button type="button" onClick={onClose}>取消</button><button type="submit" className={destructive ? 'danger' : 'primary'} disabled={!destructive && !value.trim()}>{confirmLabel}</button></div></form></div>
}
function DirectoryPicker({ onClose, onSelect }: { onClose: () => void; onSelect: (path: string) => Promise<void> }) {
const [directory, setDirectory] = useState<DirectoryChoices | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async (path = '') => {
setLoading(true)
setError('')
try {
setDirectory(await request<DirectoryChoices>(`/api/directories?path=${encodeURIComponent(path)}`))
} catch {
setError('无法读取此目录')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { void load() }, [load])
return <div className="dialog-backdrop"><div className="dialog directory-dialog">
<div className="dialog-heading"><h2>选择项目目录</h2><button className="icon-button" onClick={onClose} aria-label="关闭"><X size={17} /></button></div>
<code className="directory-current" title={directory?.path}>{directory?.path || '正在读取...'}</code>
{error && <div className="directory-error">{error}</div>}
<div className="directory-list">
{directory?.parent && <button onClick={() => void load(directory.parent!)}><Folder size={17} /><span>..</span></button>}
{directory?.directories.map((item) => <button key={item.path} onClick={() => void load(item.path)}>{item.is_git ? <GitBranch size={17} /> : <Folder size={17} />}<span>{item.name}</span><ChevronRight size={15} /></button>)}
{!loading && directory?.directories.length === 0 && <div>没有子目录</div>}
</div>
<div className="dialog-actions"><button onClick={onClose}>取消</button><button className="primary" disabled={!directory || loading} onClick={() => directory && void onSelect(directory.path)}>选择此目录</button></div>
</div></div>
}
function NewSessionDialog({ project, onClose, onCreate }: { project: Project; onClose: () => void; onCreate: (mode: 'simple' | 'worktree', name?: string) => Promise<void> }) {
const [mode, setMode] = useState<'simple' | 'worktree'>('simple')
const [name, setName] = useState('')
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const submit = async () => {
setCreating(true)
setError('')
try {
await onCreate(mode, name.trim() || undefined)
} catch {
setError('无法创建会话,请检查 worktree 名称或 Git 仓库状态')
setCreating(false)
}
}
return <div className="dialog-backdrop"><div className="dialog new-session-dialog">
<div className="dialog-heading"><h2>在 {project.name} 中新建对话</h2><button className="icon-button" onClick={onClose} aria-label="关闭"><X size={17} /></button></div>
<div className="session-mode" role="group" aria-label="会话类型">
<button className={mode === 'simple' ? 'active' : ''} onClick={() => setMode('simple')}><MessageSquare size={16} />普通</button>
<button className={mode === 'worktree' ? 'active' : ''} disabled={!project.is_git} onClick={() => setMode('worktree')}><GitBranch size={16} />Worktree</button>
</div>
{mode === 'worktree' && <label className="worktree-name"><span>Worktree 名称</span><input autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="feature-name" maxLength={48} /></label>}
{!project.is_git && <p className="dialog-note">当前项目不是 Git 仓库,只能创建普通对话。</p>}
{error && <div className="directory-error">{error}</div>}
<div className="dialog-actions"><button onClick={onClose}>取消</button><button className="primary" disabled={creating} onClick={() => void submit()}>{creating ? '创建中...' : '创建'}</button></div>
</div></div>
}
function PiAgentSettings() {
const [view, setView] = useState<'defaults' | 'models' | 'source'>('defaults')
const [sourceName, setSourceName] = useState<'settings' | 'models'>('settings')
const [files, setFiles] = useState<Record<string, PiConfigFile>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState('')
const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({})
const load = useCallback(async () => {
setLoading(true)
setStatus('')
try {
const [settings, models] = await Promise.all([
request<PiConfigFile>('/api/pi/config/settings'),
request<PiConfigFile>('/api/pi/config/models'),
])
setFiles({ settings, models })
} catch {
setStatus('无法读取 Pi 配置')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { void load() }, [load])
const parse = (name: 'settings' | 'models') => {
try { return JSON.parse(files[name]?.content ?? '{}') as Record<string, unknown> } catch { return {} }
}
const settings = parse('settings')
const modelsConfig = parse('models')
const providers = asRecord(modelsConfig.providers) ?? {}
const providerNames = Object.keys(providers)
const selectedProvider = typeof settings.defaultProvider === 'string' ? settings.defaultProvider : ''
const selectedProviderConfig = asRecord(providers[selectedProvider])
const providerModels = Array.isArray(selectedProviderConfig?.models) ? selectedProviderConfig.models : []
const updateSetting = (key: string, value: string) => {
const file = files.settings
if (!file) return
setStatus('')
setFiles((current) => ({ ...current, settings: { ...file, content: `${JSON.stringify({ ...parse('settings'), [key]: value }, null, 2)}\n` } }))
}
const updateProvider = (provider: string) => {
const file = files.settings
if (!file) return
const config = asRecord(providers[provider])
const models = Array.isArray(config?.models) ? config.models : []
const next = { ...parse('settings'), defaultProvider: provider, defaultModel: stringField(models[0], 'id') }
setStatus('')
setFiles((current) => ({ ...current, settings: { ...file, content: `${JSON.stringify(next, null, 2)}\n` } }))
}
const updateContent = (name: 'settings' | 'models', content: string) => {
const file = files[name]
if (!file) return
setStatus('')
setFiles((current) => ({ ...current, [name]: { ...file, content } }))
}
const updateModelsConfig = (mutate: (config: Record<string, unknown>) => void) => {
const file = files.models
if (!file) return
const config = JSON.parse(JSON.stringify(parse('models'))) as Record<string, unknown>
mutate(config)
updateContent('models', `${JSON.stringify(config, null, 2)}\n`)
}
const updateProviderField = (provider: string, key: string, value: unknown) => updateModelsConfig((config) => {
const nextProviders = asRecord(config.providers) ?? {}
const nextProvider = asRecord(nextProviders[provider])
if (nextProvider) nextProvider[key] = value
})
const addProvider = () => updateModelsConfig((config) => {
const nextProviders = asRecord(config.providers) ?? {}
let index = 1
let name = 'new-provider'
while (nextProviders[name]) { index += 1; name = `new-provider-${index}` }
nextProviders[name] = { baseUrl: '', api: 'openai-completions', apiKey: '', models: [] }
config.providers = nextProviders
})
const renameProvider = (provider: string, nextName: string) => {
const name = nextName.trim()
if (!name || name === provider) return
if (providers[name]) { setStatus('Provider ID 已存在'); return }
updateModelsConfig((config) => {
const nextProviders = asRecord(config.providers) ?? {}
const entries = Object.entries(nextProviders).map(([key, value]) => [key === provider ? name : key, value])
config.providers = Object.fromEntries(entries)
})
}
const deleteProvider = (provider: string) => {
if (!window.confirm(`删除 Provider “${provider}”及其全部模型?`)) return
updateModelsConfig((config) => { const nextProviders = asRecord(config.providers) ?? {}; delete nextProviders[provider] })
}
const addModel = (provider: string) => updateModelsConfig((config) => {
const nextProvider = asRecord((asRecord(config.providers) ?? {})[provider])
if (!nextProvider) return
const models = Array.isArray(nextProvider.models) ? nextProvider.models : []
let index = models.length + 1
let id = 'new-model'
while (models.some((model) => stringField(model, 'id') === id)) { index += 1; id = `new-model-${index}` }
nextProvider.models = [...models, { id, name: 'New Model', contextWindow: 128000, maxTokens: 8192, input: ['text'], reasoning: false }]
})
const updateModelField = (provider: string, index: number, key: string, value: unknown) => updateModelsConfig((config) => {
const nextProvider = asRecord((asRecord(config.providers) ?? {})[provider])
const models = Array.isArray(nextProvider?.models) ? nextProvider.models : []
const model = asRecord(models[index])
if (model) model[key] = value
})
const deleteModel = (provider: string, index: number) => updateModelsConfig((config) => {
const nextProvider = asRecord((asRecord(config.providers) ?? {})[provider])
const models = Array.isArray(nextProvider?.models) ? nextProvider.models : []
nextProvider!.models = models.filter((_, modelIndex) => modelIndex !== index)
})
const save = async (name: 'settings' | 'models') => {
const file = files[name]
if (!file) return
setSaving(true)
setStatus('')
try {
JSON.parse(file.content)
const saved = await request<PiConfigFile>(`/api/pi/config/${name}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: file.content, revision: file.revision }) })
setFiles((current) => ({ ...current, [name]: saved }))
setStatus('已保存,新会话生效')
} catch (error) {
setStatus(error instanceof SyntaxError ? 'JSON 格式错误' : String(error).startsWith('Error: 409') ? '文件已被修改,请重新载入' : '保存失败,请检查配置')
} finally {
setSaving(false)
}
}
const formatSource = () => {
try { updateContent(sourceName, `${JSON.stringify(JSON.parse(files[sourceName]?.content ?? '{}'), null, 2)}\n`) } catch { setStatus('JSON 格式错误') }
}
if (loading) return <div className="pi-settings-loading"><span /><span /><span /></div>
return <div className="pi-settings">
<div className="settings-subnav" role="tablist">
<button className={view === 'defaults' ? 'active' : ''} onClick={() => setView('defaults')}>默认配置</button>
<button className={view === 'models' ? 'active' : ''} onClick={() => setView('models')}>模型</button>
<button className={view === 'source' ? 'active' : ''} onClick={() => setView('source')}>配置文件</button>
</div>
{view === 'defaults' && <div className="pi-defaults">
<label><span>默认 Provider</span><select value={selectedProvider} onChange={(event) => updateProvider(event.target.value)}><option value="">请选择</option>{providerNames.map((provider) => <option value={provider} key={provider}>{provider}</option>)}</select></label>
<label><span>默认模型</span><select value={typeof settings.defaultModel === 'string' ? settings.defaultModel : ''} onChange={(event) => updateSetting('defaultModel', event.target.value)}><option value="">请选择</option>{providerModels.map((model, index) => <option value={stringField(model, 'id')} key={`${stringField(model, 'id')}-${index}`}>{stringField(model, 'name') || stringField(model, 'id')}</option>)}</select></label>
<label><span>思考强度</span><select value={typeof settings.defaultThinkingLevel === 'string' ? settings.defaultThinkingLevel : 'medium'} onChange={(event) => updateSetting('defaultThinkingLevel', event.target.value)}>{THINKING_LEVELS.map((level) => <option value={level} key={level}>{level}</option>)}</select></label>
<div className="pi-settings-actions"><span>{files.settings?.path}</span><button className="primary-action" disabled={saving || !files.settings} onClick={() => void save('settings')}><Save size={15} />{saving ? '保存中...' : '保存默认配置'}</button></div>
</div>}
{view === 'models' && <div className="provider-manager"><div className="provider-toolbar"><span>{providerNames.length} 个 Provider</span><div><button onClick={addProvider}><Plus size={15} />添加 Provider</button><button className="primary-action" disabled={saving || !files.models} onClick={() => void save('models')}><Save size={15} />{saving ? '保存中...' : '保存模型配置'}</button></div></div><div className="provider-list">{providerNames.map((provider) => {
const config = asRecord(providers[provider]); const models = Array.isArray(config?.models) ? config.models : []
return <section className="provider-section" key={provider}><header><div><strong>{provider}</strong><span>{models.length} 个模型</span></div><div><button className="icon-button danger-icon" title="删除 Provider" aria-label={`删除 ${provider}`} onClick={() => deleteProvider(provider)}><Trash2 size={15} /></button></div></header><div className="provider-fields">
<label><span>Provider ID</span><input defaultValue={provider} onBlur={(event) => renameProvider(provider, event.target.value)} /></label>
<label><span>API 类型</span><input value={stringField(config, 'api')} onChange={(event) => updateProviderField(provider, 'api', event.target.value)} placeholder="openai-completions" /></label>
<label className="wide-field"><span>Base URL</span><input value={stringField(config, 'baseUrl')} onChange={(event) => updateProviderField(provider, 'baseUrl', event.target.value)} placeholder="https://api.example.com/v1" /></label>
<label className="wide-field"><span>API Key</span><div className="secret-input"><input type={visibleKeys[provider] ? 'text' : 'password'} value={stringField(config, 'apiKey')} autoComplete="new-password" onChange={(event) => updateProviderField(provider, 'apiKey', event.target.value)} placeholder="输入 API Key" /><button type="button" title={visibleKeys[provider] ? '隐藏 API Key' : '显示 API Key'} aria-label={visibleKeys[provider] ? '隐藏 API Key' : '显示 API Key'} onClick={() => setVisibleKeys((current) => ({ ...current, [provider]: !current[provider] }))}>{visibleKeys[provider] ? <EyeOff size={15} /> : <Eye size={15} />}</button></div></label>
</div><div className="models-heading"><strong>模型</strong><button onClick={() => addModel(provider)}><Plus size={14} />添加模型</button></div><div className="model-editor-list">{models.map((value, index) => { const model = asRecord(value) ?? {}; return <div className="model-editor" key={`${stringField(model, 'id')}-${index}`}>
<div className="model-editor-heading"><strong>{stringField(model, 'name') || stringField(model, 'id') || `模型 ${index + 1}`}</strong><button className="icon-button danger-icon" title="删除模型" aria-label="删除模型" onClick={() => deleteModel(provider, index)}><Trash2 size={14} /></button></div>
<div className="model-fields"><label><span>模型 ID</span><input value={stringField(model, 'id')} onChange={(event) => updateModelField(provider, index, 'id', event.target.value)} /></label><label><span>显示名称</span><input value={stringField(model, 'name')} onChange={(event) => updateModelField(provider, index, 'name', event.target.value)} /></label><label><span>上下文窗口</span><input type="number" min="1" value={typeof model.contextWindow === 'number' ? model.contextWindow : ''} onChange={(event) => updateModelField(provider, index, 'contextWindow', Number(event.target.value))} /></label><label><span>最大输出 Tokens</span><input type="number" min="1" value={typeof model.maxTokens === 'number' ? model.maxTokens : ''} onChange={(event) => updateModelField(provider, index, 'maxTokens', Number(event.target.value))} /></label><label><span>输入类型</span><input value={Array.isArray(model.input) ? model.input.join(', ') : ''} onChange={(event) => updateModelField(provider, index, 'input', event.target.value.split(',').map((item) => item.trim()).filter(Boolean))} placeholder="text, image" /></label><label className="toggle-field"><span>推理模型</span><input type="checkbox" checked={model.reasoning === true} onChange={(event) => updateModelField(provider, index, 'reasoning', event.target.checked)} /></label></div>
</div>})}{models.length === 0 && <div className="models-empty">还没有模型</div>}</div></section>
})}{providerNames.length === 0 && <div className="settings-empty">没有自定义 Provider,点击上方按钮添加</div>}</div></div>}
{view === 'source' && <div className="config-source">
<div className="source-toolbar"><div className="source-switch"><button className={sourceName === 'settings' ? 'active' : ''} onClick={() => setSourceName('settings')}>settings.json</button><button className={sourceName === 'models' ? 'active' : ''} onClick={() => setSourceName('models')}>models.json</button></div><code>{files[sourceName]?.path}</code><button onClick={formatSource}>格式化</button><button className="primary-action" disabled={saving || !files[sourceName]} onClick={() => void save(sourceName)}><Save size={15} />保存</button></div>
<textarea className="config-editor" spellCheck={false} value={files[sourceName]?.content ?? ''} onChange={(event) => updateContent(sourceName, event.target.value)} aria-label={`编辑 ${sourceName}.json`} />
</div>}
{status && <div className={`pi-settings-status ${status.includes('已保存') ? 'success' : ''}`}>{status}</div>}
</div>
}
function SettingsPage({ projects, onBack }: { projects: Project[]; onBack: () => void }) {
const [tab, setTab] = useState<'general' | 'pi' | 'account' | 'about'>('general')
const [info, setInfo] = useState<SettingsInfo | null>(null)
const [loggingOut, setLoggingOut] = useState(false)
useEffect(() => { void request<SettingsInfo>('/api/settings').then(setInfo) }, [])
const logout = async () => {
setLoggingOut(true)
try {
await fetch('/logout', { method: 'POST' })
} finally {
window.location.assign('/login')
}
}
return <main className="settings-page">
<header className="settings-header"><button className="icon-button mobile-back" onClick={onBack} aria-label="返回会话列表"><ArrowLeft /></button><h1>设置</h1></header>
<div className="settings-layout">
<nav className="settings-nav" aria-label="设置分类">
<button className={tab === 'general' ? 'active' : ''} onClick={() => setTab('general')}><Settings2 size={17} />常规</button>
<button className={tab === 'pi' ? 'active' : ''} onClick={() => setTab('pi')}><Bot size={17} />Pi Agent</button>
<button className={tab === 'account' ? 'active' : ''} onClick={() => setTab('account')}><UserRound size={17} />账户</button>
<button className={tab === 'about' ? 'active' : ''} onClick={() => setTab('about')}><Info size={17} />关于</button>
</nav>
<section className="settings-content">
{tab === 'general' && <><h2>常规</h2><div className="settings-rows"><div><span>工作目录</span><code>{info?.workspace_root || '...'}</code></div><div><span>数据目录</span><code>{info?.data_dir || '...'}</code></div><div><span>项目</span><strong>{projects.length}</strong></div><div><span>外观</span><strong>跟随系统</strong></div></div></>}
{tab === 'pi' && <><h2>Pi Agent</h2><PiAgentSettings /></>}
{tab === 'account' && <><h2>账户</h2><div className="account-status"><span className="status-dot" /><div><strong>已登录</strong><span>当前浏览器会话有效</span></div></div><div className="settings-rows"><div><span>服务</span><code>{window.location.host}</code></div><div><span>连接</span><strong>安全会话</strong></div></div><div className="account-actions"><button className="logout-button" disabled={loggingOut} onClick={() => void logout()}><LogOut size={16} />{loggingOut ? '正在退出...' : '退出账户'}</button></div></>}
{tab === 'about' && <><h2>关于</h2><div className="about-product"><div className="brand-mark"><BrainCircuit size={19} /></div><div><strong>xagent-pi</strong><span>本地智能助手</span></div></div><div className="settings-rows"><div><span>版本</span><code>{info?.version || '...'}</code></div><div><span>服务组件</span><strong>xagent-service</strong></div></div></>}
</section>
</div>
</main>
}
export default function App() {
const [settingsInfo, setSettingsInfo] = useState<SettingsInfo | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [sessions, setSessions] = useState<SessionMeta[]>([])
const [activeId, setActiveId] = useState<string | null>(() => sessionIdFromPath())
const [mobileConversation, setMobileConversation] = useState(() => Boolean(sessionIdFromPath()))
const [items, setItems] = useState<TimelineItem[]>([])
const [sessionsLoading, setSessionsLoading] = useState(true)
const [messagesLoading, setMessagesLoading] = useState(false)
const [thinking, setThinking] = useState(false)
const [connected, setConnected] = useState(true)
const [models, setModels] = useState<ModelInfo[]>([])
const [currentModel, setCurrentModel] = useState<CurrentModel | null>(null)
const [thinkingLevel, setThinkingLevel] = useState('medium')
const [renameTarget, setRenameTarget] = useState<SessionMeta | null>(null)
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null)
const [projectPickerOpen, setProjectPickerOpen] = useState(false)
const [sessionProject, setSessionProject] = useState<Project | null>(null)
const [settingsPage, setSettingsPage] = useState(() => window.location.pathname === '/settings')
const refreshSessions = useCallback(async () => {
const result = await request<SessionMeta[]>('/api/sessions')
setSessions(result)
return result
}, [])
const createSession = useCallback(async (projectId: string, mode: 'simple' | 'worktree' = 'simple', worktreeName?: string) => {
const session = await request<SessionMeta>('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: projectId, session_type: mode, worktree_name: worktreeName }) })
setSessions((current) => [session, ...current])
setActiveId(session.id)
setSessionProject(null)
window.history.pushState({}, '', `/sessions/${encodeURIComponent(session.id)}`)
setSettingsPage(false)
setMobileConversation(true)
}, [])
useEffect(() => {
void Promise.all([refreshSessions(), request<Project[]>('/api/projects'), request<SettingsInfo>('/api/settings')]).then(([, projectItems, info]) => {
setProjects(projectItems)
setSettingsInfo(info)
}).finally(() => setSessionsLoading(false))
}, [refreshSessions])
useEffect(() => {
const onPopState = () => {
const isSettings = window.location.pathname === '/settings'
const sessionId = sessionIdFromPath()
setSettingsPage(isSettings)
setActiveId(sessionId)
setMobileConversation(isSettings || Boolean(sessionId))
}
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
}, [])
useEffect(() => {
if (!activeId) return
setMessagesLoading(true)
setThinking(false)
setConnected(true)
setItems([])
void request<StoredMessage[]>(`/api/sessions/${activeId}/messages`).then((messages) => setItems(messages.map((message, index): TimelineItem => {
const id = `${message.ts}-${index}`
if (message.role === 'tool') return { id, kind: 'tool', callId: message.call_id ?? id, name: message.tool_name ?? '', input: message.input, result: message.result, isError: Boolean(message.is_error) }
if (message.role === 'reasoning') return { id, kind: 'reasoning', text: message.text }
return { id, kind: 'message', role: message.role, text: message.text, ts: message.ts }
}))).finally(() => setMessagesLoading(false))
void request<{ current: CurrentModel | null; available: ModelInfo[] }>(`/api/sessions/${activeId}/models`).then((result) => { setModels(result.available); setCurrentModel(result.current) }).catch(() => { setModels([]); setCurrentModel(null) })
const events = new EventSource(`/api/sessions/${activeId}/events`)
events.onopen = () => setConnected(true)
events.onerror = () => setConnected(false)
events.onmessage = (event) => {
const value = JSON.parse(event.data) as { type: string; role?: string; text?: string; on?: boolean; call_id?: string; name?: string; input?: unknown; result?: unknown; is_error?: boolean }
if (value.type === 'thinking') return setThinking(Boolean(value.on))
if (value.type === 'message') setItems((current) => [...current, { id: uid(), kind: 'message', role: value.role ?? 'assistant', text: value.text ?? '', ts: Date.now() }])
if (value.type === 'reasoning') setItems((current) => [...current, { id: uid(), kind: 'reasoning', text: value.text ?? '' }])
if (value.type === 'tool') setItems((current) => {
const callId = value.call_id || uid()
const index = current.findIndex((item) => item.kind === 'tool' && item.callId === callId)
if (index < 0) return [...current, { id: uid(), kind: 'tool', callId, name: value.name ?? '', input: value.input, result: value.result, isError: Boolean(value.is_error) }]
return current.map((item, itemIndex) => itemIndex === index && item.kind === 'tool' ? { ...item, name: value.name || item.name, input: value.input ?? item.input, result: value.result ?? item.result, isError: Boolean(value.is_error) } : item)
})
}
return () => events.close()
}, [activeId])
const activeSession = sessions.find((session) => session.id === activeId) ?? null
const rename = async (title: string) => {
if (!renameTarget || !title) return
const updated = await request<SessionMeta>(`/api/sessions/${renameTarget.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) })
setSessions((current) => current.map((session) => session.id === updated.id ? updated : session))
setRenameTarget(null)
}
const remove = async () => {
if (!deleteTarget) return
await request<void>(`/api/sessions/${deleteTarget.id}`, { method: 'DELETE' })
const remaining = sessions.filter((session) => session.id !== deleteTarget.id)
setSessions(remaining)
if (activeId === deleteTarget.id) {
setActiveId(null)
window.history.pushState({}, '', '/')
}
setDeleteTarget(null)
setMobileConversation(false)
}
const createProject = async (path: string) => {
const project = await request<Project>('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) })
setProjects((current) => current.some((item) => item.id === project.id) ? current : [...current, project])
setProjectPickerOpen(false)
setSessionProject(project)
}
const send = async (text: string, uploads: Upload[]) => {
if (!activeId) return
const attachmentText = uploads.map((upload) => `[附件:${upload.name},路径 ${upload.path}]`).join('\n')
const fullText = [text, attachmentText].filter(Boolean).join('\n\n')
await request<void>(`/api/sessions/${activeId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: fullText }) })
void refreshSessions()
}
const openSettings = () => { window.history.pushState({}, '', '/settings'); setSettingsPage(true); setMobileConversation(true) }
const closeSettings = () => { window.history.pushState({}, '', '/'); setSettingsPage(false); setMobileConversation(false) }
const selectSession = (id: string) => { window.history.pushState({}, '', `/sessions/${encodeURIComponent(id)}`); setSettingsPage(false); setActiveId(id); setMobileConversation(true) }
return <div className={`app-shell ${mobileConversation || settingsPage ? 'show-conversation' : ''}`}>
<SessionList username={settingsInfo?.username ?? ''} projects={projects} sessions={sessions} activeId={settingsPage ? null : activeId} loading={sessionsLoading} onSelect={selectSession} onCreateProject={() => setProjectPickerOpen(true)} onCreateSession={(projectId) => setSessionProject(projects.find((project) => project.id === projectId) ?? null)} onSettings={openSettings} settingsActive={settingsPage} connected={connected} onRename={setRenameTarget} onDelete={setDeleteTarget} />
{settingsPage ? <SettingsPage projects={projects} onBack={closeSettings} /> : <Conversation session={activeSession} items={items} loading={messagesLoading} thinking={thinking} connected={connected} models={models} currentModel={currentModel} thinkingLevel={thinkingLevel} onBack={() => setMobileConversation(false)} onRename={() => activeSession && setRenameTarget(activeSession)} onModel={(model) => { setCurrentModel(model); if (activeId) void request(`/api/sessions/${activeId}/model`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(model) }) }} onThinking={(level) => { setThinkingLevel(level); if (activeId) void request(`/api/sessions/${activeId}/thinking`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level }) }) }} onSend={send} onAbort={() => { if (activeId) void request(`/api/sessions/${activeId}/abort`, { method: 'POST' }) }} />}
{renameTarget && <TextDialog title="重命名会话" initialValue={renameTarget.title} confirmLabel="保存" onClose={() => setRenameTarget(null)} onConfirm={(value) => void rename(value)} />}
{deleteTarget && <TextDialog title={`删除“${deleteTarget.title}”?`} initialValue="" confirmLabel="删除" destructive onClose={() => setDeleteTarget(null)} onConfirm={() => void remove()} />}
{projectPickerOpen && <DirectoryPicker onClose={() => setProjectPickerOpen(false)} onSelect={createProject} />}
{sessionProject && <NewSessionDialog project={sessionProject} onClose={() => setSessionProject(null)} onCreate={(mode, name) => createSession(sessionProject.id, mode, name)} />}
</div>
}