v0.5.0: 打磨(Markdown/审批倒计时/会话刷新/设置过滤/快捷键)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,9 +70,10 @@ 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">
|
||||||
|
{active && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="重命名"
|
title="重命名当前会话"
|
||||||
className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -81,6 +82,7 @@ function SessionRow({
|
|||||||
>
|
>
|
||||||
<Pencil size={12} />
|
<Pencil size={12} />
|
||||||
</button>
|
</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
|
||||||
|
|||||||
@@ -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 — 骨架 + 聊天链路
|
||||||
|
|
||||||
**交付**:
|
**交付**:
|
||||||
|
|||||||
Reference in New Issue
Block a user