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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@aso/desktop", "name": "@aso/desktop",
"private": true, "private": true,
"version": "0.1.0", "version": "0.5.0",
"description": "店铺运营 Agent 桌面端(Electron for pi agent)", "description": "店铺运营 Agent 桌面端(Electron for pi agent)",
"type": "module", "type": "module",
"main": "out/main/index.mjs", "main": "out/main/index.mjs",
+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 type { JSX } from 'react'
import { useAgent } from './hooks/useAgent' import { useAgent } from './hooks/useAgent'
import { Sidebar } from './components/Sidebar' import { Sidebar } from './components/Sidebar'
@@ -18,11 +18,29 @@ export default function App(): JSX.Element {
openSession, openSession,
renameSession, renameSession,
deleteSession, deleteSession,
resolveApproval resolveApproval,
refreshSessions
} = useAgent() } = useAgent()
const prefillRef = useRef<string | null>(null) const prefillRef = useRef<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false) 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 => { const handlePickSuggestion = (text: string): void => {
prefillRef.current = text prefillRef.current = text
} }
@@ -41,6 +59,7 @@ export default function App(): JSX.Element {
onRenameSession={renameSession} onRenameSession={renameSession}
onDeleteSession={deleteSession} onDeleteSession={deleteSession}
onOpenSettings={() => setSettingsOpen(true)} onOpenSettings={() => setSettingsOpen(true)}
onRefreshSessions={refreshSessions}
/> />
<main className="relative flex min-w-0 flex-1 flex-col"> <main className="relative flex min-w-0 flex-1 flex-col">
<ChatView <ChatView
@@ -50,6 +69,7 @@ export default function App(): JSX.Element {
error={state.error} error={state.error}
retry={state.retry} retry={state.retry}
sessionError={state.sessionError} sessionError={state.sessionError}
pendingApprovals={state.approvals}
onDismissError={clearError} onDismissError={clearError}
onPickSuggestion={handlePickSuggestion} onPickSuggestion={handlePickSuggestion}
/> />
@@ -63,7 +83,12 @@ export default function App(): JSX.Element {
/> />
<ApprovalStack requests={state.approvals} onResolve={resolveApproval} /> <ApprovalStack requests={state.approvals} onResolve={resolveApproval} />
</main> </main>
<SettingsModal open={settingsOpen} sessionState={state.sessionState} onClose={() => setSettingsOpen(false)} /> <SettingsModal
open={settingsOpen}
sessionState={state.sessionState}
streaming={state.streaming}
onClose={() => setSettingsOpen(false)}
/>
</div> </div>
) )
} }
@@ -1,11 +1,13 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
import type { JSX } 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' import type { ApprovalRequest } from '@shared/types'
const APPROVAL_TIMEOUT_S = 120
/** /**
* 工具调用审批卡(beautifului.dev Approval Card 风格)。 * 工具调用审批卡(beautifului.dev Approval Card 风格)。
* 敏感工具(核价、写操作等)执行前弹出,允许/拒绝后 1.2s 收起。 * 敏感工具(核价、写操作等)执行前弹出,带剩余时间倒计时。
*/ */
function ApprovalRow({ function ApprovalRow({
request, request,
@@ -15,6 +17,17 @@ function ApprovalRow({
onResolve: (approved: boolean) => void onResolve: (approved: boolean) => void
}): JSX.Element { }): JSX.Element {
const ref = useRef<HTMLDivElement>(null) 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(() => { useEffect(() => {
const el = ref.current const el = ref.current
@@ -56,6 +69,15 @@ function ApprovalRow({
<div className="text-[13px] font-medium text-zinc-100">敏感操作,需要你的确认</div> <div className="text-[13px] font-medium text-zinc-100">敏感操作,需要你的确认</div>
<div className="font-mono text-[11px] text-amber-300/80">{request.toolName}</div> <div className="font-mono text-[11px] text-amber-300/80">{request.toolName}</div>
</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> </div>
{argsText && argsText !== 'null' && ( {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"> <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 { useEffect, useRef } from 'react'
import type { JSX } from 'react' import type { JSX } from 'react'
import { AlertTriangle, RotateCw, Store } from 'lucide-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' import { MessageItem } from './MessageItem'
const SUGGESTIONS = [ const SUGGESTIONS = [
@@ -43,6 +43,7 @@ export function ChatView({
error, error,
retry, retry,
sessionError, sessionError,
pendingApprovals,
onDismissError, onDismissError,
onPickSuggestion onPickSuggestion
}: { }: {
@@ -52,6 +53,7 @@ export function ChatView({
error: string | null error: string | null
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
sessionError: string | null sessionError: string | null
pendingApprovals: ApprovalRequest[]
onDismissError: () => void onDismissError: () => void
onPickSuggestion: (text: string) => void onPickSuggestion: (text: string) => void
}): JSX.Element { }): JSX.Element {
@@ -114,7 +116,13 @@ export function ChatView({
{hasContent ? ( {hasContent ? (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8"> <div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
{messages.map((message) => ( {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> </div>
) : ( ) : (
@@ -1,10 +1,10 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
/** /**
* 极简 Markdown 渲染(v0.1): * 轻量 Markdown 渲染:
* 支持 fenced code block、行内 code、**加粗** 与换行。 * fenced code block、标题(#~###)、无序/有序列表、引用块、链接、行内 code、加粗。
* 不使用 dangerouslySetInnerHTML,全部构建 React 节点。 * 全部构建 React 节点,不使用 dangerouslySetInnerHTML。
* 完整 Markdown(标题/列表/链接/表格)排入 v0.5。 * 链接点击由外层(Electron main 的 setWindowOpenHandler)转交系统浏览器。
*/ */
interface CodeBlock { interface CodeBlock {
@@ -28,7 +28,7 @@ function splitFences(text: string): Array<{ type: 'text'; text: string } | { typ
function renderInline(text: string, keyPrefix: string): ReactNode[] { function renderInline(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = [] const nodes: ReactNode[] = []
const regex = /(\*\*[^*]+\*\*|`[^`\n]+`)/g const regex = /(\*\*[^*\n]+\*\*|`[^`\n]+`|\[[^\]\n]+\]\((https?:\/\/[^)\s]+)\))/g
let last = 0 let last = 0
let match: RegExpExecArray | null let match: RegExpExecArray | null
let i = 0 let i = 0
@@ -41,6 +41,18 @@ function renderInline(text: string, keyPrefix: string): ReactNode[] {
{token.slice(2, -2)} {token.slice(2, -2)}
</strong> </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 { } else {
nodes.push( 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"> <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 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 { export function Markdown({ text }: { text: string }): ReactNode {
const parts = splitFences(text) const parts = splitFences(text)
return ( return (
<div className="space-y-3"> <div className="space-y-2.5 text-[14.5px] text-zinc-200">
{parts.map((part, i) => { {parts.map((part, i) => {
if (part.type === 'code') { if (part.type === 'code') {
return ( return (
@@ -75,9 +212,11 @@ export function Markdown({ text }: { text: string }): ReactNode {
const trimmed = part.text.replace(/^\n+|\n+$/g, '') const trimmed = part.text.replace(/^\n+|\n+$/g, '')
if (!trimmed) return null if (!trimmed) return null
return ( return (
<p key={i} className="whitespace-pre-wrap text-[14.5px] leading-7 text-zinc-200"> <div key={i}>
{renderInline(trimmed, `p${i}`)} {parseBlocks(trimmed).map((block, j) => (
</p> <BlockView key={j} block={block} k={j} />
))}
</div>
) )
})} })}
</div> </div>
@@ -1,9 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import type { JSX } from 'react' import type { JSX } from 'react'
import { Brain, ChevronRight } from 'lucide-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 { Markdown } from './Markdown'
import { ToolCard, useEntrance } from './ToolCard' import { ToolCard, ToolPendingApproval, useEntrance } from './ToolCard'
interface ContentBlock { interface ContentBlock {
type?: string type?: string
@@ -55,11 +55,13 @@ function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }
function AssistantBody({ function AssistantBody({
message, message,
tools, tools,
streaming streaming,
pendingApprovals
}: { }: {
message: UiMessage message: UiMessage
tools: Record<string, ToolState> tools: Record<string, ToolState>
streaming: boolean streaming: boolean
pendingApprovals: ApprovalRequest[]
}): JSX.Element { }): JSX.Element {
if (message.stream) { if (message.stream) {
return ( return (
@@ -98,6 +100,10 @@ function AssistantBody({
if (block.type === 'toolCall' && block.id) { if (block.type === 'toolCall' && block.id) {
const tool = tools[block.id] const tool = tools[block.id]
if (tool) return <ToolCard key={`c${i}`} toolCallId={block.id} tool={tool} /> 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 ( 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"> <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'} {block.name ?? 'tool'}
@@ -113,11 +119,13 @@ function AssistantBody({
export function MessageItem({ export function MessageItem({
message, message,
tools, tools,
streaming streaming,
pendingApprovals = []
}: { }: {
message: UiMessage message: UiMessage
tools: Record<string, ToolState> tools: Record<string, ToolState>
streaming: boolean streaming: boolean
pendingApprovals?: ApprovalRequest[]
}): JSX.Element | null { }): JSX.Element | null {
const ref = useEntrance<HTMLDivElement>() const ref = useEntrance<HTMLDivElement>()
@@ -136,7 +144,7 @@ export function MessageItem({
if (message.role === 'assistant') { if (message.role === 'assistant') {
return ( return (
<div ref={ref} className="max-w-full"> <div ref={ref} className="max-w-full">
<AssistantBody message={message} tools={tools} streaming={streaming} /> <AssistantBody message={message} tools={tools} streaming={streaming} pendingApprovals={pendingApprovals} />
</div> </div>
) )
} }
@@ -24,16 +24,19 @@ function formatContext(n: number): string {
export function SettingsModal({ export function SettingsModal({
open, open,
sessionState, sessionState,
streaming,
onClose onClose
}: { }: {
open: boolean open: boolean
sessionState: SessionState | null sessionState: SessionState | null
streaming: boolean
onClose: () => void onClose: () => void
}): JSX.Element | null { }): JSX.Element | null {
const [tab, setTab] = useState<Tab>('model') const [tab, setTab] = useState<Tab>('model')
const [catalog, setCatalog] = useState<ModelCatalog | null>(null) const [catalog, setCatalog] = useState<ModelCatalog | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [onlyConfigured, setOnlyConfigured] = useState(false)
const [keyProvider, setKeyProvider] = useState<string | null>(null) const [keyProvider, setKeyProvider] = useState<string | null>(null)
const [keyValue, setKeyValue] = useState('') const [keyValue, setKeyValue] = useState('')
const [prompt, setPrompt] = useState('') const [prompt, setPrompt] = useState('')
@@ -73,6 +76,7 @@ export function SettingsModal({
if (!catalog) return [] if (!catalog) return []
const q = search.trim().toLowerCase() const q = search.trim().toLowerCase()
return catalog.providers return catalog.providers
.filter((p) => !onlyConfigured || p.hasApiKey || p.isOAuth)
.map((p) => ({ .map((p) => ({
...p, ...p,
models: q models: q
@@ -82,7 +86,7 @@ export function SettingsModal({
: p.models : p.models
})) }))
.filter((p) => p.models.length > 0 || (!q && p.models.length === 0)) .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 => { const run = (label: string, fn: () => Promise<void>, after?: () => void): void => {
setBusy(true) setBusy(true)
@@ -148,6 +152,15 @@ export function SettingsModal({
/> />
</div> </div>
{loading && <span className="text-[11px] text-zinc-600">加载中…</span>} {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>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 pb-4"> <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 pb-4">
{filtered.map((provider) => ( {filtered.map((provider) => (
@@ -277,11 +290,15 @@ export function SettingsModal({
<button <button
type="button" type="button"
disabled={busy || !promptDirty} disabled={busy || !promptDirty}
onClick={() => onClick={() => {
if (streaming) {
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再保存(保存会重建运行时)' })
return
}
run('系统提示词已保存,运行时已重建', () => window.api.setSystemPrompt(prompt), () => run('系统提示词已保存,运行时已重建', () => window.api.setSystemPrompt(prompt), () =>
setPromptDirty(false) 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" 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} /> <Check size={13} />
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { JSX } 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' import type { SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
interface VersionInfo { interface VersionInfo {
@@ -70,17 +70,19 @@ function SessionRow({
</div> </div>
</div> </div>
<div className="hidden shrink-0 items-center gap-0.5 group-hover:flex"> <div className="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<button {active && (
type="button" <button
title="重命名" type="button"
className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200" title="重命名当前会话"
onClick={(e) => { className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
e.stopPropagation() onClick={(e) => {
onRename() e.stopPropagation()
}} onRename()
> }}
<Pencil size={12} /> >
</button> <Pencil size={12} />
</button>
)}
<button <button
type="button" type="button"
title="删除(移入回收站)" title="删除(移入回收站)"
@@ -106,7 +108,8 @@ export function Sidebar({
onOpenSession, onOpenSession,
onRenameSession, onRenameSession,
onDeleteSession, onDeleteSession,
onOpenSettings onOpenSettings,
onRefreshSessions
}: { }: {
sessionState: SessionState | null sessionState: SessionState | null
stats: SessionStatsInfo | null stats: SessionStatsInfo | null
@@ -117,6 +120,7 @@ export function Sidebar({
onRenameSession: (name: string) => void onRenameSession: (name: string) => void
onDeleteSession: (path: string) => void onDeleteSession: (path: string) => void
onOpenSettings: () => void onOpenSettings: () => void
onRefreshSessions: () => void
}): JSX.Element { }): JSX.Element {
const [versions, setVersions] = useState<VersionInfo | null>(null) const [versions, setVersions] = useState<VersionInfo | null>(null)
const [renaming, setRenaming] = useState(false) 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="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"> <div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto pb-2">
{sessions.length === 0 ? ( {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"> <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),仅挂载时执行一次 */ /** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> { export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
const ref = useRef<T | null>(null) const ref = useRef<T | null>(null)
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react' import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import type { AgentUiEvent, ApprovalRequest, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types' import type { AgentUiEvent, ApprovalRequest, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
export type { ApprovalRequest }
export interface ToolState { export interface ToolState {
toolName: string toolName: string
args?: unknown args?: unknown
+18
View File
@@ -63,6 +63,24 @@
4. **工具卡片在审批等待期间无"等待审批"状态**——ToolCard 显示 running,但用户看不出是在等他确认;应联动 approval 状态显示"等待审批中"。排 v0.5。 4. **工具卡片在审批等待期间无"等待审批"状态**——ToolCard 显示 running,但用户看不出是在等他确认;应联动 approval 状态显示"等待审批中"。排 v0.5。
5. **审批卡无倒计时**——120s 超时静默拒绝,卡片上应显示剩余时间。排 v0.5。 5. **审批卡无倒计时**——120s 超时静默拒绝,卡片上应显示剩余时间。排 v0.5。
## v0.5.0 — 打磨
**交付**(消化 v0.1–v0.4 自省清单):
- Markdown 渲染增强:标题(#~### 映射 h3~h5)、无序/有序列表、引用块、链接(外链转系统浏览器)、行内 code/加粗;fenced code 不变
- 审批体验闭环:ToolCard 新增"等待审批"占位态(tool_call 已产生、审批未决时 amber 呼吸点 + 参数预览);Approval Card 增加 mm:ss 倒计时(≤15s 变红)
- 会话列表:手动刷新按钮(补齐外部进程/CLI 新建会话不可见的缺口);重命名按钮仅在当前活跃会话显示(修正 v0.2 的误导交互)
- 设置:模型列表"仅显示已配置"开关;保存系统提示词前检查流式状态(回复中禁止重建运行时并给出提示)
- 快捷键:Ctrl+N 新会话、Ctrl+, 打开设置、Esc 关闭弹窗
- 版本号提升 0.5.0(侧栏状态区展示)
- 验证:typecheck/build;应用截图核验(刷新按钮/版本号/消息渲染/无异常)
**自省**(v0.5.0 → 后续):
1. **浅色主题未做**——深色已统一,浅色需要全量 CSS 变量化改造(所有 zinc-* 硬编码),工作量半人日以上,单独排 v0.6。
2. **OAuth 登录流仍未接**(GitHub Copilot 等 OAuth-only provider 用不了),需要 auth_url/device_code 的 UI 事件处理。
3. **密钥连通性测试未做**——保存假 key 要到真正发消息才暴露。
4. **打包发布链路未建**——electron-builder 配置、图标、自动更新(排 v0.6,发布前置条件)。
5. **真实 LLM 端到端仍未验证**(本机无凭据,所有版本共通的遗留项);用户配 key 后建议按 roadmap 末尾的验收清单过一遍。
## v0.1.0 — 骨架 + 聊天链路 ## v0.1.0 — 骨架 + 聊天链路
**交付**: **交付**: