v0.5.0: 打磨(Markdown/审批倒计时/会话刷新/设置过滤/快捷键)

This commit is contained in:
Rick Ye
2026-09-05 09:52:32 +08:00
parent 807fab4f20
commit 3c2e9e96c5
11 changed files with 321 additions and 40 deletions
+28 -3
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { JSX } from 'react'
import { useAgent } from './hooks/useAgent'
import { Sidebar } from './components/Sidebar'
@@ -18,11 +18,29 @@ export default function App(): JSX.Element {
openSession,
renameSession,
deleteSession,
resolveApproval
resolveApproval,
refreshSessions
} = useAgent()
const prefillRef = useRef<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
// 全局快捷键:Ctrl+N 新会话 / Ctrl+, 设置 / Esc 关闭弹窗
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
e.preventDefault()
newSession()
} else if (e.ctrlKey && e.key === ',') {
e.preventDefault()
setSettingsOpen(true)
} else if (e.key === 'Escape') {
setSettingsOpen(false)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [newSession])
const handlePickSuggestion = (text: string): void => {
prefillRef.current = text
}
@@ -41,6 +59,7 @@ export default function App(): JSX.Element {
onRenameSession={renameSession}
onDeleteSession={deleteSession}
onOpenSettings={() => setSettingsOpen(true)}
onRefreshSessions={refreshSessions}
/>
<main className="relative flex min-w-0 flex-1 flex-col">
<ChatView
@@ -50,6 +69,7 @@ export default function App(): JSX.Element {
error={state.error}
retry={state.retry}
sessionError={state.sessionError}
pendingApprovals={state.approvals}
onDismissError={clearError}
onPickSuggestion={handlePickSuggestion}
/>
@@ -63,7 +83,12 @@ export default function App(): JSX.Element {
/>
<ApprovalStack requests={state.approvals} onResolve={resolveApproval} />
</main>
<SettingsModal open={settingsOpen} sessionState={state.sessionState} onClose={() => setSettingsOpen(false)} />
<SettingsModal
open={settingsOpen}
sessionState={state.sessionState}
streaming={state.streaming}
onClose={() => setSettingsOpen(false)}
/>
</div>
)
}
@@ -1,11 +1,13 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { JSX } from 'react'
import { Check, ShieldAlert, X } from 'lucide-react'
import { Check, ShieldAlert, Timer, X } from 'lucide-react'
import type { ApprovalRequest } from '@shared/types'
const APPROVAL_TIMEOUT_S = 120
/**
* 工具调用审批卡(beautifului.dev Approval Card 风格)。
* 敏感工具(核价、写操作等)执行前弹出,允许/拒绝后 1.2s 收起。
* 敏感工具(核价、写操作等)执行前弹出,带剩余时间倒计时。
*/
function ApprovalRow({
request,
@@ -15,6 +17,17 @@ function ApprovalRow({
onResolve: (approved: boolean) => void
}): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
const [remaining, setRemaining] = useState(APPROVAL_TIMEOUT_S)
useEffect(() => {
const start = Date.now()
const timer = setInterval(() => {
const left = Math.max(0, APPROVAL_TIMEOUT_S - Math.floor((Date.now() - start) / 1000))
setRemaining(left)
if (left === 0) clearInterval(timer)
}, 1000)
return () => clearInterval(timer)
}, [request.id])
useEffect(() => {
const el = ref.current
@@ -56,6 +69,15 @@ function ApprovalRow({
<div className="text-[13px] font-medium text-zinc-100">敏感操作,需要你的确认</div>
<div className="font-mono text-[11px] text-amber-300/80">{request.toolName}</div>
</div>
<div
className={`flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] tabular-nums ${
remaining <= 15 ? 'bg-red-950/70 text-red-300' : 'bg-zinc-800 text-zinc-400'
}`}
title="超时将自动拒绝"
>
<Timer size={11} />
{Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, '0')}
</div>
</div>
{argsText && argsText !== 'null' && (
<pre className="mb-3 max-h-32 overflow-auto rounded-lg bg-zinc-950/70 p-2.5 font-mono text-[11px] leading-relaxed text-zinc-400">
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'
import type { JSX } from 'react'
import { AlertTriangle, RotateCw, Store } from 'lucide-react'
import type { ToolState, UiMessage } from '../hooks/useAgent'
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
import { MessageItem } from './MessageItem'
const SUGGESTIONS = [
@@ -43,6 +43,7 @@ export function ChatView({
error,
retry,
sessionError,
pendingApprovals,
onDismissError,
onPickSuggestion
}: {
@@ -52,6 +53,7 @@ export function ChatView({
error: string | null
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
sessionError: string | null
pendingApprovals: ApprovalRequest[]
onDismissError: () => void
onPickSuggestion: (text: string) => void
}): JSX.Element {
@@ -114,7 +116,13 @@ export function ChatView({
{hasContent ? (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
{messages.map((message) => (
<MessageItem key={message.key} message={message} tools={tools} streaming={streaming} />
<MessageItem
key={message.key}
message={message}
tools={tools}
streaming={streaming}
pendingApprovals={pendingApprovals}
/>
))}
</div>
) : (
@@ -1,10 +1,10 @@
import type { ReactNode } from 'react'
/**
* 极简 Markdown 渲染(v0.1):
* 支持 fenced code block、行内 code、**加粗** 与换行。
* 不使用 dangerouslySetInnerHTML,全部构建 React 节点。
* 完整 Markdown(标题/列表/链接/表格)排入 v0.5。
* 轻量 Markdown 渲染:
* fenced code block、标题(#~###)、无序/有序列表、引用块、链接、行内 code、加粗。
* 全部构建 React 节点,不使用 dangerouslySetInnerHTML。
* 链接点击由外层(Electron main 的 setWindowOpenHandler)转交系统浏览器。
*/
interface CodeBlock {
@@ -28,7 +28,7 @@ function splitFences(text: string): Array<{ type: 'text'; text: string } | { typ
function renderInline(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = []
const regex = /(\*\*[^*]+\*\*|`[^`\n]+`)/g
const regex = /(\*\*[^*\n]+\*\*|`[^`\n]+`|\[[^\]\n]+\]\((https?:\/\/[^)\s]+)\))/g
let last = 0
let match: RegExpExecArray | null
let i = 0
@@ -41,6 +41,18 @@ function renderInline(text: string, keyPrefix: string): ReactNode[] {
{token.slice(2, -2)}
</strong>
)
} else if (token.startsWith('[')) {
nodes.push(
<a
key={`${keyPrefix}-l${i}`}
href={match[2]}
target="_blank"
rel="noreferrer noopener"
className="text-emerald-400 underline decoration-emerald-800 underline-offset-2 hover:text-emerald-300"
>
{token.slice(1, token.indexOf(']'))}
</a>
)
} else {
nodes.push(
<code key={`${keyPrefix}-c${i}`} className="rounded bg-zinc-800/80 px-1.5 py-0.5 font-mono text-[0.85em] text-emerald-300">
@@ -55,10 +67,135 @@ function renderInline(text: string, keyPrefix: string): ReactNode[] {
return nodes
}
const INLINE = /^(\s*)([-*+]\s+|\d+[.)]\s+|>\s?|(#{1,3}\s+))?(.*)$/
interface Block {
kind: 'p' | 'h1' | 'h2' | 'h3' | 'ul' | 'ol' | 'quote'
lines: string[]
}
/** 把普通文本切成块级结构(段落/标题/列表/引用) */
function parseBlocks(text: string): Block[] {
const blocks: Block[] = []
const lines = text.split('\n')
let current: Block | null = null
const flush = (): void => {
if (current) blocks.push(current)
current = null
}
for (const line of lines) {
const m = line.match(INLINE)
if (!m) continue
const [, indent, marker, content] = m
const isListItem = Boolean(marker && /^([-*+]\s+|\d+[.)]\s+)/.test(marker))
const kind: Block['kind'] | null = marker
? marker.startsWith('#')
? marker.trim().length === 2 && marker.startsWith('# ')
? 'h1'
: marker.trim().length === 3 && marker.startsWith('## ')
? 'h2'
: marker.trim().length === 4 && marker.startsWith('### ')
? 'h3'
: null
: isListItem
? /^\d/.test(marker)
? 'ol'
: 'ul'
: marker.startsWith('>')
? 'quote'
: null
: 'p'
if (kind === null) {
if (!current || current.kind !== 'p') flush()
current = current ?? { kind: 'p', lines: [] }
current.lines.push(line)
continue
}
if (kind === 'p') {
if (!current || current.kind !== 'p') {
flush()
current = { kind: 'p', lines: [] }
}
current.lines.push(content)
continue
}
const stripped = marker?.startsWith('>') ? content : content
if (!current || current.kind !== kind) {
flush()
current = { kind, lines: [stripped ?? ''] }
} else {
current.lines.push(stripped ?? '')
}
void indent
}
flush()
return blocks
}
function BlockView({ block, k }: { block: Block; k: number }): ReactNode {
switch (block.kind) {
case 'h1':
return (
<h3 key={k} className="mb-1 mt-3 text-[17px] font-semibold text-zinc-50 first:mt-0">
{renderInline(block.lines.join(' '), `h${k}`)}
</h3>
)
case 'h2':
return (
<h4 key={k} className="mb-1 mt-3 text-[15.5px] font-semibold text-zinc-100 first:mt-0">
{renderInline(block.lines.join(' '), `h${k}`)}
</h4>
)
case 'h3':
return (
<h5 key={k} className="mb-1 mt-2.5 text-[14.5px] font-semibold text-zinc-100 first:mt-0">
{renderInline(block.lines.join(' '), `h${k}`)}
</h5>
)
case 'ul':
return (
<ul key={k} className="my-1.5 list-disc space-y-1 pl-5 marker:text-zinc-600">
{block.lines.map((line, i) => (
<li key={i} className="leading-7">
{renderInline(line, `ul${k}-${i}`)}
</li>
))}
</ul>
)
case 'ol':
return (
<ol key={k} className="my-1.5 list-decimal space-y-1 pl-5 marker:text-zinc-600">
{block.lines.map((line, i) => (
<li key={i} className="leading-7">
{renderInline(line, `ol${k}-${i}`)}
</li>
))}
</ol>
)
case 'quote':
return (
<blockquote key={k} className="my-2 border-l-2 border-zinc-700 pl-3 text-[13.5px] italic text-zinc-400">
{renderInline(block.lines.join('\n'), `q${k}`)}
</blockquote>
)
default:
return (
<p key={k} className="whitespace-pre-wrap leading-7">
{renderInline(block.lines.join('\n'), `p${k}`)}
</p>
)
}
}
export function Markdown({ text }: { text: string }): ReactNode {
const parts = splitFences(text)
return (
<div className="space-y-3">
<div className="space-y-2.5 text-[14.5px] text-zinc-200">
{parts.map((part, i) => {
if (part.type === 'code') {
return (
@@ -75,9 +212,11 @@ export function Markdown({ text }: { text: string }): ReactNode {
const trimmed = part.text.replace(/^\n+|\n+$/g, '')
if (!trimmed) return null
return (
<p key={i} className="whitespace-pre-wrap text-[14.5px] leading-7 text-zinc-200">
{renderInline(trimmed, `p${i}`)}
</p>
<div key={i}>
{parseBlocks(trimmed).map((block, j) => (
<BlockView key={j} block={block} k={j} />
))}
</div>
)
})}
</div>
@@ -1,9 +1,9 @@
import { useState } from 'react'
import type { JSX } from 'react'
import { Brain, ChevronRight } from 'lucide-react'
import type { ToolState, UiMessage } from '../hooks/useAgent'
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
import { Markdown } from './Markdown'
import { ToolCard, useEntrance } from './ToolCard'
import { ToolCard, ToolPendingApproval, useEntrance } from './ToolCard'
interface ContentBlock {
type?: string
@@ -55,11 +55,13 @@ function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }
function AssistantBody({
message,
tools,
streaming
streaming,
pendingApprovals
}: {
message: UiMessage
tools: Record<string, ToolState>
streaming: boolean
pendingApprovals: ApprovalRequest[]
}): JSX.Element {
if (message.stream) {
return (
@@ -98,6 +100,10 @@ function AssistantBody({
if (block.type === 'toolCall' && block.id) {
const tool = tools[block.id]
if (tool) return <ToolCard key={`c${i}`} toolCallId={block.id} tool={tool} />
const pending = pendingApprovals.find((a) => a.toolName === block.name)
if (pending) {
return <ToolPendingApproval key={`c${i}`} toolName={block.name ?? 'tool'} args={pending.args} />
}
return (
<div key={`c${i}`} className="rounded-lg border border-zinc-800 bg-zinc-900/60 px-3 py-2 font-mono text-[12.5px] text-zinc-400">
{block.name ?? 'tool'}
@@ -113,11 +119,13 @@ function AssistantBody({
export function MessageItem({
message,
tools,
streaming
streaming,
pendingApprovals = []
}: {
message: UiMessage
tools: Record<string, ToolState>
streaming: boolean
pendingApprovals?: ApprovalRequest[]
}): JSX.Element | null {
const ref = useEntrance<HTMLDivElement>()
@@ -136,7 +144,7 @@ export function MessageItem({
if (message.role === 'assistant') {
return (
<div ref={ref} className="max-w-full">
<AssistantBody message={message} tools={tools} streaming={streaming} />
<AssistantBody message={message} tools={tools} streaming={streaming} pendingApprovals={pendingApprovals} />
</div>
)
}
@@ -24,16 +24,19 @@ function formatContext(n: number): string {
export function SettingsModal({
open,
sessionState,
streaming,
onClose
}: {
open: boolean
sessionState: SessionState | null
streaming: boolean
onClose: () => void
}): JSX.Element | null {
const [tab, setTab] = useState<Tab>('model')
const [catalog, setCatalog] = useState<ModelCatalog | null>(null)
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [onlyConfigured, setOnlyConfigured] = useState(false)
const [keyProvider, setKeyProvider] = useState<string | null>(null)
const [keyValue, setKeyValue] = useState('')
const [prompt, setPrompt] = useState('')
@@ -73,6 +76,7 @@ export function SettingsModal({
if (!catalog) return []
const q = search.trim().toLowerCase()
return catalog.providers
.filter((p) => !onlyConfigured || p.hasApiKey || p.isOAuth)
.map((p) => ({
...p,
models: q
@@ -82,7 +86,7 @@ export function SettingsModal({
: p.models
}))
.filter((p) => p.models.length > 0 || (!q && p.models.length === 0))
}, [catalog, search])
}, [catalog, search, onlyConfigured])
const run = (label: string, fn: () => Promise<void>, after?: () => void): void => {
setBusy(true)
@@ -148,6 +152,15 @@ export function SettingsModal({
/>
</div>
{loading && <span className="text-[11px] text-zinc-600">加载中…</span>}
<label className="flex shrink-0 cursor-pointer select-none items-center gap-1.5 text-[11.5px] text-zinc-500 hover:text-zinc-300">
<input
type="checkbox"
checked={onlyConfigured}
onChange={(e) => setOnlyConfigured(e.target.checked)}
className="h-3 w-3 accent-emerald-500"
/>
仅显示已配置
</label>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 pb-4">
{filtered.map((provider) => (
@@ -277,11 +290,15 @@ export function SettingsModal({
<button
type="button"
disabled={busy || !promptDirty}
onClick={() =>
onClick={() => {
if (streaming) {
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再保存(保存会重建运行时)' })
return
}
run('系统提示词已保存,运行时已重建', () => window.api.setSystemPrompt(prompt), () =>
setPromptDirty(false)
)
}
}}
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3.5 py-1.5 text-[12.5px] text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<Check size={13} />
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import type { JSX } from 'react'
import { Check, Cpu, MessageSquare, Pencil, Settings, Store, Trash2, X } from 'lucide-react'
import { Check, Cpu, MessageSquare, Pencil, RefreshCw, Settings, Store, Trash2, X } from 'lucide-react'
import type { SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
interface VersionInfo {
@@ -70,17 +70,19 @@ function SessionRow({
</div>
</div>
<div className="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<button
type="button"
title="重命名"
className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
onClick={(e) => {
e.stopPropagation()
onRename()
}}
>
<Pencil size={12} />
</button>
{active && (
<button
type="button"
title="重命名当前会话"
className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
onClick={(e) => {
e.stopPropagation()
onRename()
}}
>
<Pencil size={12} />
</button>
)}
<button
type="button"
title="删除(移入回收站)"
@@ -106,7 +108,8 @@ export function Sidebar({
onOpenSession,
onRenameSession,
onDeleteSession,
onOpenSettings
onOpenSettings,
onRefreshSessions
}: {
sessionState: SessionState | null
stats: SessionStatsInfo | null
@@ -117,6 +120,7 @@ export function Sidebar({
onRenameSession: (name: string) => void
onDeleteSession: (path: string) => void
onOpenSettings: () => void
onRefreshSessions: () => void
}): JSX.Element {
const [versions, setVersions] = useState<VersionInfo | null>(null)
const [renaming, setRenaming] = useState(false)
@@ -187,7 +191,17 @@ export function Sidebar({
{/* 会话列表 */}
<div className="mt-4 flex min-h-0 flex-1 flex-col px-3">
<div className="px-2 pb-2 text-[11px] font-medium uppercase tracking-wider text-zinc-600">会话</div>
<div className="flex items-center px-2 pb-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-600">会话</span>
<button
type="button"
onClick={onRefreshSessions}
title="刷新会话列表(也会显示其他窗口/CLI 新建的会话)"
className="ml-auto rounded p-1 text-zinc-600 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
>
<RefreshCw size={12} />
</button>
</div>
<div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto pb-2">
{sessions.length === 0 ? (
<div className="rounded-xl border border-dashed border-zinc-800/80 px-3 py-4 text-center text-[12px] leading-5 text-zinc-600">
@@ -107,6 +107,34 @@ export function ToolCard({ toolCallId, tool }: { toolCallId: string; tool: ToolS
)
}
/** 等待用户审批中的工具占位卡(tool_call 已产生、审批未决) */
export function ToolPendingApproval({ toolName, args }: { toolName: string; args?: unknown }): React.JSX.Element {
const argsText = (() => {
try {
return JSON.stringify(args, null, 2)
} catch {
return undefined
}
})()
return (
<div className="overflow-hidden rounded-lg border border-amber-900/50 bg-amber-950/20">
<div className="flex items-center gap-2 px-3 py-2">
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-amber-400" />
</span>
<span className="font-mono text-[12.5px] text-zinc-300">{toolName}</span>
<span className="text-[11px] text-amber-300/90">等待你的审批…</span>
</div>
{argsText && argsText !== 'undefined' && (
<pre className="max-h-24 overflow-auto border-t border-amber-900/30 bg-zinc-950/40 px-3 py-2 font-mono text-[11px] leading-relaxed text-zinc-500">
{argsText.length > 500 ? `${argsText.slice(0, 500)}…` : argsText}
</pre>
)}
</div>
)
}
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
const ref = useRef<T | null>(null)
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import type { AgentUiEvent, ApprovalRequest, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
export type { ApprovalRequest }
export interface ToolState {
toolName: string
args?: unknown