From 3c2e9e96c59636553375abee5fd3091ddc0c6258 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Sat, 5 Sep 2026 09:52:32 +0800 Subject: [PATCH] =?UTF-8?q?v0.5.0:=20=E6=89=93=E7=A3=A8=EF=BC=88Markdown/?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=80=92=E8=AE=A1=E6=97=B6/=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=B7=E6=96=B0/=E8=AE=BE=E7=BD=AE=E8=BF=87?= =?UTF-8?q?=E6=BB=A4/=E5=BF=AB=E6=8D=B7=E9=94=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/package.json | 2 +- apps/desktop/src/renderer/src/App.tsx | 31 +++- .../renderer/src/components/ApprovalCard.tsx | 28 +++- .../src/renderer/src/components/ChatView.tsx | 12 +- .../src/renderer/src/components/Markdown.tsx | 157 +++++++++++++++++- .../renderer/src/components/MessageItem.tsx | 18 +- .../renderer/src/components/SettingsModal.tsx | 23 ++- .../src/renderer/src/components/Sidebar.tsx | 42 +++-- .../src/renderer/src/components/ToolCard.tsx | 28 ++++ .../src/renderer/src/hooks/useAgent.ts | 2 + docs/roadmap.md | 18 ++ 11 files changed, 321 insertions(+), 40 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cca9fcc..e2a95f2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@aso/desktop", "private": true, - "version": "0.1.0", + "version": "0.5.0", "description": "店铺运营 Agent 桌面端(Electron for pi agent)", "type": "module", "main": "out/main/index.mjs", diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 978e457..4aa769b 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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(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} />
@@ -63,7 +83,12 @@ export default function App(): JSX.Element { />
- setSettingsOpen(false)} /> + setSettingsOpen(false)} + /> ) } diff --git a/apps/desktop/src/renderer/src/components/ApprovalCard.tsx b/apps/desktop/src/renderer/src/components/ApprovalCard.tsx index 8a23b62..bb5cd2b 100644 --- a/apps/desktop/src/renderer/src/components/ApprovalCard.tsx +++ b/apps/desktop/src/renderer/src/components/ApprovalCard.tsx @@ -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(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({
敏感操作,需要你的确认
{request.toolName}
+
+ + {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, '0')} +
{argsText && argsText !== 'null' && (
diff --git a/apps/desktop/src/renderer/src/components/ChatView.tsx b/apps/desktop/src/renderer/src/components/ChatView.tsx
index 58525a0..a00e179 100644
--- a/apps/desktop/src/renderer/src/components/ChatView.tsx
+++ b/apps/desktop/src/renderer/src/components/ChatView.tsx
@@ -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 ? (
           
{messages.map((message) => ( - + ))}
) : ( diff --git a/apps/desktop/src/renderer/src/components/Markdown.tsx b/apps/desktop/src/renderer/src/components/Markdown.tsx index ce3f57c..ea79b79 100644 --- a/apps/desktop/src/renderer/src/components/Markdown.tsx +++ b/apps/desktop/src/renderer/src/components/Markdown.tsx @@ -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)} ) + } else if (token.startsWith('[')) { + nodes.push( + + {token.slice(1, token.indexOf(']'))} + + ) } else { nodes.push( @@ -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 ( +

+ {renderInline(block.lines.join(' '), `h${k}`)} +

+ ) + case 'h2': + return ( +

+ {renderInline(block.lines.join(' '), `h${k}`)} +

+ ) + case 'h3': + return ( +
+ {renderInline(block.lines.join(' '), `h${k}`)} +
+ ) + case 'ul': + return ( +
    + {block.lines.map((line, i) => ( +
  • + {renderInline(line, `ul${k}-${i}`)} +
  • + ))} +
+ ) + case 'ol': + return ( +
    + {block.lines.map((line, i) => ( +
  1. + {renderInline(line, `ol${k}-${i}`)} +
  2. + ))} +
+ ) + case 'quote': + return ( +
+ {renderInline(block.lines.join('\n'), `q${k}`)} +
+ ) + default: + return ( +

+ {renderInline(block.lines.join('\n'), `p${k}`)} +

+ ) + } +} + export function Markdown({ text }: { text: string }): ReactNode { const parts = splitFences(text) return ( -
+
{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 ( -

- {renderInline(trimmed, `p${i}`)} -

+
+ {parseBlocks(trimmed).map((block, j) => ( + + ))} +
) })}
diff --git a/apps/desktop/src/renderer/src/components/MessageItem.tsx b/apps/desktop/src/renderer/src/components/MessageItem.tsx index 21d294f..d69333e 100644 --- a/apps/desktop/src/renderer/src/components/MessageItem.tsx +++ b/apps/desktop/src/renderer/src/components/MessageItem.tsx @@ -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 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 + const pending = pendingApprovals.find((a) => a.toolName === block.name) + if (pending) { + return + } return (
{block.name ?? 'tool'} @@ -113,11 +119,13 @@ function AssistantBody({ export function MessageItem({ message, tools, - streaming + streaming, + pendingApprovals = [] }: { message: UiMessage tools: Record streaming: boolean + pendingApprovals?: ApprovalRequest[] }): JSX.Element | null { const ref = useEntrance() @@ -136,7 +144,7 @@ export function MessageItem({ if (message.role === 'assistant') { return (
- +
) } diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx index aef6dae..71660ce 100644 --- a/apps/desktop/src/renderer/src/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx @@ -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('model') const [catalog, setCatalog] = useState(null) const [loading, setLoading] = useState(false) const [search, setSearch] = useState('') + const [onlyConfigured, setOnlyConfigured] = useState(false) const [keyProvider, setKeyProvider] = useState(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, after?: () => void): void => { setBusy(true) @@ -148,6 +152,15 @@ export function SettingsModal({ />
{loading && 加载中…} +
{filtered.map((provider) => ( @@ -277,11 +290,15 @@ export function SettingsModal({
- + {active && ( + + )} +
{sessions.length === 0 ? (
diff --git a/apps/desktop/src/renderer/src/components/ToolCard.tsx b/apps/desktop/src/renderer/src/components/ToolCard.tsx index 73567cb..9a99f4a 100644 --- a/apps/desktop/src/renderer/src/components/ToolCard.tsx +++ b/apps/desktop/src/renderer/src/components/ToolCard.tsx @@ -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 ( +
+
+ + + + + {toolName} + 等待你的审批… +
+ {argsText && argsText !== 'undefined' && ( +
+          {argsText.length > 500 ? `${argsText.slice(0, 500)}…` : argsText}
+        
+ )} +
+ ) +} + /** 流式消息的入场动画(GSAP),仅挂载时执行一次 */ export function useEntrance(): RefObject { const ref = useRef(null) diff --git a/apps/desktop/src/renderer/src/hooks/useAgent.ts b/apps/desktop/src/renderer/src/hooks/useAgent.ts index e0e66d8..fad542b 100644 --- a/apps/desktop/src/renderer/src/hooks/useAgent.ts +++ b/apps/desktop/src/renderer/src/hooks/useAgent.ts @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 45f8d03..3938989 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,6 +63,24 @@ 4. **工具卡片在审批等待期间无"等待审批"状态**——ToolCard 显示 running,但用户看不出是在等他确认;应联动 approval 状态显示"等待审批中"。排 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 — 骨架 + 聊天链路 **交付**: