From a5434fc522943b785358bc8aca44c4fbb8e6c0e3 Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Wed, 5 Aug 2026 22:27:42 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20GitHub=20Pages?= =?UTF-8?q?=20=E4=BC=9A=E8=AF=9D=E5=8E=86=E5=8F=B2=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E4=B8=8E=E5=AF=BC=E5=87=BA=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pages.yml | 37 ++ README.md | 5 + package.json | 3 +- packages/agent-core/src/web/export.ts | 177 +++++++++ packages/client/README.md | 27 ++ packages/client/app.js | 345 +++++++++++++++++ packages/client/data.example.json | 169 +++++++++ packages/client/index.html | 17 + packages/client/styles.css | 519 ++++++++++++++++++++++++++ source/app.tsx | 21 ++ source/export-web.ts | 19 + tests/web-export.spec.ts | 105 ++++++ tsconfig.json | 6 +- 13 files changed, 1448 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 packages/agent-core/src/web/export.ts create mode 100644 packages/client/README.md create mode 100644 packages/client/app.js create mode 100644 packages/client/data.example.json create mode 100644 packages/client/index.html create mode 100644 packages/client/styles.css create mode 100644 source/export-web.ts create mode 100644 tests/web-export.spec.ts diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..ebe662d --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,37 @@ +name: Deploy Client to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload Packages Client + uses: actions/upload-pages-artifact@v3 + with: + path: packages/client + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 64caba6..149f9c0 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ packages/ prompt/ # 系统提示词、工具说明、模式提示词 tools/ # 环境、文件、命令等本地工具 policy/ # PLAN/BUILD 模式下的工具权限 + web/ # GitHub Pages 客户端数据导出 + client/ # 静态会话历史界面,可部署到 GitHub Pages tests/ # 项目统一单元测试 vitest.config.ts # Vitest 测试配置 ``` @@ -103,6 +105,9 @@ pnpm exec vitest run --reporter verbose # 构建 agent-core pnpm run build:agent-core + +# 导出会话历史到 packages/client/data.json +pnpm export:web ``` ## 测试说明 diff --git a/package.json b/package.json index f41f9fe..418bea9 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "tsx source/app.tsx", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --reporter verbose", - "build:agent-core": "tsc -p packages/agent-core/tsconfig.json" + "build:agent-core": "tsc -p packages/agent-core/tsconfig.json", + "export:web": "tsx source/export-web.ts" }, "dependencies": { "dotenv": "^17.4.2", diff --git a/packages/agent-core/src/web/export.ts b/packages/agent-core/src/web/export.ts new file mode 100644 index 0000000..5567a6f --- /dev/null +++ b/packages/agent-core/src/web/export.ts @@ -0,0 +1,177 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { + EntryLike, + SessionStoreLike, + SessionStatsLike, +} from '../session/session-repository'; + +/** GitHub Pages 客户端读取的会话快照格式。 */ +export interface WebEntry { + seq: number; + id: string; + parentId: string | null; + type: string; + role: string; + timestamp: string; + text?: string; + tool?: string | null; + tags?: string[]; + payload: unknown; +} + +/** 运行记录的最小导出形状。 */ +export interface WebRecord { + seq: number; + id: string; + lane: string; + runId: string | null; + type: string; + opKind: string | null; + timestamp: string; + payload: unknown; +} + +/** 事实表的最小导出形状。 */ +export interface WebFact { + seq: number; + kind: string; + key: string | null; + value: string | null; +} + +/** 单个会话的导出结构。 */ +export interface WebSession { + id: string; + createdAt: string; + cwd: string; + parentSessionId: string | null; + metadata: Record; + stats: SessionStatsLike; + entries: WebEntry[]; + records?: WebRecord[]; + facts?: WebFact[]; +} + +/** 完整导出文件结构。 */ +export interface WebExport { + schemaVersion: 1; + exportedAt: string; + sessions: WebSession[]; +} + +/** export.ts 需要的存储最小接口,兼容 SessionStoreLike 及 SQLite 扩展方法。 */ +type WebStore = SessionStoreLike & { + getRecords?: (sessionId: string, options?: { limit?: number; offset?: number }) => WebRecord[]; + listFacts?: (sessionId: string) => WebFact[]; +}; + +const entryText = (payload: unknown): string | undefined => { + if (!payload || typeof payload !== 'object') { + return undefined; + } + const content = (payload as { content?: unknown }).content; + return typeof content === 'string' ? content : undefined; +}; + +const entryRole = (entry: EntryLike): string => { + const payload = entry.payload as { role?: unknown } | null; + return payload && typeof payload.role === 'string' ? payload.role : entry.type; +}; + +const entryTool = (payload: unknown): string | null => { + if (!payload || typeof payload !== 'object') { + return null; + } + const tool = (payload as { tool?: unknown }).tool; + return typeof tool === 'string' ? tool : null; +}; + +const entryTags = (payload: unknown): string[] | undefined => { + if (!payload || typeof payload !== 'object') { + return undefined; + } + const tags = (payload as { tags?: unknown }).tags; + if (!Array.isArray(tags)) { + return undefined; + } + const result = tags.filter((tag): tag is string => typeof tag === 'string'); + return result.length > 0 ? result : undefined; +}; + +const toWebEntry = (entry: EntryLike): WebEntry => { + const role = entryRole(entry); + const text = entryText(entry.payload); + const tool = entryTool(entry.payload); + const tags = entryTags(entry.payload); + + return { + seq: entry.seq, + id: entry.id, + parentId: entry.parentId, + type: entry.type, + role, + timestamp: entry.timestamp, + text, + tool, + tags, + payload: entry.payload, + }; +}; + +/** + * 把会话存储导出为静态客户端可用的 JSON 结构。 + * records 和 facts 是可选能力,存储未实现时自动省略。 + */ +export const buildWebExport = ( + store: WebStore, + options: { limit?: number } = {}, +): WebExport => { + const sessions = store.listSessions({ limit: options.limit ?? 100 }); + + return { + schemaVersion: 1, + exportedAt: new Date().toISOString(), + sessions: sessions.map((session) => ({ + id: session.id, + createdAt: session.createdAt, + cwd: session.cwd, + parentSessionId: session.parentSessionId, + metadata: session.metadata, + stats: store.getStats(session.id), + entries: store.getEntries(session.id, { limit: 100000 }).map(toWebEntry), + records: store.getRecords + ? store.getRecords(session.id).map((record) => ({ + seq: record.seq, + id: record.id, + lane: record.lane, + runId: record.runId, + type: record.type, + opKind: record.opKind, + timestamp: record.timestamp, + payload: record.payload, + })) + : undefined, + facts: store.listFacts + ? store.listFacts(session.id).map((fact) => ({ + seq: fact.seq, + kind: fact.kind, + key: fact.key, + value: fact.value, + })) + : undefined, + })), + }; +}; + +/** 把会话快照写入 packages/client 可读取的静态 JSON 文件。 */ +export const writeWebExport = ( + store: WebStore, + outputPath: string, + options: { limit?: number } = {}, +): WebExport => { + const data = buildWebExport(store, options); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + return data; +}; diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..b4efece --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,27 @@ +# Call Code History Client + +这是 CLI 会话历史的静态展示端,可直接部署到 GitHub Pages。页面读取同目录下的 `data.json`,如果不存在则回退到 `data.example.json` 预览数据。 + +## 导出数据 + +在仓库根目录运行: + +```bash +pnpm export:web +``` + +脚本会把当前 `SESSION_DB_PATH` 指向的会话数据库导出为 `packages/client/data.json`。也可以在 CLI 中执行 `/export` 完成同样操作。 + +导出文件包含会话、消息、工具调用、统计和事实数据。发布到公开 GitHub Pages 前请先检查内容是否包含不该公开的路径、命令输出或密钥。 + +## 本地预览 + +```bash +python3 -m http.server 4173 --directory packages/client +``` + +然后打开 [http://localhost:4173](http://localhost:4173)。 + +## 主题 + +页面内置白色毛玻璃和高级黑两套主题,右上角按钮可切换,选择会保存在浏览器本地。 diff --git a/packages/client/app.js b/packages/client/app.js new file mode 100644 index 0000000..d0ee3c7 --- /dev/null +++ b/packages/client/app.js @@ -0,0 +1,345 @@ +const DATA_URLS = ['./data.json', './data.example.json']; +const THEME_KEY = 'call-code-theme'; + +const state = { + data: null, + sessions: [], + activeId: null, + query: '', + filter: 'all', + theme: localStorage.getItem(THEME_KEY) || 'dark', +}; + +const app = document.getElementById('app'); + +const applyTheme = () => { + document.documentElement.dataset.theme = state.theme; +}; + +const toggleTheme = () => { + state.theme = state.theme === 'dark' ? 'light' : 'dark'; + localStorage.setItem(THEME_KEY, state.theme); + applyTheme(); + render(); +}; + +const createElement = (tag, className, text) => { + const element = document.createElement(tag); + if (className) { + element.className = className; + } + if (text !== undefined && text !== null) { + element.textContent = String(text); + } + return element; +}; + +const formatTime = (value) => { + if (!value) { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + return date.toLocaleString('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const entryText = (entry) => { + if (entry.text) { + return entry.text; + } + if (entry.payload && typeof entry.payload === 'object' && 'content' in entry.payload) { + const content = entry.payload.content; + if (typeof content === 'string') { + return content; + } + } + return ''; +}; + +const entryRole = (entry) => entry.role || entry.type || 'assistant'; + +const getSessionTitle = (session) => { + const firstUser = session.entries.find((entry) => entryRole(entry) === 'user'); + if (firstUser) { + const text = entryText(firstUser).trim(); + if (text) { + return text; + } + } + const objective = session.metadata?.objective; + if (typeof objective === 'string' && objective.trim()) { + return objective; + } + return session.id; +}; + +const getSessionMode = (session) => { + const mode = session.metadata?.mode; + return typeof mode === 'string' ? mode : 'build'; +}; + +const getEntryCount = (session, role) => + role ? session.entries.filter((entry) => entryRole(entry) === role).length : session.entries.length; + +const matchesQuery = (session) => { + const query = state.query.trim().toLowerCase(); + if (!query) { + return true; + } + const haystack = [session.id, session.cwd, getSessionTitle(session)].map((value) => value.toLowerCase()); + for (const entry of session.entries) { + haystack.push(entryText(entry).toLowerCase()); + if (entry.tool) { + haystack.push(entry.tool.toLowerCase()); + } + } + return haystack.some((value) => value.includes(query)); +}; + +const filteredSessions = () => + state.sessions.filter((session) => matchesQuery(session)); + +const activeSession = () => { + const sessions = filteredSessions(); + return sessions.find((session) => session.id === state.activeId) || sessions[0] || null; +}; + +const filteredEntries = (session) => { + if (state.filter === 'all') { + return session.entries; + } + return session.entries.filter((entry) => entryRole(entry) === state.filter); +}; + +const renderStats = (sessions) => ({ + sessions: sessions.length, + messages: sessions.reduce((sum, session) => sum + session.entries.length, 0), + tools: sessions.reduce( + (sum, session) => sum + session.entries.filter((entry) => entryRole(entry) === 'tool').length, + 0, + ), +}); + +const renderSidebar = (sessions) => { + const sidebar = createElement('aside', 'panel sidebar'); + + const header = createElement('div', 'sidebar-header'); + const brandRow = createElement('div', 'brand-row'); + const brand = createElement('div', 'brand'); + brand.append(createElement('span', 'brand-mark', 'CC')); + const brandText = createElement('span', 'muted', 'Call Code'); + brand.append(brandText); + + const themeButton = createElement('button', 'theme-toggle', state.theme === 'dark' ? '☀' : '◐'); + themeButton.setAttribute('aria-label', state.theme === 'dark' ? '切换到白色毛玻璃主题' : '切换到高级黑主题'); + themeButton.addEventListener('click', toggleTheme); + brandRow.append(brand, themeButton); + + const searchBox = createElement('div', 'search-box'); + const searchIcon = createElement('span', 'faint', '⌕'); + const searchInput = createElement('input'); + searchInput.type = 'search'; + searchInput.placeholder = '搜索会话'; + searchInput.value = state.query; + searchInput.setAttribute('aria-label', '搜索会话'); + searchInput.addEventListener('input', (event) => { + state.query = event.target.value; + render(); + }); + searchBox.append(searchIcon, searchInput); + + const stats = renderStats(sessions); + const metaStats = createElement('div', 'meta-stats'); + const statDefs = [ + ['会话', stats.sessions], + ['消息', stats.messages], + ['工具', stats.tools], + ]; + for (const [label, value] of statDefs) { + const cell = createElement('div', 'stat-cell'); + cell.append(createElement('div', 'stat-value', value)); + cell.append(createElement('div', 'stat-label', label)); + metaStats.append(cell); + } + + header.append(brandRow, searchBox, metaStats); + sidebar.append(header); + + const list = createElement('div', 'session-list'); + if (sessions.length === 0) { + list.append(createElement('div', 'empty-state', '暂无会话')); + } else { + for (const session of sessions) { + const button = createElement('button', 'session-item'); + if (session.id === state.activeId) { + button.classList.add('active'); + } + button.append(createElement('div', 'session-title', getSessionTitle(session))); + const sub = createElement('div', 'session-sub'); + const meta = createElement('span', '', `${getSessionMode(session).toUpperCase()} · ${session.entries.length} 条`); + const time = createElement('span', '', formatTime(session.createdAt)); + sub.append(meta, time); + button.append(sub); + button.addEventListener('click', () => { + state.activeId = session.id; + render(); + }); + list.append(button); + } + } + + sidebar.append(list); + return sidebar; +}; + +const renderMessage = (entry) => { + const role = entryRole(entry); + const message = createElement('div', `message ${role}`); + const avatar = createElement('div', 'message-avatar', role === 'user' ? 'U' : role === 'assistant' ? 'A' : 'T'); + message.append(avatar); + + const body = createElement('div', 'message-body'); + const head = createElement('div', 'message-head'); + const label = createElement('div', 'message-label'); + label.append(createElement('span', '', role === 'user' ? '用户' : role === 'assistant' ? '助手' : '工具')); + if (entry.tool) { + label.append(createElement('span', 'pill', entry.tool)); + } + head.append(label, createElement('div', 'message-time', formatTime(entry.timestamp))); + body.append(head); + + const text = entryText(entry); + if (role === 'tool' || text.length > 320) { + body.append(createElement('pre', 'code-block', text)); + } else { + body.append(createElement('div', 'message-content', text)); + } + + message.append(body); + return message; +}; + +const renderFacts = (session) => { + if (!Array.isArray(session.facts) || session.facts.length === 0) { + return null; + } + const grid = createElement('div', 'fact-grid'); + for (const fact of session.facts.slice(0, 12)) { + const cell = createElement('div', 'fact-cell'); + cell.append(createElement('div', 'fact-kind', fact.kind)); + cell.append(createElement('div', 'fact-value', fact.value || fact.key || String(fact.seq))); + grid.append(cell); + } + return grid; +}; + +const renderMain = (session) => { + const main = createElement('main', 'panel main'); + + const header = createElement('div', 'main-header'); + const titleWrap = createElement('div'); + titleWrap.append(createElement('h1', 'main-title', session ? getSessionTitle(session) : 'Call Code')); + const meta = createElement('div', 'main-meta'); + if (session) { + const stats = session.stats || {}; + const pills = [ + getSessionMode(session).toUpperCase(), + `${session.entries.length} 条消息`, + `tokens ${stats.totalTokens ?? 0}`, + session.cwd, + ]; + for (const pill of pills) { + meta.append(createElement('span', 'pill', pill)); + } + } + titleWrap.append(meta); + header.append(titleWrap); + + const toolbar = createElement('div', 'toolbar'); + const segmented = createElement('div', 'segmented'); + const filters = [ + ['all', '全部'], + ['user', '用户'], + ['assistant', '助手'], + ['tool', '工具'], + ]; + for (const [value, label] of filters) { + const button = createElement('button', state.filter === value ? 'active' : '', label); + button.addEventListener('click', () => { + state.filter = value; + render(); + }); + segmented.append(button); + } + toolbar.append(segmented); + + const messages = createElement('div', 'messages'); + if (!session) { + messages.append(createElement('div', 'empty-state', '暂无会话数据')); + } else { + const entries = filteredEntries(session); + if (entries.length === 0) { + messages.append(createElement('div', 'empty-state', '没有匹配的消息')); + } else { + for (const entry of entries) { + messages.append(renderMessage(entry)); + } + const facts = renderFacts(session); + if (facts) { + messages.append(facts); + } + } + } + + main.append(header, toolbar, messages); + return main; +}; + +const render = () => { + app.className = 'app-shell'; + app.innerHTML = ''; + const sessions = filteredSessions(); + const session = activeSession(); + app.append(renderSidebar(sessions), renderMain(session)); +}; + +const renderLoadError = () => { + app.className = 'app-shell'; + app.innerHTML = ''; + const panel = createElement('div', 'panel main'); + const empty = createElement('div', 'empty-state', '无法读取会话数据'); + panel.append(empty); + app.append(createElement('aside', 'panel sidebar', 'Call Code'), panel); +}; + +const init = async () => { + applyTheme(); + for (const url of DATA_URLS) { + try { + const response = await fetch(url, { cache: 'no-cache' }); + if (!response.ok) { + continue; + } + const data = await response.json(); + if (data && Array.isArray(data.sessions)) { + state.data = data; + state.sessions = data.sessions; + state.activeId = data.sessions[0]?.id || null; + render(); + return; + } + } catch { + // 继续尝试下一个数据源 + } + } + renderLoadError(); +}; + +init(); diff --git a/packages/client/data.example.json b/packages/client/data.example.json new file mode 100644 index 0000000..e8e8f15 --- /dev/null +++ b/packages/client/data.example.json @@ -0,0 +1,169 @@ +{ + "schemaVersion": 1, + "exportedAt": "2026-08-05T12:00:00.000Z", + "sessions": [ + { + "id": "demo-build", + "createdAt": "2026-08-05T10:10:00.000Z", + "cwd": "/Users/allure/Desktop/demo-app", + "parentSessionId": null, + "metadata": { + "mode": "build", + "objective": "为仪表盘增加搜索过滤" + }, + "stats": { + "messageCount": 4, + "cachedTokens": 1200, + "uncachedTokens": 860, + "totalTokens": 2060, + "costTotal": 0.04 + }, + "entries": [ + { + "seq": 1, + "id": "e-user-1", + "parentId": null, + "type": "user", + "role": "user", + "timestamp": "2026-08-05T10:10:00.000Z", + "text": "给仪表盘加一个搜索框,按任务状态过滤列表。", + "payload": { + "content": "给仪表盘加一个搜索框,按任务状态过滤列表。", + "tags": ["task-input"] + } + }, + { + "seq": 2, + "id": "e-assistant-1", + "parentId": "e-user-1", + "type": "assistant", + "role": "assistant", + "timestamp": "2026-08-05T10:10:12.000Z", + "text": "我会先读取现有仪表盘组件,找出状态字段和列表渲染位置,再补充搜索过滤。", + "payload": { + "content": "我会先读取现有仪表盘组件,找出状态字段和列表渲染位置,再补充搜索过滤。", + "tags": ["model-response"] + } + }, + { + "seq": 3, + "id": "e-tool-1", + "parentId": "e-assistant-1", + "type": "tool", + "role": "tool", + "timestamp": "2026-08-05T10:10:18.000Z", + "tool": "read_file", + "text": "{\"path\":\"src/pages/dashboard.tsx\"}\nsuccess", + "payload": { + "content": "{\"path\":\"src/pages/dashboard.tsx\"}\nsuccess", + "tool": "read_file", + "tags": ["tool-result", "read_file"] + } + }, + { + "seq": 4, + "id": "e-assistant-2", + "parentId": "e-tool-1", + "type": "assistant", + "role": "assistant", + "timestamp": "2026-08-05T10:10:30.000Z", + "text": "已完成搜索框和状态过滤,列表会按状态字段实时过滤。", + "payload": { + "content": "已完成搜索框和状态过滤,列表会按状态字段实时过滤。", + "tags": ["model-response"] + } + } + ], + "records": [ + { + "seq": 3, + "id": "r-tool-1", + "lane": "run", + "runId": "demo-build", + "type": "tool_call", + "opKind": "read_file", + "timestamp": "2026-08-05T10:10:18.000Z", + "payload": { + "tool": "read_file", + "path": "src/pages/dashboard.tsx" + } + } + ], + "facts": [ + { + "seq": 1, + "kind": "task-objective", + "key": "objective", + "value": "为仪表盘增加搜索过滤" + } + ] + }, + { + "id": "demo-plan", + "createdAt": "2026-08-04T09:20:00.000Z", + "cwd": "/Users/allure/Desktop/call-code", + "parentSessionId": null, + "metadata": { + "mode": "plan", + "objective": "评估 GitHub Pages 静态发布方案" + }, + "stats": { + "messageCount": 3, + "cachedTokens": 900, + "uncachedTokens": 640, + "totalTokens": 1540, + "costTotal": 0.02 + }, + "entries": [ + { + "seq": 1, + "id": "e-user-p1", + "parentId": null, + "type": "user", + "role": "user", + "timestamp": "2026-08-04T09:20:00.000Z", + "text": "梳理 GitHub Pages 部署路径和需要提交的静态文件。", + "payload": { + "content": "梳理 GitHub Pages 部署路径和需要提交的静态文件。", + "tags": ["task-input"] + } + }, + { + "seq": 2, + "id": "e-assistant-p1", + "parentId": "e-user-p1", + "type": "assistant", + "role": "assistant", + "timestamp": "2026-08-04T09:20:08.000Z", + "text": "可以使用 GitHub Actions 将 packages/client 发布到 Pages,数据由 pnpm export:web 生成并提交。", + "payload": { + "content": "可以使用 GitHub Actions 将 packages/client 发布到 Pages,数据由 pnpm export:web 生成并提交。", + "tags": ["model-response"] + } + }, + { + "seq": 3, + "id": "e-assistant-p2", + "parentId": "e-assistant-p1", + "type": "assistant", + "role": "assistant", + "timestamp": "2026-08-04T09:20:16.000Z", + "text": "计划完成,确认后进入 BUILD 模式部署工作流。", + "payload": { + "content": "计划完成,确认后进入 BUILD 模式部署工作流。", + "tags": ["model-response"] + } + } + ], + "records": [], + "facts": [ + { + "seq": 1, + "kind": "task-objective", + "key": "objective", + "value": "评估 GitHub Pages 静态发布方案" + } + ] + } + ] +} diff --git a/packages/client/index.html b/packages/client/index.html new file mode 100644 index 0000000..a0874d2 --- /dev/null +++ b/packages/client/index.html @@ -0,0 +1,17 @@ + + + + + + + Call Code History + + + +
+
+
+
+ + + diff --git a/packages/client/styles.css b/packages/client/styles.css new file mode 100644 index 0000000..42fe608 --- /dev/null +++ b/packages/client/styles.css @@ -0,0 +1,519 @@ +:root { + color-scheme: light dark; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + letter-spacing: 0; +} + +:root[data-theme="light"] { + --bg: #eef3f7; + --bg-deep: #fbfdff; + --panel: rgba(255, 255, 255, 0.72); + --panel-strong: rgba(255, 255, 255, 0.9); + --panel-soft: rgba(255, 255, 255, 0.46); + --border: rgba(15, 23, 42, 0.1); + --border-strong: rgba(15, 23, 42, 0.16); + --text: #17212b; + --muted: #5b6775; + --faint: #8a95a2; + --accent: #0d9488; + --accent-strong: #0f766e; + --warm: #b45309; + --code-bg: rgba(15, 23, 42, 0.06); + --shadow: 0 18px 50px rgba(15, 23, 42, 0.12); + --shadow-soft: 0 8px 24px rgba(15, 23, 42, 0.08); + --glass-blur: blur(18px) saturate(160%); + --hover: rgba(255, 255, 255, 0.72); +} + +:root[data-theme="dark"] { + --bg: #05070a; + --bg-deep: #0a0d11; + --panel: rgba(13, 17, 22, 0.76); + --panel-strong: rgba(16, 21, 27, 0.92); + --panel-soft: rgba(255, 255, 255, 0.04); + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.14); + --text: #e8eef4; + --muted: #9aa7b4; + --faint: #67727e; + --accent: #22d3ee; + --accent-strong: #67e8f9; + --warm: #fbbf24; + --code-bg: rgba(0, 0, 0, 0.34); + --shadow: 0 18px 60px rgba(0, 0, 0, 0.42); + --shadow-soft: 0 8px 28px rgba(0, 0, 0, 0.3); + --glass-blur: blur(22px) saturate(130%); + --hover: rgba(255, 255, 255, 0.06); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + background: + linear-gradient(145deg, var(--bg-deep), var(--bg)); + color: var(--text); + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +button, +input { + font: inherit; + color: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(280px, 340px) 1fr; + gap: 16px; + min-height: 100vh; + padding: 16px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + box-shadow: var(--shadow); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border-radius: 8px; + overflow: hidden; +} + +.sidebar { + display: flex; + flex-direction: column; + min-height: 0; + height: calc(100vh - 32px); + position: sticky; + top: 16px; +} + +.sidebar-header { + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.brand-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 18px; + font-weight: 700; +} + +.brand-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 8px; + background: var(--accent); + color: #051014; + font-size: 14px; + font-weight: 800; +} + +.muted { + color: var(--muted); +} + +.faint { + color: var(--faint); +} + +.theme-toggle { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-soft); + color: var(--muted); + transition: + background 160ms ease, + color 160ms ease, + border-color 160ms ease; +} + +.theme-toggle:hover { + background: var(--hover); + color: var(--text); + border-color: var(--border-strong); +} + +.search-box { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + padding: 0 10px; + height: 38px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-soft); +} + +.search-box input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--text); +} + +.search-box input::placeholder { + color: var(--faint); +} + +.meta-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-top: 14px; +} + +.stat-cell { + padding: 8px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-soft); +} + +.stat-value { + font-size: 16px; + font-weight: 700; +} + +.stat-label { + margin-top: 2px; + font-size: 12px; + color: var(--faint); +} + +.session-list { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px; +} + +.session-item { + width: 100%; + text-align: left; + padding: 12px; + margin-bottom: 6px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--text); + transition: + background 140ms ease, + border-color 140ms ease; +} + +.session-item:hover { + background: var(--hover); +} + +.session-item.active { + background: var(--panel-strong); + border-color: var(--border-strong); + box-shadow: var(--shadow-soft); +} + +.session-title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 14px; + line-height: 1.35; + font-weight: 600; +} + +.session-sub { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 8px; + font-size: 12px; + color: var(--faint); +} + +.main { + display: flex; + flex-direction: column; + min-height: 0; + height: calc(100vh - 32px); +} + +.main-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.main-title { + margin: 0; + font-size: 16px; + font-weight: 700; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.main-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-soft); + color: var(--muted); + font-size: 12px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); +} + +.segmented { + display: inline-flex; + gap: 4px; + padding: 3px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-soft); +} + +.segmented button { + padding: 6px 10px; + border-radius: 6px; + background: transparent; + color: var(--muted); +} + +.segmented button.active { + background: var(--panel-strong); + color: var(--text); + box-shadow: var(--shadow-soft); +} + +.messages { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px; +} + +.empty-state { + display: grid; + place-items: center; + min-height: 100%; + color: var(--faint); + text-align: center; + padding: 24px; +} + +.message { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 10px; + margin-bottom: 16px; +} + +.message-avatar { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 8px; + font-size: 12px; + font-weight: 800; + background: var(--panel-soft); + border: 1px solid var(--border); + color: var(--muted); +} + +.message.user .message-avatar { + background: var(--accent); + color: #051014; + border-color: transparent; +} + +.message.assistant .message-avatar { + background: var(--warm); + color: #1a0f02; + border-color: transparent; +} + +.message-body { + min-width: 0; +} + +.message-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.message-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: var(--muted); +} + +.message-time { + font-size: 12px; + color: var(--faint); +} + +.message-content { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.6; + font-size: 14px; +} + +.code-block { + margin: 0; + padding: 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--code-bg); + color: var(--text); + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.55; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; +} + +.fact-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.fact-cell { + padding: 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-soft); +} + +.fact-kind { + font-size: 12px; + color: var(--faint); +} + +.fact-value { + margin-top: 4px; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-skeleton { + display: grid; + grid-template-columns: minmax(280px, 340px) 1fr; + gap: 16px; + min-height: 100vh; + padding: 16px; +} + +.skeleton-panel { + border-radius: 8px; + background: var(--panel); + border: 1px solid var(--border); + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 0.9; + } +} + +@media (max-width: 860px) { + .app-shell, + .app-skeleton { + grid-template-columns: 1fr; + padding: 10px; + } + + .sidebar, + .main { + height: auto; + position: static; + min-height: 0; + } + + .sidebar { + max-height: 46vh; + } + + .main { + min-height: 46vh; + } +} diff --git a/source/app.tsx b/source/app.tsx index 0bc43d5..772ba33 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useMemo } from 'react'; import fs from 'node:fs'; +import { resolve } from 'node:path'; import { render, Text, Box, useInput } from 'ink'; import { agent } from '@core/agent'; import { llmModel } from '@core/llm'; @@ -8,11 +9,13 @@ import Spinner from 'ink-spinner'; import { get_encoding } from 'tiktoken'; import { memoryStore } from '@agent-core/memory/memory-store'; import { + getSharedSessionStore, loadRecentFeed, loadRecentHistory, setSharedSessionStore, type RecentHistoryItem, } from '@agent-core/session/session-repository'; +import { writeWebExport } from '@web/export'; import { SessionStore } from '../packages/session-sqlite/src/index'; import { resolveUserPath } from '@tools/pathUtils'; @@ -170,6 +173,7 @@ const commandHelp = [ '/pages - 打开相关页面选择', '/memory - 查看 memory 概览', '/status - 查看当前 CLI 状态', + '/export - 导出会话数据到 GitHub Pages 客户端', '/mode - 查看当前模式和阶段', '/plan - 切换到 PLAN 模式', '/build - 切换到 BUILD 模式', @@ -463,6 +467,23 @@ const App = () => { ); return true; + case '/export': { + try { + const outputPath = + process.env.CALL_CODE_WEB_DATA ?? + resolve(process.cwd(), 'packages/client/data.json'); + const data = writeWebExport(getSharedSessionStore(), outputPath); + showCommandMessage( + `已导出 ${data.sessions.length} 个会话到 ${outputPath}`, + ); + } catch (error) { + showCommandMessage( + `导出失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return true; + } + case '/mode': showCommandMessage( `当前模式: ${state.mode.toUpperCase()}\n当前阶段: ${state.phase}`, diff --git a/source/export-web.ts b/source/export-web.ts new file mode 100644 index 0000000..cd00b6c --- /dev/null +++ b/source/export-web.ts @@ -0,0 +1,19 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DEFAULT_DB_PATH, SessionStore } from '../packages/session-sqlite/src/index'; +import { writeWebExport } from '@web/export'; + +const defaultOutput = fileURLToPath( + new URL('../packages/client/data.json', import.meta.url), +); +const outputPath = process.argv[2] ? resolve(process.argv[2]) : defaultOutput; +const store = new SessionStore({ + dbPath: process.env.SESSION_DB_PATH ?? DEFAULT_DB_PATH, +}); + +try { + const data = writeWebExport(store, outputPath); + console.log(`已导出 ${data.sessions.length} 个会话到 ${outputPath}`); +} finally { + store.close(); +} diff --git a/tests/web-export.spec.ts b/tests/web-export.spec.ts new file mode 100644 index 0000000..77bb095 --- /dev/null +++ b/tests/web-export.spec.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SessionStore } from '../packages/session-sqlite/src/index'; +import { + buildWebExport, + writeWebExport, +} from '../packages/agent-core/src/web/export'; + +describe('WebExport', () => { + const stores: SessionStore[] = []; + + const createStore = () => { + const store = new SessionStore({ dbPath: ':memory:' }); + stores.push(store); + return store; + }; + + afterEach(() => { + for (const store of stores.splice(0)) { + store.close(); + } + }); + + it('构建 GitHub Pages 客户端可读的会话快照', () => { + const store = createStore(); + store.createSession({ cwd: '/tmp/project', id: 's-web' }); + store.appendEntry('s-web', { + id: 'e1', + type: 'user', + payload: { role: 'user', content: 'hello' }, + }); + store.appendEntry('s-web', { + id: 'e2', + parentId: 'e1', + type: 'assistant', + payload: { role: 'assistant', content: 'hi', tags: ['model-response'] }, + }); + store.appendEntry('s-web', { + id: 'e3', + parentId: 'e2', + type: 'tool', + payload: { role: 'tool', content: '{"path":"/tmp/x"}', tool: 'read_file' }, + }); + store.updateStats('s-web', { + messageCount: 3, + cachedTokens: 10, + uncachedTokens: 20, + totalTokens: 30, + costTotal: 0.1, + }); + store.appendRecord('s-web', { + id: 'r1', + lane: 'run', + runId: 'run-1', + type: 'tool_call', + opKind: 'read_file', + payload: { tool: 'read_file', path: '/tmp/x' }, + }); + store.addFact('s-web', { kind: 'task-objective', key: 'objective', value: 'demo' }); + + const data = buildWebExport(store); + + expect(data.schemaVersion).toBe(1); + expect(data.sessions).toHaveLength(1); + expect(data.sessions[0].entries.map((entry) => entry.type)).toEqual([ + 'user', + 'assistant', + 'tool', + ]); + expect(data.sessions[0].entries[1].text).toBe('hi'); + expect(data.sessions[0].entries[1].tags).toEqual(['model-response']); + expect(data.sessions[0].stats.totalTokens).toBe(30); + expect(data.sessions[0].records).toHaveLength(1); + expect(data.sessions[0].facts).toHaveLength(1); + }); + + it('可以把快照写入静态 JSON 文件', () => { + const store = createStore(); + store.createSession({ cwd: '/tmp/project', id: 's-json' }); + store.appendEntry('s-json', { + id: 'e1', + type: 'user', + payload: { role: 'user', content: 'ping' }, + }); + + const dir = mkdtempSync(join(tmpdir(), 'call-code-web-export-')); + const outputPath = join(dir, 'data.json'); + + try { + const data = writeWebExport(store, outputPath); + const parsed = JSON.parse(readFileSync(outputPath, 'utf8')) as { + schemaVersion: number; + sessions: Array<{ id: string }>; + }; + + expect(data.sessions).toHaveLength(1); + expect(parsed.schemaVersion).toBe(1); + expect(parsed.sessions[0].id).toBe('s-json'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 3ee66b3..5921bd5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,11 @@ "@protocol/*": ["./packages/agent-core/src/protocol/*"] } }, - "include": ["packages/agent-core/src/**/*.ts", "source/**/*.tsx"], + "include": [ + "packages/agent-core/src/**/*.ts", + "source/**/*.ts", + "source/**/*.tsx" + ], "exclude": ["node_modules", "dist", "apps"], "composite": true } From 6f526973760561ed925a82fb5e07e48c100838a9 Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Thu, 6 Aug 2026 10:27:50 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20GitHub=20Pages?= =?UTF-8?q?=20=E4=BC=9A=E8=AF=9D=E5=8E=86=E5=8F=B2=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pages.yml | 17 +- .gitignore | 2 + README.md | 7 +- package.json | 12 +- packages/client/README.md | 26 +- packages/client/app.js | 345 -------- packages/client/index.html | 13 +- .../client/{ => public}/data.example.json | 0 packages/client/src/App.tsx | 97 +++ packages/client/src/components/MainPanel.tsx | 163 ++++ .../client/src/components/MessageItem.tsx | 56 ++ packages/client/src/components/Sidebar.tsx | 136 +++ packages/client/src/data.ts | 58 ++ packages/client/src/global.css | 38 + packages/client/src/main.tsx | 16 + packages/client/src/types.ts | 62 ++ packages/client/src/utils.ts | 90 ++ packages/client/styles.css | 519 ------------ packages/client/tsconfig.json | 22 + packages/client/vite.config.ts | 14 + pnpm-lock.yaml | 776 +++++++++++++++++- source/app.tsx | 2 +- source/export-web.ts | 2 +- tests/client-utils.spec.ts | 101 +++ 24 files changed, 1679 insertions(+), 895 deletions(-) delete mode 100644 packages/client/app.js rename packages/client/{ => public}/data.example.json (100%) create mode 100644 packages/client/src/App.tsx create mode 100644 packages/client/src/components/MainPanel.tsx create mode 100644 packages/client/src/components/MessageItem.tsx create mode 100644 packages/client/src/components/Sidebar.tsx create mode 100644 packages/client/src/data.ts create mode 100644 packages/client/src/global.css create mode 100644 packages/client/src/main.tsx create mode 100644 packages/client/src/types.ts create mode 100644 packages/client/src/utils.ts delete mode 100644 packages/client/styles.css create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/vite.config.ts create mode 100644 tests/client-utils.spec.ts diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ebe662d..c76684e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -24,13 +24,28 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build client + run: pnpm build:client + - name: Configure Pages uses: actions/configure-pages@v5 - name: Upload Packages Client uses: actions/upload-pages-artifact@v3 with: - path: packages/client + path: packages/client/dist - name: Deploy to GitHub Pages id: deployment diff --git a/.gitignore b/.gitignore index 90c2bb3..cdcefa7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ out .remix-serve-node_modules .env/* .agent-sessions/ +.pnpm-store/ +packages/client/dist/ *.tsbuildinfo bundle.* docs/* diff --git a/README.md b/README.md index 149f9c0..9176dd2 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ packages/ tools/ # 环境、文件、命令等本地工具 policy/ # PLAN/BUILD 模式下的工具权限 web/ # GitHub Pages 客户端数据导出 - client/ # 静态会话历史界面,可部署到 GitHub Pages + client/ # TypeScript + React 会话历史界面,可部署到 GitHub Pages tests/ # 项目统一单元测试 vitest.config.ts # Vitest 测试配置 ``` @@ -106,8 +106,11 @@ pnpm exec vitest run --reporter verbose # 构建 agent-core pnpm run build:agent-core -# 导出会话历史到 packages/client/data.json +# 导出会话历史到 packages/client/public/data.json pnpm export:web + +# 构建 GitHub Pages 客户端 +pnpm build:client ``` ## 测试说明 diff --git a/package.json b/package.json index 418bea9..3d9cdf6 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,13 @@ "dev": "tsx source/app.tsx", "start": "tsx source/app.tsx", "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck:client": "tsc -p packages/client/tsconfig.json --noEmit", "test": "vitest run --reporter verbose", "build:agent-core": "tsc -p packages/agent-core/tsconfig.json", - "export:web": "tsx source/export-web.ts" + "export:web": "tsx source/export-web.ts", + "dev:client": "vite --config packages/client/vite.config.ts", + "build:client": "vite build --config packages/client/vite.config.ts", + "preview:client": "vite preview --config packages/client/vite.config.ts" }, "dependencies": { "dotenv": "^17.4.2", @@ -18,14 +22,20 @@ "ink-text-input": "^6.0.0", "openai": "^6.34.0", "react": "^19.2.8", + "react-dom": "^19.2.8", "tiktoken": "^1.0.20" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@types/node": "^25.6.0", "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", "prettier": "^3.4.2", + "tailwindcss": "^4.3.3", "tsx": "^4.23.7", "typescript": "^6.0.3", + "vite": "^8.2.0", "vitest": "^3.2.4" } } diff --git a/packages/client/README.md b/packages/client/README.md index b4efece..854e344 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -1,6 +1,20 @@ # Call Code History Client -这是 CLI 会话历史的静态展示端,可直接部署到 GitHub Pages。页面读取同目录下的 `data.json`,如果不存在则回退到 `data.example.json` 预览数据。 +这是 CLI 会话历史的静态展示端,基于 TypeScript、React 和 Tailwind CSS 构建,可直接部署到 GitHub Pages。页面默认读取 `data.json`,如果不存在则回退到 `data.example.json` 预览数据;也可以用 `?data=` 指定数据文件,用 `?session=` 直达某个会话。 + +## 构建 + +```bash +pnpm build:client +``` + +产物输出到 `packages/client/dist`,GitHub Actions 会构建后上传该目录。 + +## 本地开发 + +```bash +pnpm dev:client +``` ## 导出数据 @@ -10,18 +24,18 @@ pnpm export:web ``` -脚本会把当前 `SESSION_DB_PATH` 指向的会话数据库导出为 `packages/client/data.json`。也可以在 CLI 中执行 `/export` 完成同样操作。 +脚本会把当前 `SESSION_DB_PATH` 指向的会话数据库导出为 `packages/client/public/data.json`。也可以在 CLI 中执行 `/export` 完成同样操作。 导出文件包含会话、消息、工具调用、统计和事实数据。发布到公开 GitHub Pages 前请先检查内容是否包含不该公开的路径、命令输出或密钥。 ## 本地预览 +预览构建产物: + ```bash -python3 -m http.server 4173 --directory packages/client +pnpm preview:client ``` -然后打开 [http://localhost:4173](http://localhost:4173)。 - ## 主题 -页面内置白色毛玻璃和高级黑两套主题,右上角按钮可切换,选择会保存在浏览器本地。 +页面内置白色毛玻璃和高级黑两套主题,侧边栏顶部的分段按钮可切换,选择会保存在浏览器本地。 diff --git a/packages/client/app.js b/packages/client/app.js deleted file mode 100644 index d0ee3c7..0000000 --- a/packages/client/app.js +++ /dev/null @@ -1,345 +0,0 @@ -const DATA_URLS = ['./data.json', './data.example.json']; -const THEME_KEY = 'call-code-theme'; - -const state = { - data: null, - sessions: [], - activeId: null, - query: '', - filter: 'all', - theme: localStorage.getItem(THEME_KEY) || 'dark', -}; - -const app = document.getElementById('app'); - -const applyTheme = () => { - document.documentElement.dataset.theme = state.theme; -}; - -const toggleTheme = () => { - state.theme = state.theme === 'dark' ? 'light' : 'dark'; - localStorage.setItem(THEME_KEY, state.theme); - applyTheme(); - render(); -}; - -const createElement = (tag, className, text) => { - const element = document.createElement(tag); - if (className) { - element.className = className; - } - if (text !== undefined && text !== null) { - element.textContent = String(text); - } - return element; -}; - -const formatTime = (value) => { - if (!value) { - return ''; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return ''; - } - return date.toLocaleString('zh-CN', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -}; - -const entryText = (entry) => { - if (entry.text) { - return entry.text; - } - if (entry.payload && typeof entry.payload === 'object' && 'content' in entry.payload) { - const content = entry.payload.content; - if (typeof content === 'string') { - return content; - } - } - return ''; -}; - -const entryRole = (entry) => entry.role || entry.type || 'assistant'; - -const getSessionTitle = (session) => { - const firstUser = session.entries.find((entry) => entryRole(entry) === 'user'); - if (firstUser) { - const text = entryText(firstUser).trim(); - if (text) { - return text; - } - } - const objective = session.metadata?.objective; - if (typeof objective === 'string' && objective.trim()) { - return objective; - } - return session.id; -}; - -const getSessionMode = (session) => { - const mode = session.metadata?.mode; - return typeof mode === 'string' ? mode : 'build'; -}; - -const getEntryCount = (session, role) => - role ? session.entries.filter((entry) => entryRole(entry) === role).length : session.entries.length; - -const matchesQuery = (session) => { - const query = state.query.trim().toLowerCase(); - if (!query) { - return true; - } - const haystack = [session.id, session.cwd, getSessionTitle(session)].map((value) => value.toLowerCase()); - for (const entry of session.entries) { - haystack.push(entryText(entry).toLowerCase()); - if (entry.tool) { - haystack.push(entry.tool.toLowerCase()); - } - } - return haystack.some((value) => value.includes(query)); -}; - -const filteredSessions = () => - state.sessions.filter((session) => matchesQuery(session)); - -const activeSession = () => { - const sessions = filteredSessions(); - return sessions.find((session) => session.id === state.activeId) || sessions[0] || null; -}; - -const filteredEntries = (session) => { - if (state.filter === 'all') { - return session.entries; - } - return session.entries.filter((entry) => entryRole(entry) === state.filter); -}; - -const renderStats = (sessions) => ({ - sessions: sessions.length, - messages: sessions.reduce((sum, session) => sum + session.entries.length, 0), - tools: sessions.reduce( - (sum, session) => sum + session.entries.filter((entry) => entryRole(entry) === 'tool').length, - 0, - ), -}); - -const renderSidebar = (sessions) => { - const sidebar = createElement('aside', 'panel sidebar'); - - const header = createElement('div', 'sidebar-header'); - const brandRow = createElement('div', 'brand-row'); - const brand = createElement('div', 'brand'); - brand.append(createElement('span', 'brand-mark', 'CC')); - const brandText = createElement('span', 'muted', 'Call Code'); - brand.append(brandText); - - const themeButton = createElement('button', 'theme-toggle', state.theme === 'dark' ? '☀' : '◐'); - themeButton.setAttribute('aria-label', state.theme === 'dark' ? '切换到白色毛玻璃主题' : '切换到高级黑主题'); - themeButton.addEventListener('click', toggleTheme); - brandRow.append(brand, themeButton); - - const searchBox = createElement('div', 'search-box'); - const searchIcon = createElement('span', 'faint', '⌕'); - const searchInput = createElement('input'); - searchInput.type = 'search'; - searchInput.placeholder = '搜索会话'; - searchInput.value = state.query; - searchInput.setAttribute('aria-label', '搜索会话'); - searchInput.addEventListener('input', (event) => { - state.query = event.target.value; - render(); - }); - searchBox.append(searchIcon, searchInput); - - const stats = renderStats(sessions); - const metaStats = createElement('div', 'meta-stats'); - const statDefs = [ - ['会话', stats.sessions], - ['消息', stats.messages], - ['工具', stats.tools], - ]; - for (const [label, value] of statDefs) { - const cell = createElement('div', 'stat-cell'); - cell.append(createElement('div', 'stat-value', value)); - cell.append(createElement('div', 'stat-label', label)); - metaStats.append(cell); - } - - header.append(brandRow, searchBox, metaStats); - sidebar.append(header); - - const list = createElement('div', 'session-list'); - if (sessions.length === 0) { - list.append(createElement('div', 'empty-state', '暂无会话')); - } else { - for (const session of sessions) { - const button = createElement('button', 'session-item'); - if (session.id === state.activeId) { - button.classList.add('active'); - } - button.append(createElement('div', 'session-title', getSessionTitle(session))); - const sub = createElement('div', 'session-sub'); - const meta = createElement('span', '', `${getSessionMode(session).toUpperCase()} · ${session.entries.length} 条`); - const time = createElement('span', '', formatTime(session.createdAt)); - sub.append(meta, time); - button.append(sub); - button.addEventListener('click', () => { - state.activeId = session.id; - render(); - }); - list.append(button); - } - } - - sidebar.append(list); - return sidebar; -}; - -const renderMessage = (entry) => { - const role = entryRole(entry); - const message = createElement('div', `message ${role}`); - const avatar = createElement('div', 'message-avatar', role === 'user' ? 'U' : role === 'assistant' ? 'A' : 'T'); - message.append(avatar); - - const body = createElement('div', 'message-body'); - const head = createElement('div', 'message-head'); - const label = createElement('div', 'message-label'); - label.append(createElement('span', '', role === 'user' ? '用户' : role === 'assistant' ? '助手' : '工具')); - if (entry.tool) { - label.append(createElement('span', 'pill', entry.tool)); - } - head.append(label, createElement('div', 'message-time', formatTime(entry.timestamp))); - body.append(head); - - const text = entryText(entry); - if (role === 'tool' || text.length > 320) { - body.append(createElement('pre', 'code-block', text)); - } else { - body.append(createElement('div', 'message-content', text)); - } - - message.append(body); - return message; -}; - -const renderFacts = (session) => { - if (!Array.isArray(session.facts) || session.facts.length === 0) { - return null; - } - const grid = createElement('div', 'fact-grid'); - for (const fact of session.facts.slice(0, 12)) { - const cell = createElement('div', 'fact-cell'); - cell.append(createElement('div', 'fact-kind', fact.kind)); - cell.append(createElement('div', 'fact-value', fact.value || fact.key || String(fact.seq))); - grid.append(cell); - } - return grid; -}; - -const renderMain = (session) => { - const main = createElement('main', 'panel main'); - - const header = createElement('div', 'main-header'); - const titleWrap = createElement('div'); - titleWrap.append(createElement('h1', 'main-title', session ? getSessionTitle(session) : 'Call Code')); - const meta = createElement('div', 'main-meta'); - if (session) { - const stats = session.stats || {}; - const pills = [ - getSessionMode(session).toUpperCase(), - `${session.entries.length} 条消息`, - `tokens ${stats.totalTokens ?? 0}`, - session.cwd, - ]; - for (const pill of pills) { - meta.append(createElement('span', 'pill', pill)); - } - } - titleWrap.append(meta); - header.append(titleWrap); - - const toolbar = createElement('div', 'toolbar'); - const segmented = createElement('div', 'segmented'); - const filters = [ - ['all', '全部'], - ['user', '用户'], - ['assistant', '助手'], - ['tool', '工具'], - ]; - for (const [value, label] of filters) { - const button = createElement('button', state.filter === value ? 'active' : '', label); - button.addEventListener('click', () => { - state.filter = value; - render(); - }); - segmented.append(button); - } - toolbar.append(segmented); - - const messages = createElement('div', 'messages'); - if (!session) { - messages.append(createElement('div', 'empty-state', '暂无会话数据')); - } else { - const entries = filteredEntries(session); - if (entries.length === 0) { - messages.append(createElement('div', 'empty-state', '没有匹配的消息')); - } else { - for (const entry of entries) { - messages.append(renderMessage(entry)); - } - const facts = renderFacts(session); - if (facts) { - messages.append(facts); - } - } - } - - main.append(header, toolbar, messages); - return main; -}; - -const render = () => { - app.className = 'app-shell'; - app.innerHTML = ''; - const sessions = filteredSessions(); - const session = activeSession(); - app.append(renderSidebar(sessions), renderMain(session)); -}; - -const renderLoadError = () => { - app.className = 'app-shell'; - app.innerHTML = ''; - const panel = createElement('div', 'panel main'); - const empty = createElement('div', 'empty-state', '无法读取会话数据'); - panel.append(empty); - app.append(createElement('aside', 'panel sidebar', 'Call Code'), panel); -}; - -const init = async () => { - applyTheme(); - for (const url of DATA_URLS) { - try { - const response = await fetch(url, { cache: 'no-cache' }); - if (!response.ok) { - continue; - } - const data = await response.json(); - if (data && Array.isArray(data.sessions)) { - state.data = data; - state.sessions = data.sessions; - state.activeId = data.sessions[0]?.id || null; - render(); - return; - } - } catch { - // 继续尝试下一个数据源 - } - } - renderLoadError(); -}; - -init(); diff --git a/packages/client/index.html b/packages/client/index.html index a0874d2..3ab6ba1 100644 --- a/packages/client/index.html +++ b/packages/client/index.html @@ -1,17 +1,12 @@ - - - + + Call Code History - -
-
-
-
- +
+ diff --git a/packages/client/data.example.json b/packages/client/public/data.example.json similarity index 100% rename from packages/client/data.example.json rename to packages/client/public/data.example.json diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx new file mode 100644 index 0000000..0f7f962 --- /dev/null +++ b/packages/client/src/App.tsx @@ -0,0 +1,97 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Sidebar } from './components/Sidebar'; +import { MainPanel } from './components/MainPanel'; +import { loadWebExport } from './data'; +import type { Filter, Theme, WebExport } from './types'; +import { filterSessions } from './utils'; + +const THEME_KEY = 'call-code-theme'; + +const initialTheme = (): Theme => { + const stored = localStorage.getItem(THEME_KEY); + return stored === 'light' ? 'light' : 'dark'; +}; + +export default function App() { + const [data, setData] = useState(null); + const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading'); + const [theme, setTheme] = useState(initialTheme); + const [activeId, setActiveId] = useState(null); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + + useEffect(() => { + let cancelled = false; + + loadWebExport().then((result) => { + if (cancelled) { + return; + } + + setData(result); + const requestedId = new URLSearchParams(window.location.search).get('session'); + setActiveId( + requestedId && result?.sessions.some((session) => session.id === requestedId) + ? requestedId + : (result?.sessions[0]?.id ?? null), + ); + setLoadState(result ? 'ready' : 'error'); + }); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const root = document.documentElement; + root.classList.toggle('dark', theme === 'dark'); + localStorage.setItem(THEME_KEY, theme); + }, [theme]); + + const sessions = useMemo(() => data?.sessions ?? [], [data]); + const filteredSessions = useMemo( + () => filterSessions(sessions, query), + [sessions, query], + ); + const activeSession = useMemo( + () => + filteredSessions.find((session) => session.id === activeId) ?? + filteredSessions[0] ?? + null, + [filteredSessions, activeId], + ); + + if (loadState === 'loading') { + return ( +
+ 正在读取会话 +
+ ); + } + + if (loadState === 'error' || !data) { + return ( +
+ 无法读取会话数据 +
+ ); + } + + return ( +
+
+ + +
+
+ ); +} diff --git a/packages/client/src/components/MainPanel.tsx b/packages/client/src/components/MainPanel.tsx new file mode 100644 index 0000000..ddab0c5 --- /dev/null +++ b/packages/client/src/components/MainPanel.tsx @@ -0,0 +1,163 @@ +import type { Filter, WebFact, WebRecord, WebSession } from '../types'; +import { filterEntries, formatTime, getSessionMode, getSessionTitle } from '../utils'; +import { MessageItem } from './MessageItem'; + +interface MainPanelProps { + session: WebSession | null; + filter: Filter; + onFilterChange: (filter: Filter) => void; +} + +const filters: Array<{ value: Filter; label: string }> = [ + { value: 'all', label: '全部' }, + { value: 'user', label: '用户' }, + { value: 'assistant', label: '助手' }, + { value: 'tool', label: '工具' }, +]; + +const glassPanel = + 'flex min-h-0 flex-col overflow-hidden rounded-lg border border-black/10 bg-white/70 shadow-[0_18px_50px_rgba(15,23,42,0.12)] backdrop-blur-2xl lg:h-[calc(100vh-2rem)] dark:border-white/10 dark:bg-[#0d1116]/75 dark:shadow-[0_18px_60px_rgba(0,0,0,0.42)]'; + +function FactGrid({ facts }: { facts: WebFact[] }) { + if (facts.length === 0) { + return null; + } + + return ( +
+ {facts.slice(0, 12).map((fact, index) => ( +
+
{fact.kind}
+
+ {fact.value || fact.key || String(fact.seq)} +
+
+ ))} +
+ ); +} + +const payloadText = (payload: unknown): string => { + if (typeof payload === 'string') { + return payload; + } + if (payload && typeof payload === 'object') { + return JSON.stringify(payload); + } + return String(payload ?? ''); +}; + +function RecordGrid({ records }: { records: WebRecord[] }) { + if (records.length === 0) { + return null; + } + + return ( +
+
+ 运行记录 ({records.length}) +
+
+ {records + .slice(-20) + .reverse() + .map((record) => ( +
+
+ + {record.type} + {record.opKind ? ` / ${record.opKind}` : ''} + + {formatTime(record.timestamp)} +
+
+ {payloadText(record.payload)} +
+
+ ))} +
+
+ ); +} + +export function MainPanel({ session, filter, onFilterChange }: MainPanelProps) { + const entries = session ? filterEntries(session.entries, filter) : []; + + return ( +
+
+
+

+ {session ? getSessionTitle(session) : 'Call Code'} +

+
+ {session ? ( + <> + + {getSessionMode(session).toUpperCase()} + + + {session.entries.length} 条消息 + + + tokens {session.stats?.totalTokens ?? 0} + + + 成本 {session.stats?.costTotal ?? 0} + + + {session.cwd} + + + ) : null} +
+
+
+ +
+
+ {filters.map((item) => ( + + ))} +
+
+ +
+ {!session ? ( +
+ 暂无会话数据 +
+ ) : entries.length === 0 ? ( +
+ 没有匹配的消息 +
+ ) : ( +
+ {entries.map((entry) => ( + + ))} + {session.facts ? : null} + {session.records ? : null} +
+ )} +
+
+ ); +} diff --git a/packages/client/src/components/MessageItem.tsx b/packages/client/src/components/MessageItem.tsx new file mode 100644 index 0000000..79c65f1 --- /dev/null +++ b/packages/client/src/components/MessageItem.tsx @@ -0,0 +1,56 @@ +import type { WebEntry } from '../types'; +import { entryRole, entryText, formatTime } from '../utils'; + +interface MessageItemProps { + entry: WebEntry; +} + +const roleMeta = { + user: { avatar: 'U', label: '用户', className: 'bg-teal-600 text-white dark:bg-cyan-400 dark:text-cyan-950' }, + assistant: { avatar: 'A', label: '助手', className: 'bg-amber-600 text-white dark:bg-amber-400 dark:text-amber-950' }, + tool: { avatar: 'T', label: '工具', className: 'bg-slate-200 text-slate-600 dark:bg-white/10 dark:text-slate-300' }, + system: { avatar: 'S', label: '系统', className: 'bg-slate-200 text-slate-600 dark:bg-white/10 dark:text-slate-300' }, +} as const; + +export function MessageItem({ entry }: MessageItemProps) { + const role = entryRole(entry); + const meta = roleMeta[role]; + const text = entryText(entry); + const isCode = role === 'tool' || text.length > 320; + + return ( +
+
+ {meta.avatar} +
+ +
+
+
+ {meta.label} + {entry.tool ? ( + + {entry.tool} + + ) : null} +
+
+ {formatTime(entry.timestamp)} +
+
+ + {isCode ? ( +
+            {text}
+          
+ ) : ( +
+ {text} +
+ )} +
+
+ ); +} diff --git a/packages/client/src/components/Sidebar.tsx b/packages/client/src/components/Sidebar.tsx new file mode 100644 index 0000000..7f7a1b0 --- /dev/null +++ b/packages/client/src/components/Sidebar.tsx @@ -0,0 +1,136 @@ +import { useMemo } from 'react'; +import type { Theme, WebSession } from '../types'; +import { entryRole, formatTime, getSessionMode, getSessionTitle } from '../utils'; + +interface SidebarProps { + sessions: WebSession[]; + activeId: string | null; + query: string; + theme: Theme; + onSelect: (id: string) => void; + onQueryChange: (query: string) => void; + onThemeChange: (theme: Theme) => void; +} + +const glassPanel = + 'flex max-h-[46vh] min-h-0 flex-col overflow-hidden rounded-lg border border-black/10 bg-white/70 shadow-[0_18px_50px_rgba(15,23,42,0.12)] backdrop-blur-2xl lg:sticky lg:top-4 lg:h-[calc(100vh-2rem)] lg:max-h-none dark:border-white/10 dark:bg-[#0d1116]/75 dark:shadow-[0_18px_60px_rgba(0,0,0,0.42)]'; + +export function Sidebar({ + sessions, + activeId, + query, + theme, + onSelect, + onQueryChange, + onThemeChange, +}: SidebarProps) { + const stats = useMemo(() => { + const messages = sessions.reduce((sum, session) => sum + session.entries.length, 0); + const tools = sessions.reduce( + (sum, session) => + sum + session.entries.filter((entry) => entryRole(entry) === 'tool').length, + 0, + ); + return { sessions: sessions.length, messages, tools }; + }, [sessions]); + + const statDefs = [ + ['会话', stats.sessions], + ['消息', stats.messages], + ['工具', stats.tools], + ] as const; + + return ( + + ); +} diff --git a/packages/client/src/data.ts b/packages/client/src/data.ts new file mode 100644 index 0000000..e300181 --- /dev/null +++ b/packages/client/src/data.ts @@ -0,0 +1,58 @@ +import type { WebExport, WebSession } from './types'; + +const DEFAULT_DATA_URLS = ['./data.json', './data.example.json']; + +const resolveDataUrls = (): string[] => { + const params = new URLSearchParams(window.location.search); + const override = params.get('data') || params.get('dataUrl'); + return override ? [override, ...DEFAULT_DATA_URLS] : DEFAULT_DATA_URLS; +}; + +const isWebSession = (value: unknown): value is WebSession => { + if (!value || typeof value !== 'object') { + return false; + } + + const session = value as Partial; + return ( + typeof session.id === 'string' && + typeof session.createdAt === 'string' && + typeof session.cwd === 'string' && + Array.isArray(session.entries) + ); +}; + +const isWebExport = (value: unknown): value is WebExport => { + if (!value || typeof value !== 'object') { + return false; + } + + const data = value as Partial; + return ( + data.schemaVersion === 1 && + typeof data.exportedAt === 'string' && + Array.isArray(data.sessions) && + data.sessions.every(isWebSession) + ); +}; + +export const loadWebExport = async (): Promise => { + for (const url of resolveDataUrls()) { + try { + const target = new URL(url, window.location.href); + const response = await fetch(target, { cache: 'no-cache' }); + if (!response.ok) { + continue; + } + + const value: unknown = await response.json(); + if (isWebExport(value)) { + return value; + } + } catch { + // 继续尝试下一个数据源 + } + } + + return null; +}; diff --git a/packages/client/src/global.css b/packages/client/src/global.css new file mode 100644 index 0000000..fe85281 --- /dev/null +++ b/packages/client/src/global.css @@ -0,0 +1,38 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +:root { + color-scheme: light; + --font-ui: + "Avenir Next", + "PingFang SC", + "Hiragino Sans GB", + "Source Han Sans SC", + "Microsoft YaHei", + sans-serif; +} + +:root.dark { + color-scheme: dark; +} + +html { + font-family: var(--font-ui); + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +* { + scrollbar-width: thin; + scrollbar-color: rgba(100, 116, 139, 0.35) transparent; +} + +:root.dark * { + scrollbar-color: rgba(148, 163, 184, 0.28) transparent; +} diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx new file mode 100644 index 0000000..08bce3f --- /dev/null +++ b/packages/client/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './global.css'; + +const container = document.getElementById('root'); + +if (!container) { + throw new Error('未找到 #root 容器'); +} + +createRoot(container).render( + + + , +); diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts new file mode 100644 index 0000000..02c4453 --- /dev/null +++ b/packages/client/src/types.ts @@ -0,0 +1,62 @@ +export type Theme = 'light' | 'dark'; + +export type EntryRole = 'user' | 'assistant' | 'tool' | 'system'; + +export type Filter = 'all' | EntryRole; + +export interface WebEntry { + seq: number; + id: string; + parentId: string | null; + type: string; + role: string; + timestamp: string; + text?: string; + tool?: string | null; + tags?: string[]; + payload: unknown; +} + +export interface WebRecord { + seq: number; + id: string; + lane: string; + runId: string | null; + type: string; + opKind: string | null; + timestamp: string; + payload: unknown; +} + +export interface WebFact { + seq: number; + kind: string; + key: string | null; + value: string | null; +} + +export interface WebStats { + messageCount: number; + cachedTokens: number; + uncachedTokens: number; + totalTokens: number; + costTotal: number; +} + +export interface WebSession { + id: string; + createdAt: string; + cwd: string; + parentSessionId: string | null; + metadata: Record; + stats: WebStats; + entries: WebEntry[]; + records?: WebRecord[]; + facts?: WebFact[]; +} + +export interface WebExport { + schemaVersion: 1; + exportedAt: string; + sessions: WebSession[]; +} diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts new file mode 100644 index 0000000..044825b --- /dev/null +++ b/packages/client/src/utils.ts @@ -0,0 +1,90 @@ +import type { EntryRole, Filter, WebEntry, WebSession } from './types'; + +export const entryRole = (entry: WebEntry): EntryRole => { + const role = entry.role || entry.type; + if (role === 'user' || role === 'assistant' || role === 'tool' || role === 'system') { + return role; + } + return 'assistant'; +}; + +export const entryText = (entry: WebEntry): string => { + if (entry.text) { + return entry.text; + } + + if (entry.payload && typeof entry.payload === 'object' && 'content' in entry.payload) { + const content = (entry.payload as { content?: unknown }).content; + if (typeof content === 'string') { + return content; + } + } + + return ''; +}; + +export const getSessionTitle = (session: WebSession): string => { + const firstUser = session.entries.find((entry) => entryRole(entry) === 'user'); + if (firstUser) { + const text = entryText(firstUser).trim(); + if (text) { + return text; + } + } + + const objective = session.metadata?.objective; + if (typeof objective === 'string' && objective.trim()) { + return objective; + } + + return session.id; +}; + +export const getSessionMode = (session: WebSession): string => { + const mode = session.metadata?.mode; + return typeof mode === 'string' ? mode : 'build'; +}; + +export const formatTime = (value: string): string => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + + return new Intl.DateTimeFormat('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date); +}; + +export const matchesQuery = (session: WebSession, query: string): boolean => { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return true; + } + + const haystack = [session.id, session.cwd, getSessionTitle(session)].map((value) => + value.toLowerCase(), + ); + + for (const entry of session.entries) { + haystack.push(entryText(entry).toLowerCase()); + if (entry.tool) { + haystack.push(entry.tool.toLowerCase()); + } + } + + return haystack.some((value) => value.includes(normalized)); +}; + +export const filterSessions = (sessions: WebSession[], query: string): WebSession[] => + sessions.filter((session) => matchesQuery(session, query)); + +export const filterEntries = (entries: WebEntry[], filter: Filter): WebEntry[] => { + if (filter === 'all') { + return entries; + } + return entries.filter((entry) => entryRole(entry) === filter); +}; diff --git a/packages/client/styles.css b/packages/client/styles.css deleted file mode 100644 index 42fe608..0000000 --- a/packages/client/styles.css +++ /dev/null @@ -1,519 +0,0 @@ -:root { - color-scheme: light dark; - font-family: - ui-sans-serif, - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; - letter-spacing: 0; -} - -:root[data-theme="light"] { - --bg: #eef3f7; - --bg-deep: #fbfdff; - --panel: rgba(255, 255, 255, 0.72); - --panel-strong: rgba(255, 255, 255, 0.9); - --panel-soft: rgba(255, 255, 255, 0.46); - --border: rgba(15, 23, 42, 0.1); - --border-strong: rgba(15, 23, 42, 0.16); - --text: #17212b; - --muted: #5b6775; - --faint: #8a95a2; - --accent: #0d9488; - --accent-strong: #0f766e; - --warm: #b45309; - --code-bg: rgba(15, 23, 42, 0.06); - --shadow: 0 18px 50px rgba(15, 23, 42, 0.12); - --shadow-soft: 0 8px 24px rgba(15, 23, 42, 0.08); - --glass-blur: blur(18px) saturate(160%); - --hover: rgba(255, 255, 255, 0.72); -} - -:root[data-theme="dark"] { - --bg: #05070a; - --bg-deep: #0a0d11; - --panel: rgba(13, 17, 22, 0.76); - --panel-strong: rgba(16, 21, 27, 0.92); - --panel-soft: rgba(255, 255, 255, 0.04); - --border: rgba(255, 255, 255, 0.08); - --border-strong: rgba(255, 255, 255, 0.14); - --text: #e8eef4; - --muted: #9aa7b4; - --faint: #67727e; - --accent: #22d3ee; - --accent-strong: #67e8f9; - --warm: #fbbf24; - --code-bg: rgba(0, 0, 0, 0.34); - --shadow: 0 18px 60px rgba(0, 0, 0, 0.42); - --shadow-soft: 0 8px 28px rgba(0, 0, 0, 0.3); - --glass-blur: blur(22px) saturate(130%); - --hover: rgba(255, 255, 255, 0.06); -} - -* { - box-sizing: border-box; -} - -html, -body { - min-height: 100%; - margin: 0; -} - -body { - background: - linear-gradient(145deg, var(--bg-deep), var(--bg)); - color: var(--text); - min-height: 100vh; - -webkit-font-smoothing: antialiased; -} - -button, -input { - font: inherit; - color: inherit; -} - -button { - border: 0; - cursor: pointer; -} - -.app-shell { - display: grid; - grid-template-columns: minmax(280px, 340px) 1fr; - gap: 16px; - min-height: 100vh; - padding: 16px; -} - -.panel { - background: var(--panel); - border: 1px solid var(--border); - box-shadow: var(--shadow); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border-radius: 8px; - overflow: hidden; -} - -.sidebar { - display: flex; - flex-direction: column; - min-height: 0; - height: calc(100vh - 32px); - position: sticky; - top: 16px; -} - -.sidebar-header { - padding: 16px; - border-bottom: 1px solid var(--border); -} - -.brand-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.brand { - display: flex; - align-items: center; - gap: 10px; - font-size: 18px; - font-weight: 700; -} - -.brand-mark { - display: grid; - place-items: center; - width: 30px; - height: 30px; - border-radius: 8px; - background: var(--accent); - color: #051014; - font-size: 14px; - font-weight: 800; -} - -.muted { - color: var(--muted); -} - -.faint { - color: var(--faint); -} - -.theme-toggle { - display: inline-grid; - place-items: center; - width: 34px; - height: 34px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--panel-soft); - color: var(--muted); - transition: - background 160ms ease, - color 160ms ease, - border-color 160ms ease; -} - -.theme-toggle:hover { - background: var(--hover); - color: var(--text); - border-color: var(--border-strong); -} - -.search-box { - display: flex; - align-items: center; - gap: 8px; - margin-top: 14px; - padding: 0 10px; - height: 38px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--panel-soft); -} - -.search-box input { - flex: 1; - min-width: 0; - border: 0; - outline: 0; - background: transparent; - color: var(--text); -} - -.search-box input::placeholder { - color: var(--faint); -} - -.meta-stats { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - margin-top: 14px; -} - -.stat-cell { - padding: 8px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--panel-soft); -} - -.stat-value { - font-size: 16px; - font-weight: 700; -} - -.stat-label { - margin-top: 2px; - font-size: 12px; - color: var(--faint); -} - -.session-list { - flex: 1; - min-height: 0; - overflow: auto; - padding: 8px; -} - -.session-item { - width: 100%; - text-align: left; - padding: 12px; - margin-bottom: 6px; - border-radius: 8px; - border: 1px solid transparent; - background: transparent; - color: var(--text); - transition: - background 140ms ease, - border-color 140ms ease; -} - -.session-item:hover { - background: var(--hover); -} - -.session-item.active { - background: var(--panel-strong); - border-color: var(--border-strong); - box-shadow: var(--shadow-soft); -} - -.session-title { - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - font-size: 14px; - line-height: 1.35; - font-weight: 600; -} - -.session-sub { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-top: 8px; - font-size: 12px; - color: var(--faint); -} - -.main { - display: flex; - flex-direction: column; - min-height: 0; - height: calc(100vh - 32px); -} - -.main-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - padding: 16px; - border-bottom: 1px solid var(--border); -} - -.main-title { - margin: 0; - font-size: 16px; - font-weight: 700; - line-height: 1.35; - overflow-wrap: anywhere; -} - -.main-meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 8px; -} - -.pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--panel-soft); - color: var(--muted); - font-size: 12px; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.toolbar { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 16px; - border-bottom: 1px solid var(--border); -} - -.segmented { - display: inline-flex; - gap: 4px; - padding: 3px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--panel-soft); -} - -.segmented button { - padding: 6px 10px; - border-radius: 6px; - background: transparent; - color: var(--muted); -} - -.segmented button.active { - background: var(--panel-strong); - color: var(--text); - box-shadow: var(--shadow-soft); -} - -.messages { - flex: 1; - min-height: 0; - overflow: auto; - padding: 16px; -} - -.empty-state { - display: grid; - place-items: center; - min-height: 100%; - color: var(--faint); - text-align: center; - padding: 24px; -} - -.message { - display: grid; - grid-template-columns: 34px minmax(0, 1fr); - gap: 10px; - margin-bottom: 16px; -} - -.message-avatar { - display: grid; - place-items: center; - width: 34px; - height: 34px; - border-radius: 8px; - font-size: 12px; - font-weight: 800; - background: var(--panel-soft); - border: 1px solid var(--border); - color: var(--muted); -} - -.message.user .message-avatar { - background: var(--accent); - color: #051014; - border-color: transparent; -} - -.message.assistant .message-avatar { - background: var(--warm); - color: #1a0f02; - border-color: transparent; -} - -.message-body { - min-width: 0; -} - -.message-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 6px; -} - -.message-label { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 700; - color: var(--muted); -} - -.message-time { - font-size: 12px; - color: var(--faint); -} - -.message-content { - white-space: pre-wrap; - overflow-wrap: anywhere; - line-height: 1.6; - font-size: 14px; -} - -.code-block { - margin: 0; - padding: 12px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--code-bg); - color: var(--text); - overflow: auto; - white-space: pre-wrap; - overflow-wrap: anywhere; - line-height: 1.55; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 13px; -} - -.fact-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 8px; - margin-top: 12px; -} - -.fact-cell { - padding: 10px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--panel-soft); -} - -.fact-kind { - font-size: 12px; - color: var(--faint); -} - -.fact-value { - margin-top: 4px; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.app-skeleton { - display: grid; - grid-template-columns: minmax(280px, 340px) 1fr; - gap: 16px; - min-height: 100vh; - padding: 16px; -} - -.skeleton-panel { - border-radius: 8px; - background: var(--panel); - border: 1px solid var(--border); - animation: pulse 1.4s ease-in-out infinite; -} - -@keyframes pulse { - 0%, - 100% { - opacity: 0.55; - } - 50% { - opacity: 0.9; - } -} - -@media (max-width: 860px) { - .app-shell, - .app-skeleton { - grid-template-columns: 1fr; - padding: 10px; - } - - .sidebar, - .main { - height: auto; - position: static; - min-height: 0; - } - - .sidebar { - max-height: 46vh; - } - - .main { - min-height: 46vh; - } -} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..6c35f5c --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts new file mode 100644 index 0000000..35dde7f --- /dev/null +++ b/packages/client/vite.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url'; +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + plugins: [react(), tailwindcss()], + base: './', + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 034182b..981ad98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,28 +26,46 @@ importers: react: specifier: ^19.2.8 version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) tiktoken: specifier: ^1.0.20 version: 1.0.22 devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.7)) '@types/node': specifier: ^25.6.0 version: 25.9.5 '@types/react': specifier: ^19.2.18 version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.0.5 + version: 6.0.5(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.7)) prettier: specifier: ^3.4.2 version: 3.9.6 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 tsx: specifier: ^4.23.7 version: 4.23.7 typescript: specifier: ^6.0.3 version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.7) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@25.9.5)(tsx@4.23.7) + version: 3.2.7(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7) packages: @@ -211,9 +229,22 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -221,6 +252,102 @@ packages: os: [linux] libc: [glibc] + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.62.4': resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] @@ -359,6 +486,100 @@ packages: cpu: [x64] os: [win32] + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -371,9 +592,27 @@ packages: '@types/node@25.9.5': resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@vitejs/plugin-react@6.0.5': + resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + '@vitest/expect@3.2.7': resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} @@ -479,10 +718,18 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -527,6 +774,9 @@ packages: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} @@ -567,9 +817,161 @@ packages: engines: {node: '>=20'} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -639,6 +1041,11 @@ packages: engines: {node: '>=14'} hasBin: true + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} @@ -653,6 +1060,11 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.62.4: resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -700,6 +1112,13 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + terminal-size@4.0.1: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} @@ -795,6 +1214,49 @@ packages: yaml: optional: true + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@3.2.7: resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -936,11 +1398,74 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@oxc-project/types@0.142.0': {} + + '@rolldown/binding-android-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-x64@1.2.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.2': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.62.4': optional: true @@ -1016,6 +1541,74 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.7))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.7) + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1029,10 +1622,19 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + '@types/react@19.2.18': dependencies: csstype: 3.2.3 + '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.7))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.7) + '@vitest/expect@3.2.7': dependencies: '@types/chai': 5.2.3 @@ -1041,13 +1643,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@25.9.5)(tsx@4.23.7))': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@25.9.5)(tsx@4.23.7) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7) '@vitest/pretty-format@3.2.7': dependencies: @@ -1128,8 +1730,15 @@ snapshots: deep-eql@5.0.2: {} + detect-libc@2.1.2: {} + dotenv@17.4.2: {} + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + environment@1.1.0: {} es-module-lexer@1.7.0: {} @@ -1182,6 +1791,8 @@ snapshots: get-east-asian-width@1.6.0: {} + graceful-fs@4.2.11: {} + indent-string@5.0.0: {} ink-spinner@5.0.0(ink@7.1.1(@types/react@19.2.18)(react@19.2.8))(react@19.2.8): @@ -1237,8 +1848,108 @@ snapshots: is-in-ci@2.0.0: {} + jiti@2.7.0: {} + js-tokens@9.0.1: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + loupe@3.2.1: {} magic-string@0.30.21: @@ -1277,6 +1988,11 @@ snapshots: prettier@3.9.6: {} + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + react-reconciler@0.33.0(react@19.2.8): dependencies: react: 19.2.8 @@ -1289,6 +2005,26 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + rolldown@1.2.2: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 + rollup@4.62.4: dependencies: '@types/estree': 1.0.9 @@ -1357,6 +2093,10 @@ snapshots: tagged-tag@1.0.0: {} + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + terminal-size@4.0.1: {} tiktoken@1.0.22: {} @@ -1392,13 +2132,13 @@ snapshots: undici-types@7.24.6: {} - vite-node@3.2.4(@types/node@25.9.5)(tsx@4.23.7): + vite-node@3.2.4(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(@types/node@25.9.5)(tsx@4.23.7) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7) transitivePeerDependencies: - '@types/node' - jiti @@ -1413,7 +2153,7 @@ snapshots: - tsx - yaml - vite@7.3.6(@types/node@25.9.5)(tsx@4.23.7): + vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) @@ -1424,13 +2164,29 @@ snapshots: optionalDependencies: '@types/node': 25.9.5 fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.33.0 + tsx: 4.23.7 + + vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.7): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.5 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.23.7 - vitest@3.2.7(@types/node@25.9.5)(tsx@4.23.7): + vitest@3.2.7(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@25.9.5)(tsx@4.23.7)) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -1448,8 +2204,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@25.9.5)(tsx@4.23.7) - vite-node: 3.2.4(@types/node@25.9.5)(tsx@4.23.7) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7) + vite-node: 3.2.4(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.7) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 diff --git a/source/app.tsx b/source/app.tsx index 772ba33..fca5fce 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -471,7 +471,7 @@ const App = () => { try { const outputPath = process.env.CALL_CODE_WEB_DATA ?? - resolve(process.cwd(), 'packages/client/data.json'); + resolve(process.cwd(), 'packages/client/public/data.json'); const data = writeWebExport(getSharedSessionStore(), outputPath); showCommandMessage( `已导出 ${data.sessions.length} 个会话到 ${outputPath}`, diff --git a/source/export-web.ts b/source/export-web.ts index cd00b6c..317035a 100644 --- a/source/export-web.ts +++ b/source/export-web.ts @@ -4,7 +4,7 @@ import { DEFAULT_DB_PATH, SessionStore } from '../packages/session-sqlite/src/in import { writeWebExport } from '@web/export'; const defaultOutput = fileURLToPath( - new URL('../packages/client/data.json', import.meta.url), + new URL('../packages/client/public/data.json', import.meta.url), ); const outputPath = process.argv[2] ? resolve(process.argv[2]) : defaultOutput; const store = new SessionStore({ diff --git a/tests/client-utils.spec.ts b/tests/client-utils.spec.ts new file mode 100644 index 0000000..e3c7be2 --- /dev/null +++ b/tests/client-utils.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import type { WebEntry, WebSession } from '../packages/client/src/types'; +import { + entryRole, + entryText, + filterEntries, + getSessionTitle, + matchesQuery, +} from '../packages/client/src/utils'; + +const makeEntry = (overrides: Partial = {}): WebEntry => ({ + seq: 1, + id: 'e1', + parentId: null, + type: 'assistant', + role: 'assistant', + timestamp: '2026-08-05T00:00:00.000Z', + payload: { content: 'hello' }, + ...overrides, +}); + +const makeSession = (overrides: Partial = {}): WebSession => ({ + id: 's1', + createdAt: '2026-08-05T00:00:00.000Z', + cwd: '/tmp/project', + parentSessionId: null, + metadata: {}, + stats: { + messageCount: 1, + cachedTokens: 0, + uncachedTokens: 0, + totalTokens: 0, + costTotal: 0, + }, + entries: [makeEntry()], + records: [], + facts: [], + ...overrides, +}); + +describe('client utils', () => { + it('根据 role 或 type 推断条目角色', () => { + expect(entryRole(makeEntry({ role: 'user', type: 'user' }))).toBe('user'); + expect(entryRole(makeEntry({ role: '', type: 'tool' }))).toBe('tool'); + expect(entryRole(makeEntry({ role: 'unknown' }))).toBe('assistant'); + }); + + it('优先读取 text,再读取 payload.content', () => { + expect( + entryText(makeEntry({ text: '显式文本', payload: { content: '嵌套文本' } })), + ).toBe('显式文本'); + expect(entryText(makeEntry({ payload: { content: '嵌套文本' } }))).toBe( + '嵌套文本', + ); + }); + + it('会话标题按用户消息、objective 和 id 依次回退', () => { + const userSession = makeSession({ + id: 's1', + metadata: { objective: '目标文本' }, + entries: [makeEntry({ type: 'user', role: 'user', payload: { content: '用户任务' } })], + }); + const objectiveSession = makeSession({ + id: 's2', + metadata: { objective: '目标文本' }, + entries: [], + }); + const idSession = makeSession({ id: 's3', metadata: {}, entries: [] }); + + expect(getSessionTitle(userSession)).toBe('用户任务'); + expect(getSessionTitle(objectiveSession)).toBe('目标文本'); + expect(getSessionTitle(idSession)).toBe('s3'); + }); + + it('会话搜索覆盖 id、目录、消息内容和工具名', () => { + const session = makeSession({ + id: 'demo-session', + metadata: { mode: 'build' }, + entries: [makeEntry({ tool: 'read_file', payload: { content: '打开仪表盘' } })], + }); + + expect(matchesQuery(session, 'demo')).toBe(true); + expect(matchesQuery(session, '/tmp')).toBe(true); + expect(matchesQuery(session, '仪表盘')).toBe(true); + expect(matchesQuery(session, 'read_file')).toBe(true); + expect(matchesQuery(session, 'missing')).toBe(false); + }); + + it('按 Filter 过滤条目', () => { + const entries = [ + makeEntry({ id: 'u1', type: 'user', role: 'user' }), + makeEntry({ id: 'a1', type: 'assistant', role: 'assistant' }), + makeEntry({ id: 't1', type: 'tool', role: 'tool' }), + ]; + + expect(filterEntries(entries, 'all')).toHaveLength(3); + expect(filterEntries(entries, 'user')).toEqual([entries[0]]); + expect(filterEntries(entries, 'tool')).toEqual([entries[2]]); + expect(filterEntries(entries, 'system')).toEqual([]); + }); +}); From c01f4b2e32ffea591a881d2659a278ef0e8869bc Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Thu, 6 Aug 2026 10:33:33 +0800 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=20schema.ts=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=AD=E7=9A=84=E5=A4=9A=E4=BD=99=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/session-sqlite/src/schema.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/session-sqlite/src/schema.ts b/packages/session-sqlite/src/schema.ts index 2895c60..eb6b778 100644 --- a/packages/session-sqlite/src/schema.ts +++ b/packages/session-sqlite/src/schema.ts @@ -1,5 +1,3 @@ -// SQLite 会话历史存储的完整建表语句。 -// 注释统一使用中文,方便后续维护。 export const SCHEMA_SQL = ` -- 会话主表:记录一次会话的基本信息和元数据 CREATE TABLE IF NOT EXISTS sessions ( From 439408037f9323d13e127f50dc790da7dfd49c37 Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Thu, 6 Aug 2026 10:41:34 +0800 Subject: [PATCH 4/5] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20CI=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=8D=87=E7=BA=A7=20Node.js=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=87=B3=2022=EF=BC=8C=E4=BC=98=E5=8C=96=20p?= =?UTF-8?q?npm=20=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67859ff..1e81ba9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,23 +13,34 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'pnpm' - - name: Install pnpm - run: corepack enable && corepack prepare pnpm@latest --activate - - name: Install dependencies run: pnpm install --frozen-lockfile - name: Typecheck - run: pnpm exec tsc -p tsconfig.json --noEmit + run: pnpm typecheck + + - name: Typecheck session store + run: pnpm exec tsc -p packages/session-sqlite/tsconfig.json --noEmit + + - name: Typecheck client + run: pnpm typecheck:client - name: Run unit tests - run: pnpm exec vitest run --reporter verbose + run: pnpm test - name: Build agent-core - run: pnpm run build:agent-core + run: pnpm build:agent-core + + - name: Build client + run: pnpm build:client From 0713ed6eccbc23d4f8ee96b7ffd01fac1e18467f Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Thu, 6 Aug 2026 10:41:39 +0800 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20GitHub=20Acti?= =?UTF-8?q?ons=20=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=8D=87=E7=BA=A7=20Node.js?= =?UTF-8?q?=20=E7=89=88=E6=9C=AC=E8=87=B3=2022=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=20pnpm=20=E5=AE=89=E8=A3=85=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pages.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c76684e..594693a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -24,15 +24,17 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'pnpm' - - name: Install pnpm - run: corepack enable && corepack prepare pnpm@latest --activate - - name: Install dependencies run: pnpm install --frozen-lockfile