feat(ext): add chat history

This commit is contained in:
Simon
2026-02-10 18:43:54 +08:00
parent 3f284cf2e6
commit 430be466fc
6 changed files with 307 additions and 6 deletions

8
package-lock.json generated
View File

@@ -6757,6 +6757,13 @@
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"dev": true,
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -11091,6 +11098,7 @@
"@wxt-dev/module-react": "^1.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^0.563.0",
"motion": "^12.34.0",
"next-themes": "^0.4.6",

View File

@@ -23,6 +23,7 @@
"@wxt-dev/module-react": "^1.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^0.563.0",
"motion": "^12.34.0",
"next-themes": "^0.4.6",

View File

@@ -1,4 +1,4 @@
import { Send, Settings, Square } from 'lucide-react'
import { History, Send, Settings, Square } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -8,20 +8,47 @@ import {
InputGroupButton,
InputGroupTextarea,
} from '@/components/ui/input-group'
import { saveSession } from '@/lib/db'
import { useAgent } from '../../agent/useAgent'
import { ConfigPanel } from './components/ConfigPanel'
import { HistoryDetail } from './components/HistoryDetail'
import { HistoryList } from './components/HistoryList'
import { ActivityCard, EventCard } from './components/cards'
import { EmptyState, Logo, StatusDot } from './components/misc'
type View =
| { name: 'chat' }
| { name: 'config' }
| { name: 'history' }
| { name: 'history-detail'; sessionId: string }
export default function App() {
const [showConfig, setShowConfig] = useState(false)
const [view, setView] = useState<View>({ name: 'chat' })
const [inputValue, setInputValue] = useState('')
const historyRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
// Persist session when task finishes
const prevStatusRef = useRef(status)
useEffect(() => {
const prev = prevStatusRef.current
prevStatusRef.current = status
if (
prev === 'running' &&
(status === 'completed' || status === 'error') &&
history.length > 0 &&
currentTask
) {
saveSession({ task: currentTask, history, status }).catch((err) =>
console.error('[SidePanel] Failed to save session:', err)
)
}
}, [status, history, currentTask])
// Auto-scroll to bottom on new events
useEffect(() => {
if (historyRef.current) {
@@ -56,19 +83,36 @@ export default function App() {
}
}
if (showConfig) {
// --- View routing ---
if (view.name === 'config') {
return (
<ConfigPanel
config={config}
onSave={async (newConfig) => {
await configure(newConfig)
setShowConfig(false)
setView({ name: 'chat' })
}}
onClose={() => setShowConfig(false)}
onClose={() => setView({ name: 'chat' })}
/>
)
}
if (view.name === 'history') {
return (
<HistoryList
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
onBack={() => setView({ name: 'chat' })}
/>
)
}
if (view.name === 'history-detail') {
return <HistoryDetail sessionId={view.sessionId} onBack={() => setView({ name: 'history' })} />
}
// --- Chat view ---
const isRunning = status === 'running'
const showEmptyState = !currentTask && history.length === 0 && !isRunning
@@ -85,7 +129,15 @@ export default function App() {
<Button
variant="ghost"
size="icon-sm"
onClick={() => setShowConfig(true)}
onClick={() => setView({ name: 'history' })}
className="cursor-pointer"
>
<History className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setView({ name: 'config' })}
className="cursor-pointer"
>
<Settings className="size-3.5" />

View File

@@ -0,0 +1,51 @@
import { ArrowLeft } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { type SessionRecord, getSession } from '@/lib/db'
import { EventCard } from './cards'
export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack: () => void }) {
const [session, setSession] = useState<SessionRecord | null>(null)
useEffect(() => {
getSession(sessionId).then((s) => setSession(s ?? null))
}, [sessionId])
if (!session) {
return (
<div className="flex items-center justify-center h-screen text-xs text-muted-foreground">
Loading...
</div>
)
}
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium truncate">History</span>
</header>
{/* Task */}
<div className="border-b px-3 py-2 bg-muted/30">
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
<div className="text-xs font-medium" title={session.task}>
{session.task}
</div>
</div>
{/* Events (read-only) */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{session.history.map((event, index) => (
// eslint-disable-next-line react-x/no-array-index-key
<EventCard key={index} event={event} />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
function timeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export function HistoryList({
onSelect,
onBack,
}: {
onSelect: (id: string) => void
onBack: () => void
}) {
const [sessions, setSessions] = useState<SessionRecord[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setSessions(await listSessions())
setLoading(false)
}, [])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load()
}, [load])
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
await deleteSession(id)
setSessions((prev) => prev.filter((s) => s.id !== id))
}
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium flex-1">History</span>
{sessions.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
await clearSessions()
setSessions([])
}}
className="text-[10px] text-muted-foreground hover:text-destructive cursor-pointer h-6 px-2"
>
<Trash2 className="size-3 mr-1" />
Clear All
</Button>
)}
</header>
{/* List */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
Loading...
</div>
)}
{!loading && sessions.length === 0 && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
No history yet
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() => onSelect(session.id)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)}
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
>
{/* Status icon */}
{session.status === 'completed' ? (
<CheckCircle className="size-3.5 text-green-500 shrink-0 mt-0.5" />
) : (
<XCircle className="size-3.5 text-destructive shrink-0 mt-0.5" />
)}
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{session.task}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{timeAgo(session.createdAt)} · {session.history.length} steps
</p>
</div>
{/* Delete */}
<button
type="button"
onClick={(e) => handleDelete(e, session.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer shrink-0"
>
<Trash2 className="size-3" />
</button>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import type { HistoricalEvent } from '@page-agent/core'
import { type DBSchema, type IDBPDatabase, openDB } from 'idb'
const DB_NAME = 'page-agent-ext'
const DB_VERSION = 1
export interface SessionRecord {
id: string
task: string
history: HistoricalEvent[]
status: 'completed' | 'error'
createdAt: number
}
interface PageAgentDB extends DBSchema {
sessions: {
key: string
value: SessionRecord
indexes: { 'by-created': number }
}
}
let dbPromise: Promise<IDBPDatabase<PageAgentDB>> | null = null
function getDB() {
if (!dbPromise) {
dbPromise = openDB<PageAgentDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
const store = db.createObjectStore('sessions', { keyPath: 'id' })
store.createIndex('by-created', 'createdAt')
},
})
}
return dbPromise
}
export async function saveSession(
session: Omit<SessionRecord, 'id' | 'createdAt'>
): Promise<SessionRecord> {
const db = await getDB()
const record: SessionRecord = {
...session,
id: crypto.randomUUID(),
createdAt: Date.now(),
}
await db.put('sessions', record)
return record
}
/** List sessions, newest first */
export async function listSessions(): Promise<SessionRecord[]> {
const db = await getDB()
const all = await db.getAllFromIndex('sessions', 'by-created')
return all.reverse()
}
export async function getSession(id: string): Promise<SessionRecord | undefined> {
const db = await getDB()
return db.get('sessions', id)
}
export async function deleteSession(id: string): Promise<void> {
const db = await getDB()
await db.delete('sessions', id)
}
export async function clearSessions(): Promise<void> {
const db = await getDB()
await db.clear('sessions')
}