v0.6.0: beautifului 整合 + 会话/工作区/队列/权限/命令/附件全链路
- beautifului.dev 组件直连:Sidebar Nav、Tool Chips、Approval Card(抽屉化)、Streaming/Thinking、Code Block、Selection Actions、Prompt Bar、Search
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg width="88" height="37" viewBox="0 0 88 37" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M58.1815 24.2302C49.8705 31.4996 40.4069 34.384 37.0466 30.6738C35.7262 29.2168 35.5446 26.9614 36.3107 24.3171C33.9472 28.59 33.4395 32.4443 35.3746 34.5808C38.7372 38.291 48.2009 35.4042 56.5095 28.1371C61.5557 23.7224 65.0184 18.706 66.2037 14.6161C64.4596 17.7682 61.7094 21.1467 58.1815 24.2302Z" fill="#FF6800"/>
|
||||
<path d="M2.78042 11.1644H0.733527C0.328341 11.1644 0 11.4869 0 11.885V13.8956C0 14.2936 0.328341 14.6161 0.733527 14.6161H2.78042C3.1856 14.6161 3.51395 14.2936 3.51395 13.8956V11.885C3.51395 11.4869 3.1856 11.1644 2.78042 11.1644Z" fill="#FF6800"/>
|
||||
<path d="M2.78042 15.7461H0.733527C0.328341 15.7461 0 16.0686 0 16.4667V26.8676C0 27.2656 0.328341 27.5881 0.733527 27.5881H2.78042C3.1856 27.5881 3.51395 27.2656 3.51395 26.8676V16.4689C3.51395 16.0709 3.1856 15.7461 2.78042 15.7461Z" fill="#FF6800"/>
|
||||
<path d="M10.5791 15.7553C9.92936 15.7644 9.30761 15.8879 8.73243 16.103C8.60435 15.8902 8.36916 15.7461 8.09904 15.7461H5.89612C5.49094 15.7461 5.1626 16.0686 5.1626 16.4667V21.2382V21.3572V26.9065C5.1626 27.3045 5.49094 27.627 5.89612 27.627H8.10136C8.50655 27.627 8.83489 27.3045 8.83489 26.9065V21.3595V21.1582C8.83489 20.1448 9.69417 19.3237 10.7351 19.3625C11.7248 19.3991 12.4955 20.2295 12.4955 21.2039V26.9362C12.4955 27.3342 12.8239 27.6567 13.2291 27.6567H15.432C15.8372 27.6567 16.1655 27.3342 16.1655 26.9362V21.1582C16.1655 18.1502 13.6529 15.7095 10.5791 15.7553Z" fill="#FF6800"/>
|
||||
<path d="M82.2036 15.7553C81.5981 15.7644 81.0183 15.8719 80.4757 16.0618V9.46715C80.4757 9.06914 80.1474 8.74661 79.7422 8.74661H77.5393C77.1341 8.74661 76.8057 9.06914 76.8057 9.46715V20.401C76.8057 20.4811 76.8127 20.5589 76.822 20.6343C76.7988 20.8334 76.7871 21.0346 76.7871 21.2382V26.9065C76.7871 27.3045 77.1155 27.627 77.5206 27.627H79.7235C80.1287 27.627 80.4571 27.3045 80.4571 26.9065V21.1582C80.4571 20.1448 81.3163 19.3237 82.3573 19.3625C83.3469 19.3991 84.1177 20.2295 84.1177 21.2039V26.9362C84.1177 27.3342 84.4461 27.6567 84.8512 27.6567H87.0542C87.4593 27.6567 87.7877 27.3342 87.7877 26.9362V21.1582C87.7877 18.1502 85.2751 15.7095 82.2036 15.7553Z" fill="#FF6800"/>
|
||||
<path d="M28.0928 15.8445H25.8899C25.4847 15.8445 25.1563 16.167 25.1563 16.565V17.2856C25.1563 18.2348 24.4042 19.0148 23.4564 19.0835C23.3842 19.0812 23.3097 19.0812 23.2375 19.0812C23.2189 19.0812 23.2026 19.0835 23.184 19.0835C22.7066 19.0492 22.1943 18.8341 21.7099 18.3744C21.5679 18.2394 21.4864 18.0518 21.4864 17.8574V9.46714C21.4864 9.06913 21.158 8.7466 20.7528 8.7466H18.5499C18.1447 8.7466 17.8164 9.06913 17.8164 9.46714V16.565V19.5798V24.5687V27.0094C17.8164 27.4074 18.1447 27.7299 18.5499 27.7299H20.7528C21.158 27.7299 21.4864 27.4074 21.4864 27.0094V26.9545V24.4864C21.4864 23.5394 22.2362 22.7594 23.1816 22.6907C23.2701 22.693 23.3586 22.693 23.4471 22.6907C24.4135 22.7571 25.1563 23.5737 25.1563 24.5321V27.0117C25.1563 27.4097 25.4847 27.7322 25.8899 27.7322H28.0928C28.498 27.7322 28.8263 27.4097 28.8263 27.0117V24.4887C28.8263 23.107 28.2954 21.8467 27.4245 20.8882C28.2954 19.9321 28.8263 18.6694 28.8263 17.2856V16.565C28.8263 16.167 28.498 15.8445 28.0928 15.8445Z" fill="#FF6800"/>
|
||||
<path d="M36.0501 19.3923C36.0897 19.3946 36.127 19.3991 36.1642 19.4014C36.8675 18.395 37.6546 17.3908 38.5185 16.398C37.7337 15.9954 36.8395 15.7713 35.8941 15.7873C35.2793 15.7964 34.6925 15.9085 34.1453 16.1007C34.0871 15.9085 33.9101 15.769 33.6959 15.769H31.2298C30.8246 15.769 30.4963 16.0915 30.4963 16.4895V20.8562C30.487 20.9935 30.48 21.1307 30.48 21.2702V26.9385C30.48 27.1421 30.5661 27.3273 30.7059 27.4577C30.8386 27.5927 31.0249 27.6773 31.2321 27.6773H33.435C33.8402 27.6773 34.1686 27.3548 34.1686 26.9568V20.9546C34.2873 20.051 35.0907 19.358 36.0501 19.3923Z" fill="#FF6800"/>
|
||||
<path d="M37.6057 22.693C37.7664 23.6675 38.1879 24.5985 38.812 25.3693H38.8096C41.0428 28.1645 45.4603 28.4024 48.3292 26.0006C48.4829 25.8725 48.4992 25.6438 48.3688 25.4928L46.6735 23.5348C46.5478 23.3884 46.3266 23.3678 46.1705 23.4822C44.9131 24.404 43.616 24.619 42.298 23.958L46.457 22.2721L49.4702 21.0484C49.6589 20.9729 49.7474 20.7602 49.6682 20.5772C49.3515 19.8315 48.9486 18.8136 48.5178 18.2623C47.3954 16.7091 45.5651 15.7553 43.5764 15.7576H43.5787C39.8785 15.7301 36.9677 19.1178 37.6057 22.693ZM41.0219 21.3046C41.1709 19.3603 43.6789 18.3195 45.1762 19.621C44.1097 20.0533 42.0861 20.8745 41.0219 21.3068V21.3046Z" fill="#FF6800"/>
|
||||
<path d="M70.2929 19.2482C71.1336 18.9073 72.0767 19.0011 72.8195 19.4746C73.1059 19.6576 73.4878 19.6119 73.73 19.374L75.0667 18.061C75.3834 17.7499 75.3438 17.2375 74.9852 16.9722C73.9419 16.1967 72.6612 15.7713 71.3362 15.7736C68.5953 15.7736 66.1945 17.5829 65.4866 20.1837C64.7763 22.7845 65.9337 25.5294 68.3089 26.8767C70.435 28.0822 73.064 27.8969 74.9828 26.4764C75.3415 26.2111 75.3834 25.6987 75.0667 25.3853L73.73 24.0723C73.4878 23.8345 73.1083 23.7887 72.8195 23.9717C72.0767 24.4452 71.1336 24.539 70.2929 24.1982C69.273 23.7841 68.6093 22.8074 68.6093 21.7232C68.6093 20.6412 69.273 19.6622 70.2929 19.2482Z" fill="#FF6800"/>
|
||||
<path d="M62.7735 17.4708V16.1464C62.7735 15.9474 62.6082 15.785 62.4056 15.785H59.6531C59.4505 15.785 59.2852 15.9474 59.2852 16.1464V16.318C58.4562 15.936 57.5271 15.7324 56.5467 15.7576C53.3495 15.8445 50.7414 18.4087 50.6622 21.5516C50.604 23.7887 51.8056 25.7536 53.6196 26.815C54.551 26.1196 55.4709 25.3716 56.3744 24.5756C56.5048 24.4589 56.6352 24.3423 56.7656 24.2256C56.7493 24.2256 56.7353 24.2279 56.719 24.2279C55.2054 24.2279 53.9922 22.9355 54.1668 21.4144C54.2972 20.2569 55.2473 19.3237 56.4256 19.1956C57.9742 19.0263 59.2899 20.2157 59.2899 21.7026C59.2899 21.7209 59.2899 21.7392 59.2875 21.7598C60.6079 20.3553 61.7815 18.9119 62.7735 17.4708Z" fill="#FF6800"/>
|
||||
<path d="M59.2876 27.0002V27.5675H62.408C62.6106 27.5675 62.7759 27.4051 62.7759 27.2061V23.2466C61.572 24.5962 60.7127 25.7444 59.2876 27.0002Z" fill="#FF6800"/>
|
||||
<path d="M44.3191 10.9059C52.332 3.25221 61.9866 0.241964 65.8848 4.18091C67.5847 5.89876 67.9084 8.65282 67.0724 11.8484C69.0355 7.53885 69.2055 3.70284 67.1167 1.59154C63.3861 -2.17584 53.8666 0.973939 45.856 8.62537C43.2293 11.1347 41.0892 13.8178 39.5337 16.4209C40.8564 14.5498 42.4631 12.681 44.3191 10.9059Z" fill="#FF6800"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/renderer/src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src/renderer/src'),
|
||||
'@renderer': resolve(__dirname, 'src/renderer/src'),
|
||||
'@shared': resolve(__dirname, 'src/shared')
|
||||
}
|
||||
|
||||
Generated
+2365
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"name": "@aso/desktop",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"description": "店铺运营 Agent 桌面端(Electron for pi agent)",
|
||||
"description": "Inkreach Agent 桌面端(Electron for pi agent)",
|
||||
"type": "module",
|
||||
"main": "out/main/index.mjs",
|
||||
"scripts": {
|
||||
@@ -16,10 +16,20 @@
|
||||
"@earendil-works/pi-ai": "0.85.0",
|
||||
"@earendil-works/pi-coding-agent": "0.85.0",
|
||||
"@earendil-works/pi-server": "0.85.0",
|
||||
"@llm-ui/code": "^0.13.3",
|
||||
"@llm-ui/markdown": "^0.13.3",
|
||||
"@llm-ui/react": "^0.13.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"gsap": "3.15.0",
|
||||
"lucide-react": "1.41.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"react-icons": "5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadow-plugin": "2.1.0",
|
||||
"shiki": "^1.29.2",
|
||||
"tailwind-merge": "3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>beautifului primitives preview</title>
|
||||
</head>
|
||||
<body class="bg-zinc-50 text-zinc-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,210 @@
|
||||
// beautifului.dev 组件视觉验收页(静态构建,模拟聊天流内的真实用法)
|
||||
import { useRef, useState } from 'react'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './preview.css'
|
||||
import { Thinking } from '../src/renderer/src/components/Thinking'
|
||||
import { StreamingText } from '../src/renderer/src/components/StreamingText'
|
||||
import { ApprovalStack } from '../src/renderer/src/components/ApprovalCard'
|
||||
import { ToolChips } from '../src/renderer/src/components/ToolChips'
|
||||
import { SidebarNav } from '../src/renderer/src/components/SidebarNav'
|
||||
import { Search } from '../src/renderer/src/components/Search'
|
||||
import { CodeBlock } from '../src/renderer/src/components/CodeBlock'
|
||||
import { SelectionActions } from '../src/renderer/src/components/SelectionActions'
|
||||
import { Loading } from '../src/renderer/src/components/Loading'
|
||||
import { Markdown } from '../src/renderer/src/components/Markdown'
|
||||
import type { ApprovalRequest, SessionMeta } from '../src/shared/types'
|
||||
|
||||
const thinkingRows = [
|
||||
{ primary: '用户想了解今天的品类热点,我先查一下最近的趋势数据。' },
|
||||
{ primary: '需要调用 query_trend 工具获取近 7 天的热点词,再按类目分组。' },
|
||||
{ primary: '结果里先给 TOP3 品类,附上建议的标题关键词。' }
|
||||
]
|
||||
|
||||
const toolCalls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'query_trend',
|
||||
tool: {
|
||||
toolName: 'query_trend',
|
||||
args: { category: 'home', days: 7 },
|
||||
status: 'done' as const,
|
||||
result: { content: [{ type: 'text', text: '{ "top": ["收纳", "香薰", "桌布"], "lift": [0.32, 0.21, 0.18] }' }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'draft_title',
|
||||
tool: {
|
||||
toolName: 'draft_title',
|
||||
args: { keywords: ['收纳', '灵感'] },
|
||||
status: 'error' as const,
|
||||
result: { content: [{ type: 'text', text: 'Error: rate limited, retry after 30s' }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'call-3',
|
||||
name: 'update_price',
|
||||
pendingApproval: { args: { sku: 'SKU-1024', from: 39.9, to: 35.9 } }
|
||||
}
|
||||
]
|
||||
|
||||
const approvalRequest: ApprovalRequest = {
|
||||
id: 'approval-1',
|
||||
toolName: 'update_price',
|
||||
args: { sku: 'SKU-1024', from: 39.9, to: 35.9 }
|
||||
}
|
||||
|
||||
const sessions: SessionMeta[] = [
|
||||
{ id: 's1', path: 'a.json', name: '供应商记录整理', firstMessage: '帮我整理供应商', created: '', modified: new Date().toISOString(), messageCount: 12 },
|
||||
{ id: 's2', path: 'b.json', name: '', firstMessage: '根据热点词起草一个商品标题方案', created: '', modified: new Date(Date.now() - 86400000).toISOString(), messageCount: 5 },
|
||||
{ id: 's3', path: 'c.json', name: '', firstMessage: '查询最近一次上架任务的执行结果', created: '', modified: new Date(Date.now() - 3 * 86400000).toISOString(), messageCount: 8 }
|
||||
]
|
||||
|
||||
const CODE_SNIPPET = `export async function churnBatch() {
|
||||
const flavor = await getFlavor("pistachio");
|
||||
const base = await dairy.fetch({ flavor });
|
||||
await freezer.store(base, { temp: "-16C" });
|
||||
if (!base.approved) return null;
|
||||
return base.gallons;
|
||||
}`
|
||||
|
||||
const DIFF_SNIPPET = `@@ -1,4 +1,5 @@
|
||||
export const hero = "mint-chip";
|
||||
-const windows = slots;
|
||||
+const windows = slots.filter(
|
||||
+ (s) => s.temp <= -12,
|
||||
+);
|
||||
schedule(windows);`
|
||||
|
||||
const PARA =
|
||||
'百香果风味在整个周末保持热销榜首。周六一早就要开始搅拌,让批次在下午高峰前有足够时间固化。同时需要预留两个晚间冷冻窗口,避免与常规生产冲突。'
|
||||
|
||||
function PreviewCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mb-8 rounded-2xl border border-zinc-200 bg-white p-5">
|
||||
<h2 className="mb-3 text-[12px] font-medium tracking-wide text-zinc-400 uppercase">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [applied, setApplied] = useState('')
|
||||
const selectionBoxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 调试分区:URL hash 指定只渲染某一节(#thinking / #streaming / #tools /
|
||||
// #approval / #code / #selection / #loading / #sidebar / #none),空 hash 全渲染
|
||||
const only = window.location.hash.slice(1)
|
||||
const show = (id: string): boolean => only === '' || only === id
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h1 className="mb-6 text-[14px] font-semibold text-zinc-900">beautifului.dev · 8 组件直连验收(light)</h1>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_240px] items-start gap-6">
|
||||
<div className="min-w-0">
|
||||
{show('thinking') && (
|
||||
<PreviewCard title="Thinking · 流式中 / 已完成">
|
||||
<Thinking rows={thinkingRows} streaming />
|
||||
<div className="mt-4">
|
||||
<Thinking rows={thinkingRows} streaming={false} />
|
||||
</div>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('streaming') && (
|
||||
<PreviewCard title="Streaming Text · 流式正文 + Follow-ups">
|
||||
<StreamingText streaming={false} text="已完成的回答文本。" followUps={['冬季哪些口味卖得最好', '对比凝胶和软冰的毛利']} onFollowUp={(q) => setApplied(q)}>
|
||||
<Markdown text={'夏季核果风味需求激增——**桃子和杏子**领先。建议先盘点蛋筒库存,再推广华夫碗套餐。'} />
|
||||
</StreamingText>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('tools') && (
|
||||
<PreviewCard title="Tool Chips · 真实工具调用(含待审批行)">
|
||||
<div className="w-fit max-w-full rounded-card bg-inset p-3">
|
||||
<ToolChips calls={toolCalls} thinking={['先查近 7 天热点词,再按类目分组给出 TOP3。']} />
|
||||
</div>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('approval') && (
|
||||
<PreviewCard title="Approval Card · 单题向导(允许/拒绝 + 倒计时)">
|
||||
<ApprovalStack requests={[approvalRequest]} onResolve={(_id, approved) => setApplied(approved ? '已允许' : '已拒绝')} />
|
||||
<div className="h-2" />
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('code') && (
|
||||
<PreviewCard title="Code Block · 行号代码 / unified diff">
|
||||
<CodeBlock code={CODE_SNIPPET} lang="ts" filename="churn.ts" />
|
||||
<div className="mt-4">
|
||||
<CodeBlock code={DIFF_SNIPPET} lang="diff" />
|
||||
</div>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('selection') && (
|
||||
<PreviewCard title="Selection Actions · 划选文本试试">
|
||||
<div ref={selectionBoxRef} className="relative pb-10">
|
||||
<p className="text-[13px] leading-relaxed text-ink">{PARA}</p>
|
||||
<SelectionActions containerRef={selectionBoxRef} onApply={(instruction, selection) => setApplied(`${instruction}:${selection.slice(0, 18)}…`)} />
|
||||
</div>
|
||||
{applied && <p className="mt-2 font-mono text-[11px] text-zinc-500">onApply → {applied}</p>}
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('loading') && (
|
||||
<PreviewCard title="Loading(附带)">
|
||||
<Loading label="思考中" variant="Dots" />
|
||||
</PreviewCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{show('sidebar') && (
|
||||
<div className="sticky top-8">
|
||||
<PreviewCard title="Sidebar Nav · 可折叠">
|
||||
<div className="h-[560px] overflow-hidden rounded-xl border border-zinc-200">
|
||||
<SidebarNav
|
||||
sessionState={{ sessionId: 's1', streaming: false, activeTools: [], messageCount: 12 }}
|
||||
sessions={sessions}
|
||||
workspaces={[{ name: 'Default', path: 'C:/workspaces/Default' }]}
|
||||
activeWorkspace={{ name: 'Default', path: 'C:/workspaces/Default' }}
|
||||
onCreateWorkspace={() => undefined}
|
||||
onOpenWorkspace={() => undefined}
|
||||
onNewSession={() => setApplied('新会话')}
|
||||
onOpenSession={() => setApplied('打开会话')}
|
||||
onRenameSession={() => setApplied('重命名')}
|
||||
onDeleteSession={() => setApplied('删除')}
|
||||
onRefreshSessions={() => setApplied('刷新')}
|
||||
onOpenSearch={() => setSearchOpen(true)}
|
||||
onOpenSettings={() => setApplied('设置')}
|
||||
/>
|
||||
</div>
|
||||
</PreviewCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{show('sidebar') && (
|
||||
<Search
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
sessions={sessions}
|
||||
onNewSession={() => setApplied('新会话')}
|
||||
onRefreshSessions={() => setApplied('刷新')}
|
||||
onOpenSettings={() => setApplied('设置')}
|
||||
onOpenSession={() => setApplied('打开会话')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
/* 预览页专用入口:复用应用全套样式(tailwind + beautifului foundation + 字体),
|
||||
* 并显式声明组件源码目录参与工具类扫描(vite root 在 preview/ 下,
|
||||
* 自动检测覆盖不到 ../src)。 */
|
||||
@import "../src/renderer/src/styles/globals.css";
|
||||
|
||||
@source "../src/renderer/src";
|
||||
@@ -0,0 +1,263 @@
|
||||
// 真实 App 渲染层截图(离屏,注入假 IPC 数据):
|
||||
// npx electron scripts/capture-app.cjs
|
||||
// 产出 preview-shots/app-*.png:展开态 / 收起态 / markdown 渲染
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const { mkdirSync, writeFileSync, existsSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
const ROOT = join(__dirname, '..')
|
||||
const OUT = join(ROOT, 'preview-shots')
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const MARKDOWN_SAMPLE = [
|
||||
'## 1. "这是演示数据,仅用于验证链路"',
|
||||
'',
|
||||
'我配置的 `hotword_lookup` 工具目前连的是**演示实现(demo)**,[项目主页](https://example.com)。',
|
||||
'',
|
||||
'| 说明 | 结论 |',
|
||||
'| :--- | ---: |',
|
||||
'| **工具可用** | 工具这个"壳"是好的 —— 链路没有问题 |',
|
||||
'| **数据源是空的** | 它背后的"仓库"是空的,查询返回"无匹配项" |',
|
||||
'',
|
||||
'- 列表项一,包含 `inline code`',
|
||||
'- 列表项二,**加粗** 与 ~~删除线~~',
|
||||
'',
|
||||
'```python',
|
||||
"print('Hello llm-ui!')",
|
||||
'for i in range(3):',
|
||||
' print(i)',
|
||||
'```',
|
||||
'',
|
||||
'```rust',
|
||||
'fn main() {',
|
||||
' println!("Hello, World!");',
|
||||
'}',
|
||||
'```'
|
||||
].join('\n')
|
||||
|
||||
const FAKE_STATE = {
|
||||
sessionId: 's1',
|
||||
sessionName: 'hi',
|
||||
streaming: false,
|
||||
activeTools: [],
|
||||
messageCount: 2,
|
||||
workspace: 'C:\\fake\\workspace'
|
||||
}
|
||||
|
||||
const FAKE_SESSIONS = [
|
||||
{ id: 's1', path: 'C:\\fake\\s1.jsonl', name: 'hi', firstMessage: '帮我写一个hello world', created: '2026-01-01', modified: '2026-01-02', messageCount: 2 },
|
||||
{ id: 's2', path: 'C:\\fake\\s2.jsonl', name: 'hello world', firstMessage: '帮我写一个hello world', created: '2026-01-01', modified: '2026-01-02', messageCount: 1 }
|
||||
]
|
||||
|
||||
function registerFakeIpc() {
|
||||
ipcMain.handle('agent:state', () => FAKE_STATE)
|
||||
ipcMain.handle('sessions:list', () => FAKE_SESSIONS)
|
||||
ipcMain.handle('workspaces:list', () => [{ name: 'Default', path: 'C:\\fake\\workspace' }])
|
||||
ipcMain.handle('models:catalog', () => ({ providers: [] }))
|
||||
ipcMain.handle('settings:get', () => ({ systemPrompt: '' }))
|
||||
ipcMain.handle('app:versions', () => ({ app: 'dev', electron: 'x', node: 'x', chrome: 'x', user: 'x' }))
|
||||
ipcMain.handle('agent:send', () => undefined)
|
||||
ipcMain.handle('chat:resources', () => ({
|
||||
commands: [{ name: 'daily-report', description: '生成每日经营日报', kind: 'prompt' }],
|
||||
skills: [{ name: 'hotword-analysis', description: '分析近 7 天类目热点词' }]
|
||||
}))
|
||||
ipcMain.handle('files:list', () => [
|
||||
{ path: 'data/products.csv', name: 'products.csv', isDir: false },
|
||||
{ path: 'docs/sop.md', name: 'sop.md', isDir: false },
|
||||
{ path: 'hello_world.py', name: 'hello_world.py', isDir: false },
|
||||
{ path: 'assets', name: 'assets', isDir: true }
|
||||
])
|
||||
}
|
||||
|
||||
async function capture(win, name) {
|
||||
// 离屏窗口 capturePage 常给旧帧:连拍多张,取最后一张(大概率已提交新帧)
|
||||
let image = null
|
||||
for (let k = 0; k < 4; k += 1) {
|
||||
win.webContents.invalidate()
|
||||
await sleep(320)
|
||||
image = await win.webContents.capturePage()
|
||||
}
|
||||
writeFileSync(join(OUT, name), image.toPNG())
|
||||
console.log('saved', name, `${image.getSize().width}x${image.getSize().height}`)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
registerFakeIpc()
|
||||
|
||||
const preload = join(ROOT, 'out', 'preload', 'index.cjs')
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
paintWhenInitiallyHidden: true,
|
||||
backgroundThrottling: false,
|
||||
preload: existsSync(preload) ? preload : undefined,
|
||||
contextIsolation: true
|
||||
}
|
||||
})
|
||||
await win.loadFile(join(ROOT, 'out', 'renderer', 'index.html'))
|
||||
// 清掉历史 localStorage(收起态持久化),再重载让侧栏以展开态启动
|
||||
await win.webContents.executeJavaScript('try { localStorage.clear() } catch {}')
|
||||
await win.reload()
|
||||
await sleep(1200)
|
||||
|
||||
// 注入一条含 markdown 的会话(user + assistant)
|
||||
win.webContents.send('agent:event', {
|
||||
type: 'session_switched',
|
||||
state: FAKE_STATE,
|
||||
messages: [
|
||||
{ role: 'user', content: '解释一下那两句话的意思' },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: MARKDOWN_SAMPLE }] }
|
||||
],
|
||||
activeTools: {}
|
||||
})
|
||||
await sleep(1200)
|
||||
console.log(
|
||||
'hl-diag:',
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => { const codes = [...document.querySelectorAll('code')]; return JSON.stringify({ ready: window.__shikiReady, codes: codes.map((c) => c.querySelectorAll('span[style]').length) }) })()`
|
||||
)
|
||||
)
|
||||
await capture(win, 'app-markdown.png')
|
||||
|
||||
// 注入排队消息场景(streaming + 队列两条)
|
||||
win.webContents.send('agent:event', { type: 'agent_start' })
|
||||
win.webContents.send('agent:event', {
|
||||
type: 'queue_update',
|
||||
steering: ['设置里面做成这样,分成基础设置、Agent 能力'],
|
||||
followUp: ['随后帮我补上skills、命令和附件的支持,chatbox里面/调起命令或skill,@则是引用文件,加号是附件']
|
||||
})
|
||||
await sleep(900)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
FAKE_STATE.permissionMode = 'confirm'
|
||||
await capture(win, 'app-queue.png')
|
||||
|
||||
// 权限模式菜单
|
||||
await win.webContents.executeJavaScript(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
|
||||
win.webContents.send('agent:event', { type: 'agent_end' })
|
||||
win.webContents.send('agent:event', { type: 'queue_update', steering: [], followUp: [] })
|
||||
await sleep(500)
|
||||
await win.webContents.executeJavaScript(
|
||||
`[...document.querySelectorAll('button')].find((b) => b.title.startsWith('权限模式'))?.click()`
|
||||
)
|
||||
await sleep(500)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
console.log(
|
||||
'perm-diag:',
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => { const panels = [...document.querySelectorAll('div.fixed.z-40')]; const m = panels.find((d) => d.textContent.includes('减少确认次数')); if (!m) return 'closed'; const r = m.getBoundingClientRect(); const cs = getComputedStyle(m); return JSON.stringify({ x: r.x, y: r.y, w: r.width, h: r.height, opacity: cs.opacity, display: cs.display, zIndex: cs.zIndex, buttons: m.querySelectorAll('button').length }) })()`
|
||||
)
|
||||
)
|
||||
await capture(win, 'app-permissions.png')
|
||||
// 结束流式,恢复常规状态
|
||||
win.webContents.send('agent:event', { type: 'agent_end' })
|
||||
win.webContents.send('agent:event', { type: 'queue_update', steering: [], followUp: [] })
|
||||
await sleep(400)
|
||||
|
||||
// "/" 命令/技能面板
|
||||
const typeInPrompt = (text) =>
|
||||
win.webContents.executeJavaScript(
|
||||
`(() => { const ta = document.querySelector('textarea[aria-label="Prompt"]'); if (!ta) return 'no-ta'; const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; set.call(ta, ${JSON.stringify(text)}); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'ok' })()`
|
||||
)
|
||||
await typeInPrompt('/')
|
||||
await sleep(600)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-slash-menu.png')
|
||||
// "@ " 文件引用
|
||||
await typeInPrompt('帮我看看 @')
|
||||
await sleep(600)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-at-menu.png')
|
||||
await typeInPrompt('')
|
||||
|
||||
// 注入审批卡
|
||||
win.webContents.send('agent:event', {
|
||||
type: 'approval_request',
|
||||
request: {
|
||||
id: 'approval-1',
|
||||
toolName: 'write',
|
||||
args: { path: 'hello_world.py', content: 'def main():\n print("Hello world")\n\nmain()' }
|
||||
}
|
||||
})
|
||||
await sleep(800)
|
||||
win.webContents.invalidate()
|
||||
await sleep(400)
|
||||
await capture(win, 'app-approval.png')
|
||||
|
||||
// 行内操作浮层:悬停操作按钮点击后弹出(重命名 / 删除)
|
||||
await win.webContents.executeJavaScript(
|
||||
`[...document.querySelectorAll('button')].find((b) => b.title === '重命名会话')?.click()`
|
||||
)
|
||||
await sleep(500)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-row-rename.png')
|
||||
await win.webContents.executeJavaScript(
|
||||
`[...document.querySelectorAll('button')].find((b) => b.title === '删除会话')?.click()`
|
||||
)
|
||||
await sleep(500)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-row-delete.png')
|
||||
await win.webContents.executeJavaScript(`document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`)
|
||||
|
||||
// 设置页:Ctrl+, 打开,截「模型设置」与「常规」
|
||||
await win.webContents.executeJavaScript(
|
||||
`window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', ctrlKey: true, bubbles: true }))`
|
||||
)
|
||||
await sleep(600)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-settings-model.png')
|
||||
await win.webContents.executeJavaScript(
|
||||
`[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '常规')?.click()`
|
||||
)
|
||||
await sleep(400)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-settings-general.png')
|
||||
await win.webContents.executeJavaScript(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
|
||||
|
||||
console.log(
|
||||
'diag:',
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => { const btn = document.querySelector('button[aria-label="拒绝并关闭"]'); if (!btn) return 'missing'; const card = btn.closest('div[class*="rounded-card"]') ?? btn.parentElement; const r = card.getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, w: r.width, h: r.height, winH: innerHeight, opacity: getComputedStyle(card).opacity }) })()`
|
||||
)
|
||||
)
|
||||
|
||||
// 侧栏收起回归检查
|
||||
const collapsed = await win.webContents.executeJavaScript(
|
||||
`(() => { const b = document.querySelector('[aria-label="折叠侧栏"]'); if (!b) return 'no-button'; b.click(); return 'clicked' })()`
|
||||
)
|
||||
console.log('collapse:', collapsed)
|
||||
await sleep(800)
|
||||
const geo = await win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const aside = document.querySelector('aside[aria-label="工作区导航"]')
|
||||
const row = document.querySelector('.sidebar-row')
|
||||
const icon = row ? row.querySelector('span') : null
|
||||
if (!aside || !row || !icon) return 'missing'
|
||||
const a = aside.getBoundingClientRect()
|
||||
const i = icon.getBoundingClientRect()
|
||||
return JSON.stringify({ rail: Math.round(a.width), iconCenter: Math.round(i.left - a.left + i.width / 2) })
|
||||
})()`
|
||||
)
|
||||
console.log('collapsed-geo:', geo)
|
||||
await capture(win, 'app-collapsed.png')
|
||||
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(run).catch((err) => {
|
||||
console.error(err)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
// 预览页视觉验收截图(Electron 离屏捕获):
|
||||
// npx electron scripts/capture-preview.cjs
|
||||
// 产出 preview-shots/*.png:整页 / 侧栏折叠 / 搜索面板 / 选区工具条
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { mkdirSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
// 离屏捕获在 GPU 合成异常时会得到空白帧,改用软件合成
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
const URL = 'http://localhost:4173/'
|
||||
const OUT = join(__dirname, '..', 'preview-shots')
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
async function capture(win, name) {
|
||||
try {
|
||||
const image = await win.webContents.capturePage()
|
||||
writeFileSync(join(OUT, name), image.toPNG())
|
||||
console.log('saved', name, `${image.getSize().width}x${image.getSize().height}`)
|
||||
} catch (e) {
|
||||
console.log('capture failed', name, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function js(win, code) {
|
||||
try {
|
||||
return await win.webContents.executeJavaScript(code)
|
||||
} catch (e) {
|
||||
console.log('js failed:', e.message)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 960,
|
||||
show: false,
|
||||
webPreferences: { paintWhenInitiallyHidden: true }
|
||||
})
|
||||
await win.loadURL(URL)
|
||||
await sleep(1500)
|
||||
|
||||
// 1) 整页(上半)
|
||||
await capture(win, '01-full.png')
|
||||
|
||||
// 2) 折叠侧栏(等宽度过渡)
|
||||
await js(win, `document.querySelector('[aria-label="折叠侧栏"]')?.click()`)
|
||||
await sleep(700)
|
||||
await capture(win, '02-sidebar-collapsed.png')
|
||||
await js(win, `document.querySelector('[aria-label="展开侧栏"]')?.click()`)
|
||||
await sleep(700)
|
||||
|
||||
// 3) 搜索面板(侧栏「搜索」行)+ 输入过滤
|
||||
await js(win, `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '搜索')?.click()`)
|
||||
await sleep(600)
|
||||
await js(win, `(() => { const i = document.querySelector('input[aria-label="搜索会话或命令"]'); if (!i) return 'no-input'; const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; set.call(i, '供应'); i.dispatchEvent(new Event('input', { bubbles: true })); return 'ok' })()`)
|
||||
await sleep(400)
|
||||
await capture(win, '03-search-filtered.png')
|
||||
await js(win, `document.querySelector('input[aria-label="搜索会话或命令"]')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
|
||||
await sleep(400)
|
||||
|
||||
// 4) 划选文本 → Selection Actions 工具条(展开「更多」)
|
||||
await js(win, `(() => {
|
||||
const p = [...document.querySelectorAll('p')].find((el) => el.textContent.includes('百香果'));
|
||||
if (!p) return 'no-p';
|
||||
const textNode = p.firstChild;
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, 30);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
return 'ok';
|
||||
})()`)
|
||||
await sleep(600)
|
||||
await js(win, `document.querySelector('[aria-label="更多操作"]')?.click()`)
|
||||
await sleep(500)
|
||||
await capture(win, '04-selection-actions.png')
|
||||
|
||||
// 5) 审批卡交互:选择「允许执行」(radio 自动前进 480ms 后提交)
|
||||
await js(win, `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '允许执行')?.click()`)
|
||||
await sleep(900)
|
||||
await capture(win, '05-approval-approved.png')
|
||||
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(run).catch((err) => {
|
||||
console.error(err)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// 验证侧栏折叠交互
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
app.commandLine.appendSwitch('disable-software-rasterizer')
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 1280, height: 900, show: false, webPreferences: { offscreen: true } })
|
||||
await win.loadURL('http://127.0.0.1:4173/')
|
||||
await new Promise((r) => setTimeout(r, 1200))
|
||||
const found = await win.webContents.executeJavaScript(`document.querySelector('[aria-label="折叠侧栏"]') ? 'found' : 'NOT-FOUND'`)
|
||||
console.log('collapse btn:', found)
|
||||
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="折叠侧栏"]').click()`)
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
console.log(
|
||||
'after click: collapsed=',
|
||||
await win.webContents.executeJavaScript(`document.querySelector('aside')?.getAttribute('data-sidebar-collapsed')`),
|
||||
'width=',
|
||||
await win.webContents.executeJavaScript(`document.querySelector('aside')?.style.width`)
|
||||
)
|
||||
app.exit(0)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
// 预览页控制台诊断:node 调试某一分区
|
||||
// npx electron scripts/debug-preview.cjs "#thinking"
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
|
||||
const hash = process.argv[2] ?? ''
|
||||
const URL = `http://127.0.0.1:4173/${hash}`
|
||||
|
||||
// 兜底退出(渲染进程卡死时 executeJavaScript 永不返回)
|
||||
const guard = setTimeout(() => {
|
||||
console.log('[guard] hard exit (page busy or crashed)')
|
||||
app.exit(2)
|
||||
}, 12000)
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
show: false,
|
||||
webPreferences: { offscreen: true }
|
||||
})
|
||||
win.webContents.on('console-message', (_e, level, message, line, source) => {
|
||||
console.log(`[console:${level}]`, message, `(${source}:${line})`)
|
||||
})
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.log(`[did-fail-load] ${code} ${desc} ${url}`)
|
||||
})
|
||||
win.webContents.on('render-process-gone', (_e, details) => {
|
||||
console.log('[crashed]', details.reason)
|
||||
})
|
||||
await win.loadURL(URL)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const text = await win.webContents.executeJavaScript('document.body.innerText.slice(0, 120)')
|
||||
console.log('[body]', JSON.stringify(text))
|
||||
} catch (e) {
|
||||
console.log('[executeJavaScript failed]', e.message)
|
||||
}
|
||||
clearTimeout(guard)
|
||||
app.exit(0)
|
||||
}, 2500)
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
// 对比 out/renderer 旧 bundle 与当前源码的 reducer 逻辑
|
||||
const fs = require('fs')
|
||||
const t = fs.readFileSync('apps/desktop/out/renderer/assets/index-DJXyfP9T.js', 'utf8')
|
||||
|
||||
console.log('has message_end:', t.includes('message_end'))
|
||||
console.log('has optimistic-user:', t.includes('optimistic-user'))
|
||||
console.log('has session_switched:', t.includes('session_switched'))
|
||||
console.log('has agent_settled:', t.includes('agent_settled'))
|
||||
console.log('has approval_request:', t.includes('approval_request'))
|
||||
|
||||
// reducer 里 message_end 附近的代码
|
||||
let i = t.indexOf('"message_end"')
|
||||
if (i < 0) i = t.indexOf('message_end')
|
||||
if (i >= 0) {
|
||||
console.log('\n=== around first message_end ===')
|
||||
console.log(t.slice(i - 500, i + 900))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// 生成应用图标(build/icon.ico + build/icon.png):
|
||||
// npx electron scripts/make-icons.cjs
|
||||
// 用 Electron 离屏渲染 build/icon.svg(居中于方形画布、留 9% 边距),
|
||||
// 输出多尺寸 PNG 并打包为 PNG 内嵌式 ICO(Vista+ 支持)。
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
const SIZES = [16, 24, 32, 48, 64, 128, 256]
|
||||
const ROOT = join(__dirname, '..')
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/** PNG 内嵌式 ICO 打包 */
|
||||
function buildIco(pngs) {
|
||||
const count = pngs.length
|
||||
const header = Buffer.alloc(6)
|
||||
header.writeUInt16LE(0, 0) // reserved
|
||||
header.writeUInt16LE(1, 2) // type: icon
|
||||
header.writeUInt16LE(count, 4)
|
||||
const entries = Buffer.alloc(16 * count)
|
||||
const blobs = []
|
||||
let offset = 6 + 16 * count
|
||||
pngs.forEach((png, i) => {
|
||||
const size = png.size
|
||||
entries.writeUInt8(size >= 256 ? 0 : size, i * 16 + 0) // width
|
||||
entries.writeUInt8(size >= 256 ? 0 : size, i * 16 + 1) // height
|
||||
entries.writeUInt8(0, i * 16 + 2) // palette
|
||||
entries.writeUInt8(0, i * 16 + 3) // reserved
|
||||
entries.writeUInt16LE(1, i * 16 + 4) // planes
|
||||
entries.writeUInt16LE(32, i * 16 + 6) // bpp
|
||||
entries.writeUInt32LE(png.data.length, i * 16 + 8)
|
||||
entries.writeUInt32LE(offset, i * 16 + 12)
|
||||
offset += png.data.length
|
||||
blobs.push(png.data)
|
||||
})
|
||||
return Buffer.concat([header, entries, ...blobs])
|
||||
}
|
||||
|
||||
async function renderSize(win, svgDataUrl, size) {
|
||||
await win.loadURL(
|
||||
'data:text/html,' +
|
||||
encodeURIComponent(
|
||||
`<!doctype html><html><body style="margin:0;background:transparent">` +
|
||||
`<div style="display:flex;align-items:center;justify-content:center;width:${size}px;height:${size}px">` +
|
||||
`<img src="${svgDataUrl}" style="width:${Math.round(size * 0.86)}px;height:auto;display:block"/>` +
|
||||
`</div></body></html>`
|
||||
)
|
||||
)
|
||||
await sleep(350)
|
||||
const image = await win.webContents.capturePage({ x: 0, y: 0, width: size, height: size })
|
||||
return image.toPNG()
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const svg = readFileSync(join(ROOT, 'build', 'icon.svg'), 'utf8')
|
||||
const svgDataUrl = 'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 256,
|
||||
height: 256,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
webPreferences: { paintWhenInitiallyHidden: true, offscreen: true }
|
||||
})
|
||||
|
||||
const pngs = []
|
||||
for (const size of SIZES) {
|
||||
await win.setSize ? win.setSize(size, size) : null
|
||||
const png = await renderSize(win, svgDataUrl, size)
|
||||
pngs.push({ size, data: png })
|
||||
console.log('rendered', size, `${png.length}B`)
|
||||
}
|
||||
|
||||
writeFileSync(join(ROOT, 'build', 'icon.png'), pngs.find((p) => p.size === 256).data)
|
||||
writeFileSync(join(ROOT, 'build', 'icon.ico'), buildIco(pngs))
|
||||
console.log('written build/icon.ico & build/icon.png')
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(run).catch((err) => {
|
||||
console.error(err)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// 打印 pi SDK 关键类型定义(skills / slash-commands / prompt 参数)
|
||||
const fs = require('node:fs')
|
||||
const files = [
|
||||
'dist/core/skills.d.ts',
|
||||
'dist/core/slash-commands.d.ts',
|
||||
'dist/modes/interactive/components/skill-invocation-message.d.ts'
|
||||
]
|
||||
for (const f of files) {
|
||||
console.log('=====', f)
|
||||
try {
|
||||
console.log(fs.readFileSync(f, 'utf8'))
|
||||
} catch (e) {
|
||||
console.log('READ FAIL', e.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 打印 ImageContent / PromptTemplate / AgentSessionServices 类型
|
||||
const fs = require('node:fs')
|
||||
const root = 'C:/Users/21976/Desktop/Develop/agent-store-operations/apps/desktop/node_modules'
|
||||
const files = [
|
||||
[root + '/@earendil-works/pi-ai/dist/compat.d.ts', /export (interface|type) (ImageContent|TextContent)[\s\S]*?\n\}/],
|
||||
[root + '/@earendil-works/pi-coding-agent/dist/core/prompt-templates.d.ts', null],
|
||||
[root + '/@earendil-works/pi-coding-agent/dist/core/sdk.d.ts', /export interface AgentSessionServices[\s\S]*?\n\}/]
|
||||
]
|
||||
for (const [f, re] of files) {
|
||||
console.log('=====', f.split('/').slice(-2).join('/'))
|
||||
try {
|
||||
const s = fs.readFileSync(f, 'utf8')
|
||||
if (re) {
|
||||
const m = s.match(re)
|
||||
console.log(m ? m[0] : '(no match)')
|
||||
} else {
|
||||
console.log(s.slice(0, 2500))
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('READ FAIL', e.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 验证 pi SDK 对空目录(无会话的工作区)的行为:
|
||||
// node scripts/repro-empty-workspace.mjs "C:\path\to\empty\dir"
|
||||
import { SessionManager, getAgentDir } from '@earendil-works/pi-coding-agent'
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const target = process.argv[2] ?? mkdtempSync(join(tmpdir(), 'ws-empty-'))
|
||||
mkdirSync(target, { recursive: true })
|
||||
console.log('target cwd:', target)
|
||||
|
||||
try {
|
||||
const sm = SessionManager.continueRecent(target)
|
||||
console.log('continueRecent OK, sessionFile:', sm.sessionFile ?? '(none)')
|
||||
const list = await SessionManager.list(target)
|
||||
console.log('list count:', list.length)
|
||||
} catch (err) {
|
||||
console.error('continueRecent FAILED:', err instanceof Error ? err.message : err)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('agentDir:', getAgentDir())
|
||||
@@ -0,0 +1,103 @@
|
||||
// 复现渲染层"输出一闪而过":用与 agent-service.ts 相同的 SDK 调用链,
|
||||
// 订阅全部事件并打印事件序列与消息对象结构。
|
||||
import os from 'node:os'
|
||||
import {
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
createAgentSessionFromServices,
|
||||
getAgentDir,
|
||||
SessionManager
|
||||
} from '@earendil-works/pi-coding-agent'
|
||||
|
||||
const cwd = os.homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
const services = await createAgentSessionServices({ cwd, agentDir })
|
||||
const factory = async (opts) => {
|
||||
const result = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager: opts.sessionManager,
|
||||
sessionStartEvent: opts.sessionStartEvent
|
||||
})
|
||||
return { ...result, services, diagnostics: services.diagnostics }
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.continueRecent(cwd)
|
||||
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager })
|
||||
const session = runtime.session
|
||||
|
||||
let rebindCount = 0
|
||||
runtime.setRebindSession(async (s) => {
|
||||
rebindCount++
|
||||
console.log(`\n>>> REBIND fired (#${rebindCount}) — 渲染层会收到 session_switched,messages.length=${s.messages.length}`)
|
||||
})
|
||||
|
||||
session.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case 'message_start':
|
||||
console.log('EVT message_start role=', JSON.stringify(event.message?.role))
|
||||
break
|
||||
case 'message_update': {
|
||||
const inner = event.assistantMessageEvent
|
||||
if (inner) {
|
||||
if (inner.type.endsWith('_delta')) {
|
||||
process.stdout.write(`.`)
|
||||
} else {
|
||||
console.log(`EVT message_update ${inner.type}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'message_end': {
|
||||
const m = event.message
|
||||
console.log('\nEVT message_end role=', m?.role)
|
||||
console.log(' message keys:', Object.keys(m ?? {}))
|
||||
try {
|
||||
JSON.stringify(m)
|
||||
console.log(' JSON.stringify: OK, length=', JSON.stringify(m).length)
|
||||
} catch (err) {
|
||||
console.log(' JSON.stringify FAILED ->', err.message, '(toCloneable 会变成 null → 渲染层消息变空!)')
|
||||
}
|
||||
if (m?.role === 'assistant') {
|
||||
console.log(' stopReason=', m.stopReason, ' errorMessage=', m.errorMessage)
|
||||
console.log(' content types=', (m.content ?? []).map((b) => b.type).join(','))
|
||||
for (const b of m.content ?? []) {
|
||||
if (b.type === 'text') console.log(' text block:', JSON.stringify(b.text).slice(0, 200))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'agent_end':
|
||||
console.log(`\nEVT agent_end willRetry=${event.willRetry} messages=${event.messages?.length}`)
|
||||
break
|
||||
default:
|
||||
console.log('EVT', event.type)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('--- session:', session.sessionId, 'messages:', session.messages.length)
|
||||
console.log('--- model:', session.model?.provider + '/' + session.model?.id)
|
||||
console.log('--- sending "hi" ...')
|
||||
|
||||
try {
|
||||
await session.prompt('hi')
|
||||
console.log('\n--- prompt resolved')
|
||||
} catch (err) {
|
||||
console.log('\n--- prompt THREW:', err?.message)
|
||||
}
|
||||
|
||||
// 等待 agent_settled 之后的异步收尾
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
|
||||
console.log('\n=== final session.messages ===')
|
||||
for (const [i, m] of session.messages.entries()) {
|
||||
const blocks = Array.isArray(m.content)
|
||||
? m.content.map((b) => b.type).join(',')
|
||||
: typeof m.content === 'string'
|
||||
? 'str:' + m.content.slice(0, 30)
|
||||
: '??'
|
||||
console.log(`#${i} ${m.role} [${blocks}]`)
|
||||
}
|
||||
|
||||
console.log('\nrebindCount=', rebindCount)
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,41 @@
|
||||
// 验证 Markdown.tsx 的 parseBlocks 是否产出空内容
|
||||
const INLINE = /^(\s*)([-*+]\s+|\d+[.)]\s+|>\s?|(#{1,3}\s+))?(.*)$/
|
||||
|
||||
function parseBlocks(text) {
|
||||
const blocks = []
|
||||
const lines = text.split('\n')
|
||||
let current = null
|
||||
const flush = () => {
|
||||
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 = marker
|
||||
? marker.startsWith('#')
|
||||
? 'h1'
|
||||
: isListItem
|
||||
? /^\d/.test(marker) ? 'ol' : 'ul'
|
||||
: marker.startsWith('>') ? 'quote' : null
|
||||
: 'p'
|
||||
if (kind === 'p') {
|
||||
if (!current || current.kind !== 'p') {
|
||||
flush()
|
||||
current = { kind: 'p', lines: [] }
|
||||
}
|
||||
current.lines.push(content)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
for (const t of ['pong', 'Hello world', '# 标题', '- 列表项', '> 引用', '1. 有序']) {
|
||||
const blocks = parseBlocks(t)
|
||||
const rendered = blocks.map((b) => b.lines.join('\n'))
|
||||
console.log(JSON.stringify(t), '->', JSON.stringify(rendered))
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 验证渲染层对 session_switched 历史消息的渲染逻辑:
|
||||
// 用与主进程一致的 session.messages 源,跑 MessageItem 的 readBlocks + AssistantBody 判断。
|
||||
import os from 'node:os'
|
||||
import { createAgentSessionServices, getAgentDir } from '@earendil-works/pi-coding-agent'
|
||||
|
||||
const cwd = os.homedir()
|
||||
const services = await createAgentSessionServices({ cwd, agentDir: getAgentDir() })
|
||||
|
||||
// SessionManager.list + 加载最近会话的等价做法:直接用 services 里加载当前会话
|
||||
const session = services.sessionManager
|
||||
? null
|
||||
: null
|
||||
|
||||
// 走 SessionManager 拿最近会话并加载
|
||||
const { SessionManager } = await import('@earendil-works/pi-coding-agent')
|
||||
const sm = SessionManager.continueRecent(cwd)
|
||||
const smAny = sm
|
||||
// SessionManager.continueRecent 返回的 manager 需要挂到 session 上才有 messages;
|
||||
// 更直接:读它指向的会话文件并解析 message 条目
|
||||
const entries = []
|
||||
if (typeof smAny.loadEntries === 'function') {
|
||||
entries.push(...(await smAny.loadEntries()))
|
||||
}
|
||||
console.log('loadEntries available:', typeof smAny.loadEntries === 'function', 'entries:', entries.length)
|
||||
|
||||
// 退路:直接解析磁盘上的最近 jsonl(与主进程 toCloneable 后的消息同构)
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
const dir = join(cwd, '.pi', 'agent', 'sessions', '--C--Users-21976--')
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'))
|
||||
files.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
|
||||
const latest = files[0]
|
||||
console.log('latest session file:', latest)
|
||||
|
||||
const lines = readFileSync(join(dir, latest), 'utf8').trim().split('\n')
|
||||
const messages = []
|
||||
for (const line of lines) {
|
||||
let entry
|
||||
try {
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (entry.type === 'message' && entry.message) messages.push(entry.message)
|
||||
}
|
||||
|
||||
// ==== 渲染层逻辑复刻(与 MessageItem.tsx 一致)====
|
||||
function readBlocks(raw) {
|
||||
if (!raw || typeof raw !== 'object') return []
|
||||
const content = raw.content
|
||||
if (typeof content === 'string') return [{ type: 'text', text: content }]
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter((c) => typeof c === 'object' && c !== null)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
for (const [i, m] of messages.entries()) {
|
||||
console.log(`\n=== history msg #${i} role=${m.role} ===`)
|
||||
const blocks = readBlocks(m)
|
||||
let rendered = 0
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'thinking' && typeof block.text === 'string') {
|
||||
console.log(' -> ThinkingBlock (text field)')
|
||||
rendered++
|
||||
continue
|
||||
}
|
||||
if (block.type === 'text' && block.text) {
|
||||
console.log(` -> Markdown("${String(block.text).slice(0, 60)}…")`)
|
||||
rendered++
|
||||
continue
|
||||
}
|
||||
if (block.type === 'toolCall' && block.id) {
|
||||
console.log(' -> ToolCard', block.name)
|
||||
rendered++
|
||||
continue
|
||||
}
|
||||
console.log(' -> **被丢弃** block type =', block.type, 'keys =', Object.keys(block).join(','))
|
||||
}
|
||||
console.log(` rendered ${rendered}/${blocks.length} blocks`)
|
||||
}
|
||||
@@ -1,14 +1,35 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpc } from './ipc'
|
||||
|
||||
process.env.APP_ROOT = join(__dirname, '..')
|
||||
|
||||
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
|
||||
export const VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL
|
||||
export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
|
||||
|
||||
/** 应用图标(build/icon.ico,由 scripts/make-icons.cjs 从 build/icon.svg 生成) */
|
||||
const APP_ICON = join(app.getAppPath(), 'build', 'icon.ico')
|
||||
|
||||
const agentService = registerIpc()
|
||||
|
||||
/** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO,
|
||||
* 否则 overlay 的原生白色背景会盖住标题栏分隔线) */
|
||||
function registerWindowControls(): void {
|
||||
ipcMain.handle('win:minimize', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
})
|
||||
ipcMain.handle('win:toggleMaximize', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
if (win.isMaximized()) win.unmaximize()
|
||||
else win.maximize()
|
||||
})
|
||||
ipcMain.handle('win:close', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||
})
|
||||
}
|
||||
|
||||
async function createWindow(): Promise<void> {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
@@ -16,8 +37,12 @@ async function createWindow(): Promise<void> {
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
show: false,
|
||||
backgroundColor: '#09090b',
|
||||
title: '店铺运营 Agent',
|
||||
backgroundColor: '#ffffff',
|
||||
title: 'Inkreach Agent',
|
||||
// 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns)
|
||||
...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}),
|
||||
// 无原生标题栏/菜单栏,标题栏与窗口控制按钮全部由渲染层 TitleBar 自绘
|
||||
titleBarStyle: 'hidden',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
sandbox: false,
|
||||
@@ -26,6 +51,18 @@ async function createWindow(): Promise<void> {
|
||||
}
|
||||
})
|
||||
|
||||
// 最大化状态同步给渲染层(切换「最大化/还原」字形)
|
||||
const sendWinState = (): void => win.webContents.send('win:state', win.isMaximized())
|
||||
win.on('maximize', sendWinState)
|
||||
win.on('unmaximize', sendWinState)
|
||||
|
||||
// 默认应用菜单已移除(File/Edit/View 不再显示),F12 补回 DevTools 开关
|
||||
win.webContents.on('before-input-event', (_event, input) => {
|
||||
if (input.type === 'keyDown' && input.key === 'F12') {
|
||||
win.webContents.toggleDevTools()
|
||||
}
|
||||
})
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
win.show()
|
||||
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
|
||||
@@ -46,8 +83,8 @@ async function createWindow(): Promise<void> {
|
||||
})
|
||||
|
||||
// 渲染层日志/加载失败转发到主进程 stdout,便于排查白屏/黑屏问题
|
||||
win.webContents.on('console-message', (_e, level, message, line, sourceId) => {
|
||||
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
|
||||
win.webContents.on('console-message', (details) => {
|
||||
console.log(`[renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`)
|
||||
})
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.log(`[renderer:load-fail] ${code} ${desc} ${url}`)
|
||||
@@ -79,6 +116,11 @@ if (!app.requestSingleInstanceLock()) {
|
||||
})
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
// Windows 任务栏归组的 AppUserModelID
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId('com.inkreach.agent')
|
||||
}
|
||||
Menu.setApplicationMenu(null)
|
||||
void createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -89,4 +131,6 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
registerWindowControls()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { userInfo } from 'node:os'
|
||||
import { AgentService } from './pi/agent-service'
|
||||
|
||||
/**
|
||||
@@ -16,11 +17,20 @@ export function registerIpc(): AgentService {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:send', (_event, text: unknown) => {
|
||||
ipcMain.handle('agent:send', (_event, text: unknown, images: unknown) => {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('empty prompt')
|
||||
}
|
||||
return service.send(text)
|
||||
const sendImages = Array.isArray(images)
|
||||
? images.filter(
|
||||
(img): img is { mimeType: string; data: string } =>
|
||||
typeof img === 'object' &&
|
||||
img !== null &&
|
||||
typeof (img as { mimeType?: unknown }).mimeType === 'string' &&
|
||||
typeof (img as { data?: unknown }).data === 'string'
|
||||
)
|
||||
: undefined
|
||||
return service.send(text, sendImages)
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:steer', (_event, text: unknown) => {
|
||||
@@ -105,6 +115,39 @@ export function registerIpc(): AgentService {
|
||||
return pass(() => service.setSystemPrompt(text))
|
||||
})
|
||||
|
||||
// ---- 工作区 ----
|
||||
ipcMain.handle('workspaces:list', () =>
|
||||
service.listWorkspaces().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaces:create', (_event, name: unknown) => {
|
||||
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid workspace name')
|
||||
return service.createWorkspace(name).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('workspaces:open', (_event, path: unknown) => {
|
||||
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid workspace path')
|
||||
return service.openWorkspace(path).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
// 调起系统目录选择器;取消返回 null
|
||||
ipcMain.handle('workspaces:browse', async () => {
|
||||
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
title: '选择工作区目录',
|
||||
defaultPath: service.workspacesRoot,
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
// ---- 工具审批 ----
|
||||
ipcMain.handle('approval:resolve', (_event, id: unknown, approved: unknown) => {
|
||||
if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval')
|
||||
@@ -117,8 +160,39 @@ export function registerIpc(): AgentService {
|
||||
app: process.env.npm_package_version ?? '0.0.0',
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
chrome: process.versions.chrome
|
||||
chrome: process.versions.chrome,
|
||||
user: userInfo().username
|
||||
}))
|
||||
|
||||
// ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)----
|
||||
ipcMain.handle('chat:resources', () =>
|
||||
service.listChatResources().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('queue:set', (_event, steering: unknown, followUp: unknown) => {
|
||||
const norm = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
return service.setQueue(norm(steering), norm(followUp))
|
||||
})
|
||||
|
||||
ipcMain.handle('permissions:set', (_event, mode: unknown) => {
|
||||
const valid: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
|
||||
if (typeof mode !== 'string' || !valid.has(mode)) throw new Error('invalid permission mode')
|
||||
service.setPermissionMode(mode as never)
|
||||
})
|
||||
|
||||
ipcMain.handle('files:list', () =>
|
||||
service.listWorkspaceFiles().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('attachments:pick', async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getAllWindows()[0]
|
||||
return service.pickAttachments(win ?? undefined)
|
||||
})
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { app, shell } from 'electron'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { app, dialog, shell } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
createAgentSessionFromServices,
|
||||
getAgentDir,
|
||||
loadSkills,
|
||||
SessionManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
@@ -18,11 +19,17 @@ import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools'
|
||||
import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
AttachmentInfo,
|
||||
ChatResources,
|
||||
ModelCatalog,
|
||||
ModelInfo,
|
||||
PermissionMode,
|
||||
SendImage,
|
||||
SessionMeta,
|
||||
SessionState,
|
||||
SessionStatsInfo
|
||||
SessionStatsInfo,
|
||||
WorkspaceFile,
|
||||
WorkspaceMeta
|
||||
} from '@shared/types'
|
||||
|
||||
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
|
||||
@@ -43,10 +50,24 @@ export class AgentService {
|
||||
private attached: AgentSession | null = null
|
||||
private unsubscribeAttached: (() => void) | null = null
|
||||
private settings: AppSettings = { systemPrompt: '' }
|
||||
/** 当前工作区目录(agent cwd),会话按目录隔离 */
|
||||
private workspaceDir = ''
|
||||
private approvalsRegistered = false
|
||||
/** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
|
||||
private permissionMode: PermissionMode = 'confirm'
|
||||
readonly approvals = new ApprovalService()
|
||||
|
||||
/** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */
|
||||
get workspacesRoot(): string {
|
||||
return join(app.getPath('documents'), 'Inkreach')
|
||||
}
|
||||
|
||||
private get defaultWorkspaceDir(): string {
|
||||
return join(this.workspacesRoot, 'Default')
|
||||
}
|
||||
|
||||
private get cwd(): string {
|
||||
return app.getPath('home')
|
||||
return this.workspaceDir || this.defaultWorkspaceDir
|
||||
}
|
||||
|
||||
private get settingsFile(): string {
|
||||
@@ -54,14 +75,103 @@ export class AgentService {
|
||||
}
|
||||
|
||||
private loadSettings(): AppSettings {
|
||||
const VALID_MODES: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
|
||||
return {
|
||||
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : ''
|
||||
this.settings = {
|
||||
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : '',
|
||||
workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
|
||||
customWorkspaces: Array.isArray(raw.customWorkspaces)
|
||||
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
|
||||
: undefined,
|
||||
permissionMode:
|
||||
typeof raw.permissionMode === 'string' && VALID_MODES.has(raw.permissionMode)
|
||||
? (raw.permissionMode as PermissionMode)
|
||||
: 'confirm'
|
||||
}
|
||||
} catch {
|
||||
return { systemPrompt: '' }
|
||||
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
|
||||
}
|
||||
this.permissionMode = this.settings.permissionMode ?? 'confirm'
|
||||
this.initWorkspaces()
|
||||
return this.settings
|
||||
}
|
||||
|
||||
/** 确保根目录与默认工作区存在,并解析当前工作区(设置值失效时回退 Default) */
|
||||
private initWorkspaces(): void {
|
||||
try {
|
||||
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
|
||||
} catch (err) {
|
||||
console.error('[agent-service] init workspaces failed:', err)
|
||||
}
|
||||
const saved = this.settings.workspace
|
||||
this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir
|
||||
}
|
||||
|
||||
// ---- 工作区 ----
|
||||
|
||||
async listWorkspaces(): Promise<WorkspaceMeta[]> {
|
||||
try {
|
||||
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
|
||||
} catch {
|
||||
// 目录已存在或创建失败时仍尝试枚举
|
||||
}
|
||||
const entries: WorkspaceMeta[] = []
|
||||
const seen = new Set<string>([this.workspacesRoot.toLowerCase()])
|
||||
try {
|
||||
for (const d of readdirSync(this.workspacesRoot, { withFileTypes: true })) {
|
||||
if (!d.isDirectory()) continue
|
||||
const path = join(this.workspacesRoot, d.name)
|
||||
entries.push({ name: d.name, path })
|
||||
seen.add(path.toLowerCase())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[agent-service] list workspaces failed:', err)
|
||||
}
|
||||
// 目录选择器添加的根目录之外的工作区
|
||||
for (const p of this.settings.customWorkspaces ?? []) {
|
||||
if (!existsSync(p)) continue
|
||||
const normalized = join(p).replace(/[/\\]+$/, '')
|
||||
if (seen.has(normalized.toLowerCase())) continue
|
||||
seen.add(normalized.toLowerCase())
|
||||
entries.push({ name: dirname(normalized) === normalized ? normalized : normalized.split(/[\\/]/).pop() || normalized, path: normalized })
|
||||
}
|
||||
if (!entries.some((w) => w.name === 'Default')) {
|
||||
entries.unshift({ name: 'Default', path: this.defaultWorkspaceDir })
|
||||
}
|
||||
return entries.sort((a, b) => (a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name)))
|
||||
}
|
||||
|
||||
async createWorkspace(name: string): Promise<WorkspaceMeta> {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || /[\\/:*?"<>|]/.test(trimmed)) {
|
||||
throw new Error('工作区名称非法(不能包含 \\/:*?"<>|)')
|
||||
}
|
||||
const path = join(this.workspacesRoot, trimmed)
|
||||
if (existsSync(path)) {
|
||||
throw new Error(`工作区已存在: ${trimmed}`)
|
||||
}
|
||||
mkdirSync(path, { recursive: true })
|
||||
return { name: trimmed, path }
|
||||
}
|
||||
|
||||
/** 切换工作区:持久化设置后重建运行时(cwd 变化 → 会话列表随目录隔离)。
|
||||
* 允许任意已存在目录;根目录之外的目录记入自定义工作区以便下次列出。 */
|
||||
async openWorkspace(path: string): Promise<void> {
|
||||
const resolved = join(path).replace(/[/\\]+$/, '')
|
||||
if (!existsSync(resolved)) {
|
||||
throw new Error(`工作区目录不存在: ${path}`)
|
||||
}
|
||||
if (samePath(resolved, this.cwd)) return
|
||||
if (dirname(resolved).toLowerCase() !== this.workspacesRoot.toLowerCase()) {
|
||||
const custom = new Set(this.settings.customWorkspaces ?? [])
|
||||
custom.add(resolved)
|
||||
this.settings.customWorkspaces = [...custom]
|
||||
}
|
||||
this.settings.workspace = resolved
|
||||
this.saveSettings()
|
||||
this.workspaceDir = resolved
|
||||
await this.rebuildRuntime()
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
@@ -118,20 +228,25 @@ export class AgentService {
|
||||
const cwd = this.cwd
|
||||
const agentDir = getAgentDir()
|
||||
this.settings = this.loadSettings()
|
||||
// 审批拦截:TOOLS_REQUIRING_APPROVAL 名单内的工具调用需经渲染层 Approval Card 确认
|
||||
this.approvals.onRequest((request) => {
|
||||
this.emit({ type: 'approval_request', request })
|
||||
})
|
||||
this.approvals.onSettle((id, approved, timedOut) => {
|
||||
this.emit({ type: 'approval_settled', id, approved, timedOut })
|
||||
})
|
||||
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
|
||||
if (!this.approvalsRegistered) {
|
||||
this.approvalsRegistered = true
|
||||
this.approvals.onRequest((request) => {
|
||||
this.emit({ type: 'approval_request', request })
|
||||
})
|
||||
this.approvals.onSettle((id, approved, timedOut) => {
|
||||
this.emit({ type: 'approval_settled', id, approved, timedOut })
|
||||
})
|
||||
}
|
||||
const buildServices = (targetCwd: string, targetAgentDir: string) =>
|
||||
createAgentSessionServices({
|
||||
cwd: targetCwd,
|
||||
agentDir: targetAgentDir,
|
||||
resourceLoaderOptions: {
|
||||
...(this.settings.systemPrompt.trim() ? { systemPrompt: this.settings.systemPrompt } : {}),
|
||||
extensionFactories: [createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL)]
|
||||
extensionFactories: [
|
||||
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode)
|
||||
]
|
||||
}
|
||||
})
|
||||
const initialServices = await buildServices(cwd, agentDir)
|
||||
@@ -164,6 +279,8 @@ export class AgentService {
|
||||
this.emitSwitched(session)
|
||||
})
|
||||
this.attach(runtime.session)
|
||||
// 此前 runtime 从未挂到实例上(rebuildRuntime 的 dispose 永远拿不到旧实例),补上
|
||||
this.runtime = runtime
|
||||
return runtime
|
||||
}
|
||||
|
||||
@@ -306,7 +423,9 @@ export class AgentService {
|
||||
model: modelInfo,
|
||||
streaming: session.isStreaming,
|
||||
activeTools: session.getActiveToolNames(),
|
||||
messageCount: session.messages.length
|
||||
messageCount: session.messages.length,
|
||||
workspace: this.cwd,
|
||||
permissionMode: this.permissionMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,13 +450,125 @@ export class AgentService {
|
||||
return this.buildState(runtime.session)
|
||||
}
|
||||
|
||||
async send(text: string): Promise<void> {
|
||||
async send(text: string, images?: SendImage[]): Promise<void> {
|
||||
const runtime = await this.getRuntime()
|
||||
const session = runtime.session
|
||||
if (session.isStreaming) {
|
||||
await session.prompt(text, { streamingBehavior: 'followUp' })
|
||||
} else {
|
||||
await session.prompt(text)
|
||||
const imageContent = images?.length
|
||||
? images.map((img) => ({ type: 'image' as const, data: img.data, mimeType: img.mimeType }))
|
||||
: undefined
|
||||
const options = {
|
||||
...(session.isStreaming ? { streamingBehavior: 'followUp' as const } : {}),
|
||||
...(imageContent ? { images: imageContent } : {})
|
||||
}
|
||||
await session.prompt(text, Object.keys(options).length > 0 ? options : undefined)
|
||||
}
|
||||
|
||||
// ---- Chatbox 资源(/ 命令、/skill: 技能)----
|
||||
|
||||
/** 重建排队消息(编辑/删除/排序/置顶都走这里:clear 后按新顺序重推)。
|
||||
* 空闲时队列会被 pi 立即消费,无需(也不能)重推。 */
|
||||
async setQueue(steering: string[], followUp: string[]): Promise<void> {
|
||||
const runtime = await this.getRuntime()
|
||||
const session = runtime.session
|
||||
if (!session.isStreaming) return
|
||||
session.clearQueue()
|
||||
for (const text of steering) void session.steer(text)
|
||||
for (const text of followUp) void session.followUp(text)
|
||||
}
|
||||
|
||||
async listChatResources(): Promise<ChatResources> {
|
||||
const runtime = await this.getRuntime()
|
||||
let skills: ChatResources['skills'] = []
|
||||
try {
|
||||
const loaded = loadSkills({
|
||||
cwd: this.cwd,
|
||||
agentDir: getAgentDir(),
|
||||
skillPaths: [],
|
||||
includeDefaults: true
|
||||
})
|
||||
skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' }))
|
||||
} catch (err) {
|
||||
console.error('[agent-service] load skills failed:', err)
|
||||
}
|
||||
const commands = runtime.session.promptTemplates.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
kind: 'prompt' as const
|
||||
}))
|
||||
return { commands, skills }
|
||||
}
|
||||
|
||||
// ---- 工作区文件(@ 引用)----
|
||||
|
||||
async listWorkspaceFiles(): Promise<WorkspaceFile[]> {
|
||||
const root = this.cwd
|
||||
const out: WorkspaceFile[] = []
|
||||
const SKIP = new Set(['.git', 'node_modules', 'dist', 'out', 'build', '.pi', '.next', 'coverage'])
|
||||
const walk = (dir: string, depth: number): void => {
|
||||
if (depth > 4 || out.length >= 400) return
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (out.length >= 400) return
|
||||
if (entry.name.startsWith('.') && entry.name !== '.env') continue
|
||||
const full = join(dir, entry.name)
|
||||
const rel = full.slice(root.length + 1).replace(/\\/g, '/')
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP.has(entry.name)) continue
|
||||
out.push({ path: rel, name: entry.name, isDir: true })
|
||||
walk(full, depth + 1)
|
||||
} else if (entry.isFile()) {
|
||||
out.push({ path: rel, name: entry.name, isDir: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 附件(+ 选择文件)----
|
||||
|
||||
async pickAttachments(parent?: Electron.BrowserWindow): Promise<AttachmentInfo[]> {
|
||||
const options = {
|
||||
title: '添加附件',
|
||||
properties: ['openFile', 'multiSelections'] as Array<'openFile' | 'multiSelections'>,
|
||||
filters: [
|
||||
{ name: '常用文件', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'md', 'txt', 'csv', 'json', 'xlsx', 'docx'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
}
|
||||
const result = parent ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options)
|
||||
if (result.canceled || result.filePaths.length === 0) return []
|
||||
return result.filePaths.map((path) => this.prepareAttachment(path))
|
||||
}
|
||||
|
||||
prepareAttachment(path: string): AttachmentInfo {
|
||||
const name = path.split(/[\\/]/).pop() ?? path
|
||||
const ext = name.toLowerCase().split('.').pop() ?? ''
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp'
|
||||
}
|
||||
const mimeType = IMAGE_MIME[ext] ?? ''
|
||||
if (!mimeType) {
|
||||
return { name, path, mimeType: '', data: '' }
|
||||
}
|
||||
try {
|
||||
const stat = statSync(path)
|
||||
if (stat.size > 8 * 1024 * 1024) {
|
||||
throw new Error(`图片过大(>8MB): ${name}`)
|
||||
}
|
||||
return { name, path, mimeType, data: readFileSync(path).toString('base64') }
|
||||
} catch (err) {
|
||||
console.error('[agent-service] read attachment failed:', err)
|
||||
return { name, path, mimeType: '', data: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,9 +587,25 @@ export class AgentService {
|
||||
|
||||
async listSessions(): Promise<SessionMeta[]> {
|
||||
const infos = await SessionManager.list(this.cwd)
|
||||
return infos
|
||||
const metas = infos
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
|
||||
.map((info) => toSessionMeta(info))
|
||||
// 新会话在首条消息写入前没有 .jsonl 文件,SessionManager.list 扫不到;
|
||||
// 把当前活跃会话兜底合并进列表,避免侧栏“新建后不出现”
|
||||
const session = this.runtime?.session
|
||||
if (session && !metas.some((m) => samePath(m.path, session.sessionFile ?? ''))) {
|
||||
const now = new Date().toISOString()
|
||||
metas.unshift({
|
||||
id: session.sessionId,
|
||||
path: session.sessionFile ?? '',
|
||||
name: session.sessionName,
|
||||
firstMessage: firstUserText(session.messages),
|
||||
created: now,
|
||||
modified: now,
|
||||
messageCount: session.messages.length
|
||||
})
|
||||
}
|
||||
return metas
|
||||
}
|
||||
|
||||
async newSession(): Promise<void> {
|
||||
@@ -466,11 +713,22 @@ export class AgentService {
|
||||
|
||||
/** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */
|
||||
async setSystemPrompt(text: string): Promise<void> {
|
||||
this.settings = { systemPrompt: text }
|
||||
this.settings = { ...this.settings, systemPrompt: text }
|
||||
this.saveSettings()
|
||||
await this.rebuildRuntime()
|
||||
}
|
||||
|
||||
/** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */
|
||||
setPermissionMode(mode: PermissionMode): void {
|
||||
this.permissionMode = mode
|
||||
this.settings = { ...this.settings, permissionMode: mode }
|
||||
this.saveSettings()
|
||||
const session = this.runtime?.session ?? this.attached
|
||||
if (session) {
|
||||
this.emit({ type: 'state', state: this.buildState(session) })
|
||||
}
|
||||
}
|
||||
|
||||
private async rebuildRuntime(): Promise<void> {
|
||||
const old = this.runtime
|
||||
this.runtime = null
|
||||
@@ -518,3 +776,24 @@ function readRole(message: unknown): string {
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** 提取第一条用户消息的纯文本(新会话未落盘时用作列表标题) */
|
||||
function firstUserText(messages: unknown[]): string {
|
||||
for (const message of messages) {
|
||||
if (readRole(message) !== 'user') continue
|
||||
const content = (message as { content?: unknown }).content
|
||||
if (typeof content === 'string' && content.trim()) return content
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.map((block) =>
|
||||
block && typeof block === 'object' && typeof (block as { text?: unknown }).text === 'string'
|
||||
? (block as { text: string }).text
|
||||
: ''
|
||||
)
|
||||
.join('')
|
||||
.trim()
|
||||
if (text) return text
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
|
||||
import type { PermissionMode } from '@shared/types'
|
||||
|
||||
/**
|
||||
* 工具调用审批桥:
|
||||
* - extension 拦截 tool_call → 挂起等待渲染层审批
|
||||
* - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互
|
||||
* - 超时自动拒绝,避免 agent 永久阻塞
|
||||
* - 权限模式:full 全放行 / autoEdit 自动放行文件编辑 / plan 拦截修改类操作
|
||||
*/
|
||||
const APPROVAL_TIMEOUT_MS = 120_000
|
||||
|
||||
/** 文件编辑类工具(autoEdit 模式自动放行;plan 模式拦截) */
|
||||
const EDIT_TOOLS = new Set(['write', 'edit'])
|
||||
|
||||
export interface PendingApproval {
|
||||
id: string
|
||||
toolName: string
|
||||
@@ -67,17 +72,29 @@ function cloneable(value: unknown): unknown {
|
||||
|
||||
/**
|
||||
* 生产 approval 拦截 extension:
|
||||
* 匹配 NEEDS_APPROVAL 名单的工具调用先经用户审批,拒绝则 block。
|
||||
* 匹配 NEEDS_APPROVAL 名单的工具调用按权限模式处理 ——
|
||||
* confirm:先经用户审批,拒绝则 block;full:全部放行;
|
||||
* autoEdit:文件编辑自动放行(bash 等仍需审批);plan:修改类操作直接拦截。
|
||||
*/
|
||||
export function createApprovalExtension(
|
||||
service: ApprovalService,
|
||||
needsApproval: ReadonlySet<string>
|
||||
needsApproval: ReadonlySet<string>,
|
||||
getMode: () => PermissionMode
|
||||
): InlineExtension {
|
||||
return {
|
||||
name: 'aso-approval',
|
||||
factory: (pi: ExtensionAPI) => {
|
||||
pi.on('tool_call', async (event) => {
|
||||
if (!needsApproval.has(event.toolName)) return
|
||||
const mode = getMode()
|
||||
if (mode === 'full') return
|
||||
if (mode === 'autoEdit' && EDIT_TOOLS.has(event.toolName)) return
|
||||
if (mode === 'plan' && (EDIT_TOOLS.has(event.toolName) || event.toolName === 'bash')) {
|
||||
return {
|
||||
block: true,
|
||||
reason: '计划模式已开启:请先输出完整的执行计划(只读分析),待用户切换权限模式后再执行修改类操作。'
|
||||
}
|
||||
}
|
||||
const approved = await service.request(event.toolName, event.input)
|
||||
if (!approved) {
|
||||
return { block: true, reason: '用户拒绝了本次工具调用审批' }
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { AgentUiEvent, AppSettings, ModelCatalog, SessionMeta, SessionState } from '@shared/types'
|
||||
import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
AttachmentInfo,
|
||||
ChatResources,
|
||||
ModelCatalog,
|
||||
PermissionMode,
|
||||
SendImage,
|
||||
SessionMeta,
|
||||
SessionState,
|
||||
WorkspaceFile,
|
||||
WorkspaceMeta
|
||||
} from '@shared/types'
|
||||
|
||||
const api = {
|
||||
send: (text: string): Promise<void> => ipcRenderer.invoke('agent:send', text),
|
||||
// 窗口控制(自绘标题栏按钮)
|
||||
minimize: (): Promise<void> => ipcRenderer.invoke('win:minimize'),
|
||||
toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'),
|
||||
close: (): Promise<void> => ipcRenderer.invoke('win:close'),
|
||||
onWindowState: (callback: (maximized: boolean) => void): (() => void) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized)
|
||||
ipcRenderer.on('win:state', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('win:state', listener)
|
||||
}
|
||||
},
|
||||
send: (text: string, images?: SendImage[]): Promise<void> => ipcRenderer.invoke('agent:send', text, images),
|
||||
steer: (text: string): Promise<void> => ipcRenderer.invoke('agent:steer', text),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke('agent:stop'),
|
||||
getState: (): Promise<SessionState> => ipcRenderer.invoke('agent:state'),
|
||||
versions: (): Promise<{ app: string; electron: string; node: string; chrome: string }> =>
|
||||
versions: (): Promise<{ app: string; electron: string; node: string; chrome: string; user: string }> =>
|
||||
ipcRenderer.invoke('app:versions'),
|
||||
listSessions: (): Promise<SessionMeta[]> => ipcRenderer.invoke('sessions:list'),
|
||||
newSession: (): Promise<void> => ipcRenderer.invoke('sessions:new'),
|
||||
openSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:open', path),
|
||||
renameSession: (name: string): Promise<void> => ipcRenderer.invoke('sessions:rename', name),
|
||||
deleteSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:delete', path),
|
||||
listWorkspaces: (): Promise<WorkspaceMeta[]> => ipcRenderer.invoke('workspaces:list'),
|
||||
createWorkspace: (name: string): Promise<WorkspaceMeta> => ipcRenderer.invoke('workspaces:create', name),
|
||||
openWorkspace: (path: string): Promise<void> => ipcRenderer.invoke('workspaces:open', path),
|
||||
browseWorkspace: (): Promise<string | null> => ipcRenderer.invoke('workspaces:browse'),
|
||||
listChatResources: (): Promise<ChatResources> => ipcRenderer.invoke('chat:resources'),
|
||||
setQueue: (steering: string[], followUp: string[]): Promise<void> =>
|
||||
ipcRenderer.invoke('queue:set', steering, followUp),
|
||||
setPermissionMode: (mode: PermissionMode): Promise<void> => ipcRenderer.invoke('permissions:set', mode),
|
||||
listWorkspaceFiles: (): Promise<WorkspaceFile[]> => ipcRenderer.invoke('files:list'),
|
||||
pickAttachments: (): Promise<AttachmentInfo[]> => ipcRenderer.invoke('attachments:pick'),
|
||||
getModelCatalog: (): Promise<ModelCatalog> => ipcRenderer.invoke('models:catalog'),
|
||||
setModel: (providerId: string, modelId: string): Promise<void> =>
|
||||
ipcRenderer.invoke('models:set', providerId, modelId),
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"
|
||||
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"
|
||||
/>
|
||||
<title>店铺运营 Agent</title>
|
||||
<title>Inkreach Agent</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100">
|
||||
<body class="bg-white text-zinc-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { useAgent } from './hooks/useAgent'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { SidebarNav } from './components/SidebarNav'
|
||||
import { TitleBar } from './components/TitleBar'
|
||||
import { ChatView } from './components/ChatView'
|
||||
import { BottomSlot } from './components/BottomSlot'
|
||||
import { Composer } from './components/Composer'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
import { ApprovalStack } from './components/ApprovalCard'
|
||||
import { Search } from './components/Search'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const {
|
||||
state,
|
||||
sessions,
|
||||
workspaces,
|
||||
send,
|
||||
stop,
|
||||
clearError,
|
||||
@@ -18,23 +21,31 @@ export default function App(): JSX.Element {
|
||||
openSession,
|
||||
renameSession,
|
||||
deleteSession,
|
||||
setModel,
|
||||
resolveApproval,
|
||||
refreshSessions
|
||||
setPermissionMode,
|
||||
refreshSessions,
|
||||
openWorkspace
|
||||
} = useAgent()
|
||||
const prefillRef = useRef<string | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
// 全局快捷键:Ctrl+N 新会话 / Ctrl+, 设置 / Esc 关闭弹窗
|
||||
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault()
|
||||
newSession()
|
||||
} else if (e.ctrlKey && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
} else if (e.ctrlKey && e.key === ',') {
|
||||
e.preventDefault()
|
||||
setSettingsOpen(true)
|
||||
} else if (e.key === 'Escape') {
|
||||
setSettingsOpen(false)
|
||||
setSearchOpen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
@@ -45,23 +56,38 @@ export default function App(): JSX.Element {
|
||||
prefillRef.current = text
|
||||
}
|
||||
|
||||
const queuedCount = state.queue.followUp.length + state.queue.steering.length
|
||||
// 选区操作(解释/改进/精简/自定义指令):把指令 + 引用发给 Agent
|
||||
const handleApplySelection = (instruction: string, selection: string): void => {
|
||||
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
|
||||
}
|
||||
|
||||
// 标题栏会话名:优先当前会话名,其次会话列表里的名称/首条消息
|
||||
const activeMeta = state.sessionState?.sessionId
|
||||
? sessions.find((s) => s.id === state.sessionState?.sessionId)
|
||||
: undefined
|
||||
const sessionTitle =
|
||||
state.sessionState?.sessionName?.trim() ||
|
||||
activeMeta?.name?.trim() ||
|
||||
activeMeta?.firstMessage?.trim() ||
|
||||
''
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
|
||||
<Sidebar
|
||||
<div className="relative flex h-full w-full bg-page text-ink antialiased">
|
||||
<SidebarNav
|
||||
sessionState={state.sessionState}
|
||||
stats={state.stats}
|
||||
connected={state.ready}
|
||||
sessions={sessions}
|
||||
onNewSession={newSession}
|
||||
onOpenSession={openSession}
|
||||
onRenameSession={renameSession}
|
||||
onDeleteSession={deleteSession}
|
||||
onOpenSearch={() => setSearchOpen(true)}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onRefreshSessions={refreshSessions}
|
||||
workspaces={workspaces}
|
||||
activeWorkspace={workspaces.find((w) => w.path === state.sessionState?.workspace)}
|
||||
onOpenWorkspace={openWorkspace}
|
||||
/>
|
||||
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||
<TitleBar title={sessionTitle} />
|
||||
<ChatView
|
||||
messages={state.messages}
|
||||
tools={state.tools}
|
||||
@@ -72,17 +98,38 @@ export default function App(): JSX.Element {
|
||||
pendingApprovals={state.approvals}
|
||||
onDismissError={clearError}
|
||||
onPickSuggestion={handlePickSuggestion}
|
||||
onApplySelection={handleApplySelection}
|
||||
/>
|
||||
<Composer
|
||||
streaming={state.streaming}
|
||||
queuedCount={queuedCount}
|
||||
disabled={!state.ready}
|
||||
onSend={send}
|
||||
onStop={stop}
|
||||
prefillRef={prefillRef}
|
||||
<BottomSlot
|
||||
approvals={state.approvals}
|
||||
onResolve={resolveApproval}
|
||||
composer={
|
||||
<Composer
|
||||
streaming={state.streaming}
|
||||
queue={state.queue}
|
||||
disabled={!state.ready}
|
||||
model={state.sessionState?.model}
|
||||
permissionMode={state.sessionState?.permissionMode ?? 'confirm'}
|
||||
onSend={send}
|
||||
onStop={stop}
|
||||
onNewSession={newSession}
|
||||
onSetModel={setModel}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onPermissionMode={setPermissionMode}
|
||||
prefillRef={prefillRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ApprovalStack requests={state.approvals} onResolve={resolveApproval} />
|
||||
</main>
|
||||
<Search
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
sessions={sessions}
|
||||
onNewSession={newSession}
|
||||
onRefreshSessions={refreshSessions}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenSession={openSession}
|
||||
/>
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
sessionState={state.sessionState}
|
||||
|
||||
@@ -1,124 +1,241 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { Check, ShieldAlert, Timer, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
import { GlideMenu } from '@/components/GlideMenu'
|
||||
import type { ApprovalRequest } from '@shared/types'
|
||||
|
||||
const APPROVAL_TIMEOUT_S = 120
|
||||
/** 工具审批卡(beautifului.dev Approval Card · human-in-the-loop,1:1 官方形态)。
|
||||
* 问题本体即标题、radio 选项 glide 高亮 + 选中 480ms 自动前进、
|
||||
* 底栏左侧上一题/下一题箭头 + 里程表计数,右侧 pill 操作(跳过 / 继续)。
|
||||
* 关闭(×)与跳过均视为拒绝;单题,计数固定 1 / 1。 */
|
||||
const ROLL_MS = 400
|
||||
|
||||
/**
|
||||
* 工具调用审批卡(beautifului.dev Approval Card 风格)。
|
||||
* 敏感工具(核价、写操作等)执行前弹出,带剩余时间倒计时。
|
||||
*/
|
||||
function ApprovalRow({
|
||||
/** 里程表数字:变化的字符向上(或向下)滚动(官方组件原样移植) */
|
||||
function RollingDigits({ value }: { value: string }): ReactNode {
|
||||
const prevRef = useRef(value)
|
||||
const [oldVal, setOldVal] = useState(value)
|
||||
const [chars, setChars] = useState(value)
|
||||
const [rolling, setRolling] = useState(false)
|
||||
const [shifted, setShifted] = useState(false)
|
||||
const [dir, setDir] = useState<'up' | 'down'>('up')
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRef.current === value) return
|
||||
const from = prevRef.current
|
||||
prevRef.current = value
|
||||
const fromN = parseInt(from, 10)
|
||||
const toN = parseInt(value, 10)
|
||||
setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? 'down' : 'up')
|
||||
setOldVal(from)
|
||||
setChars(value)
|
||||
setRolling(true)
|
||||
setShifted(false)
|
||||
|
||||
let raf2 = 0
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setShifted(true))
|
||||
})
|
||||
const done = setTimeout(() => {
|
||||
setRolling(false)
|
||||
setOldVal(value)
|
||||
setShifted(false)
|
||||
}, ROLL_MS)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1)
|
||||
cancelAnimationFrame(raf2)
|
||||
clearTimeout(done)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: chars.length }, (_, i) => {
|
||||
const o = oldVal[i] ?? ''
|
||||
const n = chars[i] ?? ''
|
||||
if (!rolling || o === n) return <span key={`${i}-${n}`}>{n}</span>
|
||||
const top = dir === 'down' ? n : o
|
||||
const bottom = dir === 'down' ? o : n
|
||||
const restY = dir === 'down' ? '0' : '-1em'
|
||||
const startY = dir === 'down' ? '-1em' : '0'
|
||||
return (
|
||||
<span
|
||||
key={`${i}-${o}-${n}-${dir}`}
|
||||
style={{ display: 'inline-block', position: 'relative', overflow: 'hidden', height: '1em', lineHeight: '1em', verticalAlign: '-0.05em' }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 350ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: `translateY(${shifted ? restY : startY})`
|
||||
}}
|
||||
>
|
||||
<span style={{ height: '1em', lineHeight: '1em' }}>{top}</span>
|
||||
<span style={{ height: '1em', lineHeight: '1em' }}>{bottom}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Ico({ path, size = 14, sw = 2 }: { path: ReactNode; size?: number; sw?: number }): ReactNode {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
{path}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const OPTION_APPROVE = 0
|
||||
|
||||
export function ApprovalCard({
|
||||
request,
|
||||
onResolve
|
||||
}: {
|
||||
request: ApprovalRequest
|
||||
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
|
||||
if (!el) return
|
||||
let tween: { kill(): void } | null = null
|
||||
let cancelled = false
|
||||
void import('gsap').then((gsapModule) => {
|
||||
if (cancelled) return
|
||||
tween = gsapModule.gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 16, scale: 0.97 },
|
||||
{ opacity: 1, y: 0, scale: 1, duration: 0.3, ease: 'back.out(1.6)' }
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
tween?.kill()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const argsText = (() => {
|
||||
try {
|
||||
return JSON.stringify(request.args, null, 2)
|
||||
} catch {
|
||||
return String(request.args)
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="pointer-events-auto w-[440px] max-w-[90vw] rounded-2xl border border-amber-900/60 bg-zinc-900 p-4 shadow-2xl shadow-black/50"
|
||||
>
|
||||
<div className="mb-2.5 flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-950/70 ring-1 ring-amber-900/60">
|
||||
<ShieldAlert size={14} className="text-amber-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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">
|
||||
{argsText.length > 600 ? `${argsText.slice(0, 600)}…` : argsText}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResolve(false)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-[12.5px] text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||
>
|
||||
<X size={13} />
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResolve(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
<Check size={13} />
|
||||
允许执行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApprovalStack({
|
||||
requests,
|
||||
onResolve
|
||||
}: {
|
||||
requests: ApprovalRequest[]
|
||||
onResolve: (id: string, approved: boolean) => void
|
||||
}): JSX.Element | null {
|
||||
if (requests.length === 0) return null
|
||||
}): ReactNode {
|
||||
const [picked, setPicked] = useState<number | null>(null)
|
||||
const [sent, setSent] = useState<string | null>(null)
|
||||
const submitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const settled = useRef(false)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (submitTimer.current) clearTimeout(submitTimer.current)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const settle = (approved: boolean, note: string): void => {
|
||||
if (settled.current) return
|
||||
settled.current = true
|
||||
setSent(note)
|
||||
if (submitTimer.current) clearTimeout(submitTimer.current)
|
||||
onResolve(request.id, approved)
|
||||
}
|
||||
|
||||
const pick = (index: number): void => {
|
||||
if (sent) return
|
||||
setPicked(index)
|
||||
// radio 自动前进:480ms 后提交(官方 single-choice 行为)
|
||||
if (submitTimer.current) clearTimeout(submitTimer.current)
|
||||
submitTimer.current = setTimeout(() => {
|
||||
settle(index === OPTION_APPROVE, index === OPTION_APPROVE ? '已允许执行' : '已拒绝')
|
||||
}, 480)
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
const ok = sent === '已允许执行'
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3" style={{ animation: 'pop-in 260ms cubic-bezier(0.23,1,0.32,1) both' }}>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full py-1 pr-2.5 pl-1 text-[12.5px] font-medium ${
|
||||
ok ? 'bg-green-tint text-green' : 'bg-inset text-ink-2'
|
||||
}`}
|
||||
>
|
||||
<span className={`flex size-4.5 items-center justify-center rounded-full text-white ${ok ? 'bg-green' : 'bg-ink-3'}`}>
|
||||
{ok ? (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{sent}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-30 flex flex-col items-center gap-2">
|
||||
{requests.map((request) => (
|
||||
<ApprovalRow key={request.id} request={request} onResolve={(approved) => onResolve(request.id, approved)} />
|
||||
))}
|
||||
<div className="w-full">
|
||||
<div className="relative overflow-hidden rounded-card bg-surface shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拒绝并关闭"
|
||||
title="拒绝并关闭"
|
||||
onClick={() => settle(false, '已拒绝')}
|
||||
className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
|
||||
</button>
|
||||
|
||||
<div className="primitive-card-pad">
|
||||
{/* 问题本体即标题(官方形态:14px medium) */}
|
||||
<div className="pr-7 text-[14px] font-medium text-ink" aria-live="polite">
|
||||
「<span className="font-mono text-[13px]">{request.toolName}</span>」请求执行敏感操作
|
||||
</div>
|
||||
|
||||
<GlideMenu className="mt-2.5 flex flex-col gap-1" highlightClassName="inset-x-0 rounded-control bg-hover">
|
||||
{(['允许执行', '拒绝执行'] as const).map((option, i) => {
|
||||
const on = picked === i
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
data-menu-row
|
||||
aria-pressed={on}
|
||||
onClick={() => pick(i)}
|
||||
className="relative z-10 flex items-center gap-1.5 rounded-control py-1 pr-2 pl-1 text-left transition-colors duration-100"
|
||||
>
|
||||
<span
|
||||
className={`flex size-4 shrink-0 items-center justify-center rounded-full transition-colors duration-200
|
||||
${on ? 'bg-ink text-canvas' : 'shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent'}`}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? 'scale(1)' : 'scale(0)' }} />
|
||||
</span>
|
||||
<span className={`text-[13px] leading-none transition-colors duration-200 ${on ? 'text-ink' : 'text-ink-2'}`}>{option}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</GlideMenu>
|
||||
</div>
|
||||
|
||||
{/* footer — step nav (rolling counter) + pill actions(官方底栏 1:1) */}
|
||||
<div className="primitive-card-footer flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1 text-ink-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="上一题"
|
||||
disabled
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
|
||||
</button>
|
||||
<span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: '-0.1px', lineHeight: 1 }}>
|
||||
<RollingDigits value="1 / 1" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下一题"
|
||||
disabled
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="-mr-0.5 flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => settle(false, '已拒绝')}>
|
||||
跳过
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
disabled={picked === null}
|
||||
onClick={() => settle(picked === OPTION_APPROVE, picked === OPTION_APPROVE ? '已允许执行' : '已拒绝')}
|
||||
>
|
||||
继续
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { ApprovalRequest } from '@shared/types'
|
||||
import { ApprovalCard } from './ApprovalCard'
|
||||
|
||||
/**
|
||||
* 底部插槽(抽屉):审批卡出现时从 Chatbox 位置升起替换输入框,
|
||||
* 结算后输入框滑回。两行用 grid-template-rows 0fr↔1fr 过渡做真实高度动画,
|
||||
* 交叉淡入淡出;Composer 始终挂载(草稿不丢)。已结算的卡片以结算胶囊态
|
||||
* 多停留约一秒再撤出,避免「啪」一下消失。
|
||||
*/
|
||||
|
||||
const ROWS_TRANSITION = 'grid-template-rows 400ms cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
const FADE_TRANSITION = 'opacity 220ms ease, transform 400ms cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
|
||||
/** 跟踪从 requests 里消失的审批:缓存原件让卡片以结算态多停留一拍 */
|
||||
function useLeavingApprovals(requests: ApprovalRequest[]): ApprovalRequest[] {
|
||||
const seenRef = useRef(new Map<string, ApprovalRequest>())
|
||||
for (const request of requests) seenRef.current.set(request.id, request)
|
||||
const [leaving, setLeaving] = useState<ApprovalRequest[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const gone: ApprovalRequest[] = []
|
||||
for (const [id, request] of seenRef.current) {
|
||||
if (!requests.some((r) => r.id === id)) gone.push(request)
|
||||
}
|
||||
if (gone.length === 0) return
|
||||
for (const g of gone) seenRef.current.delete(g.id)
|
||||
setLeaving((cur) => [...cur.filter((l) => !gone.some((g) => g.id === l.id)), ...gone])
|
||||
const timers = gone.map((g) =>
|
||||
setTimeout(() => {
|
||||
setLeaving((cur) => cur.filter((l) => l.id !== g.id))
|
||||
}, 1000)
|
||||
)
|
||||
return () => timers.forEach(clearTimeout)
|
||||
}, [requests])
|
||||
|
||||
return leaving
|
||||
}
|
||||
|
||||
export function BottomSlot({
|
||||
approvals,
|
||||
onResolve,
|
||||
composer
|
||||
}: {
|
||||
approvals: ApprovalRequest[]
|
||||
onResolve: (id: string, approved: boolean) => void
|
||||
composer: ReactNode
|
||||
}): ReactNode {
|
||||
const leaving = useLeavingApprovals(approvals)
|
||||
const showApproval = approvals.length > 0 || leaving.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Chatbox 行:审批升起时收起(高度 0 + 下沉淡出) */}
|
||||
<div style={{ display: 'grid', gridTemplateRows: showApproval ? '0fr' : '1fr', transition: ROWS_TRANSITION }}>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div
|
||||
style={{
|
||||
opacity: showApproval ? 0 : 1,
|
||||
transform: showApproval ? 'translateY(10px)' : 'none',
|
||||
transition: FADE_TRANSITION,
|
||||
pointerEvents: showApproval ? 'none' : undefined
|
||||
}}
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 审批抽屉行:从输入框位置升起(高度 1fr + 上浮淡入) */}
|
||||
<div style={{ display: 'grid', gridTemplateRows: showApproval ? '1fr' : '0fr', transition: ROWS_TRANSITION }}>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div
|
||||
style={{
|
||||
opacity: showApproval ? 1 : 0,
|
||||
transform: showApproval ? 'none' : 'translateY(14px)',
|
||||
transition: FADE_TRANSITION,
|
||||
pointerEvents: showApproval ? undefined : 'none'
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 pb-1">
|
||||
{[...approvals, ...leaving.filter((l) => !approvals.some((a) => a.id === l.id))].map((request) => (
|
||||
<div key={request.id} className="w-full max-w-80" style={{ animation: 'fade-up 380ms cubic-bezier(0.23,1,0.32,1) both' }}>
|
||||
<ApprovalCard request={request} onResolve={onResolve} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { AlertTriangle, RotateCw, Store } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { LuRotateCw, LuTriangleAlert } from 'react-icons/lu'
|
||||
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
|
||||
import { MessageItem } from './MessageItem'
|
||||
import { AssistantTurn, MessageItem } from './MessageItem'
|
||||
import { SelectionActions } from './SelectionActions'
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'帮我分析一下今天该关注哪些品类热点',
|
||||
@@ -10,24 +11,26 @@ const SUGGESTIONS = [
|
||||
'查询最近一次上架任务的执行结果'
|
||||
]
|
||||
|
||||
function EmptyState({ onPick }: { onPick: (text: string) => void }): JSX.Element {
|
||||
/** 空状态:大标题 + 建议词(beautifului Streaming Text 的 follow-ups 行样式) */
|
||||
function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-8 text-center">
|
||||
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-emerald-950/60 ring-1 ring-emerald-900/60">
|
||||
<Store size={26} className="text-emerald-400" />
|
||||
</div>
|
||||
<h2 className="mb-1.5 text-lg font-medium text-zinc-100">店铺运营 Agent</h2>
|
||||
<p className="mb-8 max-w-sm text-[13px] leading-6 text-zinc-500">
|
||||
流程固定、编排灵活。描述你的目标,Agent 会按 SOP 调用工具完成;也可以随时切为手动逐步执行。
|
||||
<h2 className="mb-2 text-[32px] font-semibold leading-normal text-ink">今天想做点什么?</h2>
|
||||
<p className="mb-8 max-w-md text-base leading-normal text-ink-3">
|
||||
描述你的目标,Agent 会按 SOP 调用工具完成;也可以随时切为手动逐步执行。
|
||||
</p>
|
||||
<div className="grid w-full max-w-md gap-2">
|
||||
<div className="grid w-full max-w-md flex-col">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onPick(s)}
|
||||
className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-4 py-2.5 text-left text-[13px] text-zinc-400 transition-all hover:border-zinc-700 hover:bg-zinc-800/60 hover:text-zinc-200"
|
||||
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M9 10l-5 5 5 5" />
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||
</svg>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
@@ -36,6 +39,47 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): JSX.Element
|
||||
)
|
||||
}
|
||||
|
||||
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
|
||||
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。 */
|
||||
function renderTurns(
|
||||
messages: UiMessage[],
|
||||
tools: Record<string, ToolState>,
|
||||
streaming: boolean,
|
||||
pendingApprovals: ApprovalRequest[]
|
||||
): ReactNode[] {
|
||||
const items: ReactNode[] = []
|
||||
let i = 0
|
||||
while (i < messages.length) {
|
||||
const message = messages[i]
|
||||
if (message.role === 'assistant') {
|
||||
let j = i + 1
|
||||
while (j < messages.length && messages[j].role === 'assistant') j += 1
|
||||
items.push(
|
||||
<AssistantTurn
|
||||
key={`${message.key}-turn`}
|
||||
messages={messages.slice(i, j)}
|
||||
tools={tools}
|
||||
streaming={streaming}
|
||||
pendingApprovals={pendingApprovals}
|
||||
/>
|
||||
)
|
||||
i = j
|
||||
} else {
|
||||
items.push(
|
||||
<MessageItem
|
||||
key={message.key}
|
||||
message={message}
|
||||
tools={tools}
|
||||
streaming={streaming}
|
||||
pendingApprovals={pendingApprovals}
|
||||
/>
|
||||
)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
messages,
|
||||
tools,
|
||||
@@ -45,7 +89,8 @@ export function ChatView({
|
||||
sessionError,
|
||||
pendingApprovals,
|
||||
onDismissError,
|
||||
onPickSuggestion
|
||||
onPickSuggestion,
|
||||
onApplySelection
|
||||
}: {
|
||||
messages: UiMessage[]
|
||||
tools: Record<string, ToolState>
|
||||
@@ -56,7 +101,8 @@ export function ChatView({
|
||||
pendingApprovals: ApprovalRequest[]
|
||||
onDismissError: () => void
|
||||
onPickSuggestion: (text: string) => void
|
||||
}): JSX.Element {
|
||||
onApplySelection: (instruction: string, selection: string) => void
|
||||
}): ReactNode {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const pinnedRef = useRef(true)
|
||||
|
||||
@@ -80,13 +126,13 @@ export function ChatView({
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{(error || sessionError) && (
|
||||
<div className="absolute inset-x-4 top-3 z-10 flex items-start gap-2.5 rounded-xl border border-red-900/60 bg-red-950/80 px-4 py-3 text-[13px] text-red-200 shadow-lg backdrop-blur">
|
||||
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-red-400" />
|
||||
<div className="absolute inset-x-4 top-3 z-10 flex items-start gap-2.5 rounded-control border border-red/40 bg-red-tint px-4 py-3 text-[13px] text-red">
|
||||
<LuTriangleAlert size={16} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{sessionError ? (
|
||||
<>
|
||||
<div className="mb-0.5 font-medium">Agent 会话初始化失败</div>
|
||||
<div className="break-all text-red-300/80">{sessionError}</div>
|
||||
<div className="break-all text-red/80">{sessionError}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="break-all">{error}</div>
|
||||
@@ -95,7 +141,7 @@ export function ChatView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismissError}
|
||||
className="shrink-0 rounded px-1.5 text-red-300/70 transition-colors hover:bg-red-900/40 hover:text-red-200"
|
||||
className="shrink-0 rounded-control px-1.5 text-red/70 transition-colors hover:bg-red/10 hover:text-red"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -103,27 +149,19 @@ export function ChatView({
|
||||
)}
|
||||
|
||||
{retry && (
|
||||
<div className="absolute inset-x-4 top-3 z-10 flex items-center gap-2.5 rounded-xl border border-amber-900/60 bg-amber-950/80 px-4 py-2.5 text-[13px] text-amber-200 shadow-lg backdrop-blur">
|
||||
<RotateCw size={14} className="animate-spin text-amber-400" />
|
||||
<div className="absolute inset-x-4 top-3 z-10 flex items-center gap-2.5 rounded-card border border-orange/40 bg-orange-tint px-4 py-2.5 text-[13px] text-orange">
|
||||
<LuRotateCw size={14} className="animate-spin" />
|
||||
<span>
|
||||
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
|
||||
</span>
|
||||
<span className="truncate text-amber-300/70">{retry.errorMessage}</span>
|
||||
<span className="truncate text-orange/80">{retry.errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
|
||||
{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}
|
||||
pendingApprovals={pendingApprovals}
|
||||
/>
|
||||
))}
|
||||
{renderTurns(messages, tools, streaming, pendingApprovals)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
@@ -131,6 +169,9 @@ export function ChatView({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 划选消息文本后浮出的操作条 */}
|
||||
<SelectionActions containerRef={scrollRef} onApply={onApplySelection} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
/** 行号代码块(beautifului.dev Code Block)。
|
||||
* 头部:代码图标 + 文件名/语言 + Copy;主体:20px 行号列 + hairline 分隔 +
|
||||
* 语法着色(关键字 accent-ink、字符串 orange、行内标识符加粗 ink)。
|
||||
* 内容是 unified diff(```diff 或 +/- 行占多数)时整块渲染为红绿对照。 */
|
||||
|
||||
const KEYWORDS = new Set(
|
||||
`export async function const let var await if else return import from class new for while try catch finally throw typeof interface type enum public private protected static extends implements null true false undefined this super yield of in as void delete instanceof default switch case break continue do`.split(
|
||||
/\s+/
|
||||
)
|
||||
)
|
||||
|
||||
type Token = { text: string; kind: 'kw' | 'str' | 'com' | 'id' | 'plain' }
|
||||
|
||||
/** 单行词法着色:注释 → 字符串 → 词元。可选括号分支会匹配空串,
|
||||
* 必须跳过零长度匹配前进一位,否则 lastIndex 不动造成死循环。 */
|
||||
function tokenizeLine(line: string, lang: string): Token[] {
|
||||
const commentRe = lang === 'py' || lang === 'python' || lang === 'sh' || lang === 'bash' || lang === 'yaml' ? /#.*$/ : null
|
||||
const re = /("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|`(?:[^`\\]|\\.)*`?|(\/\/.*$)?|(\b[A-Za-z_$][\w$]*\b)|(\b\d[\w.]*\b))/g
|
||||
const tokens: Token[] = []
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
const commentMatch = line.match(commentRe ?? /$^/)
|
||||
const commentStart = commentMatch?.index ?? Infinity
|
||||
while ((m = re.exec(line))) {
|
||||
if (m[0] === '') {
|
||||
re.lastIndex += 1
|
||||
continue
|
||||
}
|
||||
if (m.index >= commentStart) break
|
||||
if (m.index > last) tokens.push({ text: line.slice(last, m.index), kind: 'plain' })
|
||||
const [raw, lineComment, word, num] = m
|
||||
if (lineComment) tokens.push({ text: raw, kind: 'com' })
|
||||
else if (raw && (raw[0] === '"' || raw[0] === "'" || raw[0] === '`')) tokens.push({ text: raw, kind: 'str' })
|
||||
else if (word) {
|
||||
const after = line.slice(re.lastIndex).match(/^\s*\(/)
|
||||
tokens.push({ text: raw, kind: KEYWORDS.has(word) ? 'kw' : after ? 'id' : 'plain' })
|
||||
} else if (num) tokens.push({ text: raw, kind: 'plain' })
|
||||
else tokens.push({ text: raw, kind: 'plain' })
|
||||
last = re.lastIndex
|
||||
}
|
||||
if (last < commentStart && last < line.length) tokens.push({ text: line.slice(last), kind: 'plain' })
|
||||
if (commentStart !== Infinity) tokens.push({ text: line.slice(commentStart), kind: 'com' })
|
||||
return tokens
|
||||
}
|
||||
|
||||
const TOKEN_CLASS: Record<Token['kind'], string> = {
|
||||
kw: 'text-accent-ink',
|
||||
str: 'text-orange',
|
||||
com: 'text-ink-3',
|
||||
id: 'font-medium text-ink',
|
||||
plain: ''
|
||||
}
|
||||
|
||||
function looksLikeDiff(code: string, lang: string): boolean {
|
||||
if (lang === 'diff' || lang === 'patch') return true
|
||||
const lines = code.split('\n').filter((l) => l.trim() && !l.startsWith('@@'))
|
||||
if (!lines.length) return false
|
||||
const marked = lines.filter((l) => /^[+-]/.test(l)).length
|
||||
return marked / lines.length > 0.6
|
||||
}
|
||||
|
||||
function isDiffLine(line: string): 'add' | 'del' | 'ctx' | 'hunk' {
|
||||
if (line.startsWith('@@')) return 'hunk'
|
||||
if (line.startsWith('+')) return 'add'
|
||||
if (line.startsWith('-')) return 'del'
|
||||
return 'ctx'
|
||||
}
|
||||
|
||||
/** Shiki 词元行(Markdown.tsx 异步算好传入);无 token 时回退内置简易分词 */
|
||||
export type ShikiToken = { content: string; color?: string; fontStyle?: number }
|
||||
export type ShikiLine = ShikiToken[]
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
lang = '',
|
||||
filename,
|
||||
tokens
|
||||
}: {
|
||||
code: string
|
||||
lang?: string
|
||||
filename?: string
|
||||
tokens?: ShikiLine[] | null
|
||||
}): ReactNode {
|
||||
const source = useMemo(() => code.replace(/\n$/, ''), [code])
|
||||
const language = lang.toLowerCase()
|
||||
const diff = looksLikeDiff(source, language)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const t = setTimeout(() => setCopied(false), 1600)
|
||||
return () => clearTimeout(t)
|
||||
}, [copied])
|
||||
|
||||
const copy = (): void => {
|
||||
void navigator.clipboard.writeText(source).then(() => setCopied(true))
|
||||
}
|
||||
|
||||
const title = filename ?? ((language && language !== 'diff' ? language : '') || (diff ? 'diff' : 'code'))
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-card bg-surface shadow-card">
|
||||
{/* 头部:文件名 + Copy */}
|
||||
<div className="flex h-11 items-center gap-2 border-b border-line px-4 text-[12.5px]">
|
||||
<span className="inline-flex min-w-0 items-center gap-[7px]">
|
||||
<svg aria-hidden width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 text-ink-3">
|
||||
<path d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
||||
</svg>
|
||||
<span className="truncate font-mono leading-none text-ink">{title}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copied ? '已复制' : '复制代码'}
|
||||
onClick={copy}
|
||||
className="-mr-1 ml-auto flex h-6 items-center gap-1 rounded-[6px] px-1.5 text-[12px]
|
||||
font-medium transition-colors duration-100 hover:bg-hover
|
||||
text-ink-3 hover:text-ink"
|
||||
>
|
||||
{copied ? (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2.5" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体 */}
|
||||
{diff ? (
|
||||
<DiffBody code={source} />
|
||||
) : (
|
||||
<div className="py-3 font-mono text-[12.5px] leading-[1.65] text-ink-2">
|
||||
<div className="relative">
|
||||
<span aria-hidden className="pointer-events-none absolute inset-y-0 left-5 w-px bg-line" />
|
||||
{source.split('\n').map((line, i) => {
|
||||
const lineTokens = tokens?.[i]
|
||||
return (
|
||||
<div key={i} className="grid grid-cols-[20px_minmax(0,1fr)] items-start">
|
||||
<span className="text-center text-[11px] text-ink-3 select-none">{i + 1}</span>
|
||||
<code className="pr-3 pl-1 break-words whitespace-pre-wrap">
|
||||
{lineTokens
|
||||
? lineTokens.map((token, j) => (
|
||||
<span
|
||||
key={j}
|
||||
style={{
|
||||
color: token.color,
|
||||
fontStyle: token.fontStyle && token.fontStyle & 1 ? 'italic' : undefined
|
||||
}}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
))
|
||||
: tokenizeLine(line, language).map((token, j) => (
|
||||
<span key={j} className={TOKEN_CLASS[token.kind]}>
|
||||
{token.text}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** unified diff 视图:红删绿增、@@ 保持上下文行号 */
|
||||
function DiffBody({ code }: { code: string }): ReactNode {
|
||||
return (
|
||||
<div className="py-2 font-mono text-[12px] leading-[1.7]">
|
||||
{code.split('\n').map((line, i) => {
|
||||
const kind = isDiffLine(line)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex gap-1.5 px-3 whitespace-pre-wrap ${
|
||||
kind === 'add' ? 'bg-green-tint text-green' : kind === 'del' ? 'bg-red-tint text-red' : kind === 'hunk' ? 'text-ink-3' : 'text-ink-2'
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 shrink-0 select-none">{kind === 'add' ? '+' : kind === 'del' ? '−' : ' '}</span>
|
||||
<span className="min-w-0 break-words">{kind === 'hunk' ? line : line.slice(kind === 'ctx' ? 0 : 1)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,321 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { JSX, RefObject } from 'react'
|
||||
import { ArrowUp, CornerDownLeft, Square } from 'lucide-react'
|
||||
import { LuCheck, LuClipboardList, LuFilePen, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
|
||||
import type { AttachmentInfo, ChatResources, ModelCatalog, ModelInfo, PermissionMode, WorkspaceFile } from '@shared/types'
|
||||
|
||||
/**
|
||||
* Prompt Bar(beautifului.dev #prompt-bar):
|
||||
* 14px 圆角容器 + 五列网格(+/输入/模型/语音/发送),focus 边框加深。
|
||||
* "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
|
||||
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,其他文件以 @path 引用)。
|
||||
*/
|
||||
|
||||
interface LocalCommand {
|
||||
id: string
|
||||
label: string
|
||||
hint: string
|
||||
run: () => void
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** 语音识别(Chromium 内核;不支持/失败时静默降级为禁用态) */
|
||||
function useDictation(onText: (text: string) => void): {
|
||||
supported: boolean
|
||||
listening: boolean
|
||||
toggle: () => void
|
||||
} {
|
||||
const [listening, setListening] = useState(false)
|
||||
const recRef = useRef<{ start: () => void; stop: () => void } | null>(null)
|
||||
const onTextRef = useRef(onText)
|
||||
onTextRef.current = onText
|
||||
|
||||
const SR = (
|
||||
globalThis as unknown as { webkitSpeechRecognition?: new () => never; SpeechRecognition?: new () => never }
|
||||
).SpeechRecognition
|
||||
|
||||
const supported = Boolean(SR)
|
||||
|
||||
const toggle = (): void => {
|
||||
if (!SR) return
|
||||
if (listening) {
|
||||
recRef.current?.stop()
|
||||
return
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rec = new (SR as any)()
|
||||
rec.lang = 'zh-CN'
|
||||
rec.interimResults = false
|
||||
rec.continuous = false
|
||||
rec.onresult = (event: { results: ArrayLike<ArrayLike<{ transcript: string }>> }): void => {
|
||||
let text = ''
|
||||
for (let i = 0; i < event.results.length; i += 1) text += event.results[i][0].transcript
|
||||
if (text.trim()) onTextRef.current(text.trim())
|
||||
}
|
||||
rec.onend = (): void => setListening(false)
|
||||
rec.onerror = (): void => setListening(false)
|
||||
recRef.current = rec
|
||||
rec.start()
|
||||
setListening(true)
|
||||
} catch {
|
||||
setListening(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { supported, listening, toggle }
|
||||
}
|
||||
|
||||
|
||||
/** 输入框锚定菜单:portal 到 body 固定定位(BottomSlot 的 overflow-hidden 会裁剪普通弹层) */
|
||||
function ComposerMenu({
|
||||
anchor,
|
||||
align,
|
||||
onClose,
|
||||
children
|
||||
}: {
|
||||
anchor: { top: number; left: number; right: number }
|
||||
align: 'left' | 'right'
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}): ReactNode {
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={onClose} />
|
||||
<div
|
||||
className="fixed z-40 overflow-hidden rounded-card border border-line bg-surface shadow-overlay"
|
||||
style={{
|
||||
bottom: window.innerHeight - anchor.top + 8,
|
||||
...(align === 'left' ? { left: Math.max(8, anchor.left) } : { right: Math.max(8, anchor.right) })
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/** 权限模式菜单(参考 ZCode:变更前确认 / 自动编辑 / 计划模式 / 完全访问) */
|
||||
const PERMISSION_MODES: Array<{ id: PermissionMode; label: string; desc: string; icon: typeof LuHand }> = [
|
||||
{ id: 'confirm', label: '变更前确认', desc: '改文件前先问我。', icon: LuHand },
|
||||
{ id: 'autoEdit', label: '自动编辑', desc: '自动编辑文件。', icon: LuFilePen },
|
||||
{ id: 'plan', label: '计划模式', desc: '编辑前先出计划。', icon: LuClipboardList },
|
||||
{ id: 'full', label: '完全访问', desc: '减少确认次数。', icon: LuShieldCheck }
|
||||
]
|
||||
|
||||
export function Composer({
|
||||
streaming,
|
||||
queuedCount,
|
||||
queue,
|
||||
disabled,
|
||||
model,
|
||||
permissionMode = 'confirm',
|
||||
onSend,
|
||||
onStop,
|
||||
onNewSession,
|
||||
onSetModel,
|
||||
onOpenSettings,
|
||||
onPermissionMode,
|
||||
prefillRef
|
||||
}: {
|
||||
streaming: boolean
|
||||
queuedCount: number
|
||||
/** 排队消息(steering 先于 followUp 送达) */
|
||||
queue: { steering: string[]; followUp: string[] }
|
||||
disabled: boolean
|
||||
onSend: (text: string) => void
|
||||
model?: ModelInfo
|
||||
permissionMode?: PermissionMode
|
||||
onSend: (text: string, images?: { mimeType: string; data: string }[]) => void
|
||||
onStop: () => void
|
||||
onNewSession: () => void
|
||||
onSetModel: (providerId: string, modelId: string) => void
|
||||
onOpenSettings: () => void
|
||||
onPermissionMode: (mode: PermissionMode) => void
|
||||
/** 供空状态建议词点击后填入 */
|
||||
prefillRef: RefObject<string | null>
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState('')
|
||||
const [modelOpen, setModelOpen] = useState(false)
|
||||
const [modelPos, setModelPos] = useState<{ top: number; left: number; right: number } | null>(null)
|
||||
const [permOpen, setPermOpen] = useState(false)
|
||||
const [permPos, setPermPos] = useState<{ top: number; left: number; right: number } | null>(null)
|
||||
const [catalog, setCatalog] = useState<ModelCatalog | null>(null)
|
||||
const [catalogLoading, setCatalogLoading] = useState(false)
|
||||
const [attachments, setAttachments] = useState<AttachmentInfo[]>([])
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [resources, setResources] = useState<ChatResources | null>(null)
|
||||
const [files, setFiles] = useState<WorkspaceFile[] | null>(null)
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const catalogLoadedRef = useRef(false)
|
||||
|
||||
const localCommands = useMemo<LocalCommand[]>(
|
||||
() => [
|
||||
{ id: 'new', label: '/new', hint: '新会话', run: onNewSession },
|
||||
{ id: 'settings', label: '/settings', hint: '打开设置', run: onOpenSettings },
|
||||
{ id: 'stop', label: '/stop', hint: '停止生成', run: onStop, enabled: streaming },
|
||||
{ id: 'clear', label: '/clear', hint: '清空输入', run: () => setValue('') }
|
||||
],
|
||||
[onNewSession, onOpenSettings, onStop, streaming]
|
||||
)
|
||||
|
||||
// "/" 命令面板:输入以 / 开头且当前词无空格时触发
|
||||
const trimmed = value.trimStart()
|
||||
const slashExec = trimmed === value ? /^\/(\S*)$/.exec(trimmed) : null
|
||||
const slashQuery = slashExec ? (slashExec[1] ?? '').toLowerCase() : null
|
||||
|
||||
// "@ " 引用文件:末个 @ 词令牌触发
|
||||
const atMatch = /@([^\s@]*)$/.exec(value)
|
||||
const atQuery = atMatch ? (atMatch[1] ?? '') : null
|
||||
|
||||
// 资源懒加载:每次打开菜单都刷新(文件/skills 变化即时可见),in-flight 防抖
|
||||
const filesLoadingRef = useRef(false)
|
||||
const resourcesLoadingRef = useRef(false)
|
||||
const atOpen = atQuery !== null
|
||||
const slashOpen = slashQuery !== null
|
||||
|
||||
useEffect(() => {
|
||||
if (!slashOpen || resourcesLoadingRef.current) return
|
||||
resourcesLoadingRef.current = true
|
||||
window.api
|
||||
.listChatResources()
|
||||
.then(setResources)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
resourcesLoadingRef.current = false
|
||||
})
|
||||
}, [slashOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!atOpen || filesLoadingRef.current) return
|
||||
filesLoadingRef.current = true
|
||||
window.api
|
||||
.listWorkspaceFiles()
|
||||
.then(setFiles)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
filesLoadingRef.current = false
|
||||
})
|
||||
}, [atOpen])
|
||||
|
||||
// "/" 面板条目:本地命令 + pi 命令模板 + /skill: 技能
|
||||
const commandItems = useMemo(() => {
|
||||
if (slashQuery === null) return []
|
||||
const q = slashQuery.slice(1).toLowerCase()
|
||||
const match = (s: string): boolean => s.toLowerCase().includes(q) || q === ''
|
||||
const items: Array<{ key: string; insert: string; label: string; hint: string; group: string; run?: () => void; enabled?: boolean }> = []
|
||||
for (const cmd of localCommands) {
|
||||
if (match(cmd.label.slice(1))) items.push({ key: `local-${cmd.id}`, insert: '', label: cmd.label, hint: cmd.hint, group: '命令', run: cmd.run, enabled: cmd.enabled })
|
||||
}
|
||||
for (const cmd of resources?.commands ?? []) {
|
||||
if (match(cmd.name)) items.push({ key: `prompt-${cmd.name}`, insert: `/${cmd.name} `, label: `/${cmd.name}`, hint: cmd.description || '命令模板', group: '命令' })
|
||||
}
|
||||
for (const skill of resources?.skills ?? []) {
|
||||
if (match(`skill:${skill.name}`) || match(skill.name)) {
|
||||
items.push({ key: `skill-${skill.name}`, insert: `/skill:${skill.name} `, label: `/skill:${skill.name}`, hint: skill.description || '技能', group: '技能' })
|
||||
}
|
||||
}
|
||||
return items.slice(0, 12)
|
||||
}, [slashQuery, localCommands, resources])
|
||||
|
||||
// "@ " 文件条目
|
||||
const fileItems = useMemo(() => {
|
||||
if (atQuery === null || !files) return []
|
||||
const q = atQuery.toLowerCase()
|
||||
return files
|
||||
.filter((f) => f.path.toLowerCase().includes(q))
|
||||
.sort((a, b) => {
|
||||
const an = a.name.toLowerCase().startsWith(q) ? 0 : 1
|
||||
const bn = b.name.toLowerCase().startsWith(q) ? 0 : 1
|
||||
return an - bn || a.path.length - b.path.length || a.path.localeCompare(b.path)
|
||||
})
|
||||
.slice(0, 8)
|
||||
}, [atQuery, files])
|
||||
|
||||
const menuOpen = !modelOpen && (commandItems.length > 0 || fileItems.length > 0)
|
||||
const menuCount = commandItems.length + fileItems.length
|
||||
|
||||
// ---- 排队消息(steering 先于 followUp 送达;「立即」= 置顶优先送达)----
|
||||
const [queueOverride, setQueueOverride] = useState<{ steering: string[]; followUp: string[] } | null>(null)
|
||||
useEffect(() => {
|
||||
if (!queueOverride) return
|
||||
if (
|
||||
queue.steering.join('\u0000') === queueOverride.steering.join('\u0000') &&
|
||||
queue.followUp.join('\u0000') === queueOverride.followUp.join('\u0000')
|
||||
) {
|
||||
setQueueOverride(null)
|
||||
}
|
||||
}, [queue, queueOverride])
|
||||
const effectiveQueue = queueOverride ?? queue
|
||||
|
||||
const queueItems = useMemo(
|
||||
() => [
|
||||
...effectiveQueue.steering.map((text) => ({ text, from: 'steering' as const })),
|
||||
...effectiveQueue.followUp.map((text) => ({ text, from: 'followUp' as const }))
|
||||
],
|
||||
[effectiveQueue]
|
||||
)
|
||||
|
||||
/** 应用本地新顺序并下发;600ms 内忽略回推的 queue_update,避免行闪烁 */
|
||||
const commitQueue = (items: Array<{ text: string; from: 'steering' | 'followUp' }>): void => {
|
||||
const steering = items.filter((i) => i.from === 'steering').map((i) => i.text)
|
||||
const followUp = items.filter((i) => i.from === 'followUp').map((i) => i.text)
|
||||
setQueueOverride({ steering, followUp })
|
||||
window.setTimeout(() => setQueueOverride(null), 600)
|
||||
void window.api.setQueue(steering, followUp).catch(() => undefined)
|
||||
}
|
||||
|
||||
const removeQueued = (index: number): void => {
|
||||
const items = [...queueItems]
|
||||
items.splice(index, 1)
|
||||
commitQueue(items)
|
||||
}
|
||||
|
||||
const promoteQueued = (index: number): void => {
|
||||
if (index === 0) return
|
||||
const items = [...queueItems]
|
||||
const [item] = items.splice(index, 1)
|
||||
items.unshift(item)
|
||||
commitQueue(items)
|
||||
}
|
||||
|
||||
const reorderQueued = (from: number, to: number): void => {
|
||||
if (from === to) return
|
||||
const items = [...queueItems]
|
||||
const [moved] = items.splice(from, 1)
|
||||
items.splice(to, 0, moved)
|
||||
commitQueue(items)
|
||||
}
|
||||
|
||||
const saveQueuedEdit = (index: number, text: string): void => {
|
||||
const trimmedText = text.trim()
|
||||
if (!trimmedText) return
|
||||
const items = [...queueItems]
|
||||
items[index] = { ...items[index], text: trimmedText }
|
||||
commitQueue(items)
|
||||
}
|
||||
|
||||
const [editingIdx, setEditingIdx] = useState<number | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const dragFromRef = useRef<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
|
||||
// 命令/文件菜单:覆盖层(不占布局,避免聊天区上下跳动)
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null)
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
const rect = barRef.current?.getBoundingClientRect()
|
||||
if (rect) setMenuPos({ top: rect.top, left: rect.left, width: rect.width })
|
||||
} else {
|
||||
setMenuPos(null)
|
||||
}
|
||||
}, [menuOpen])
|
||||
const closeMenu = (): void => {
|
||||
if (atMatch) setValue(value.replace(/@[^\s@]*$/, ''))
|
||||
else if (slashQuery !== null) setValue('')
|
||||
}
|
||||
|
||||
useEffect(() => setActiveIdx(0), [value])
|
||||
|
||||
// 空状态建议词点击 → 填入输入框并聚焦
|
||||
useEffect(() => {
|
||||
@@ -38,73 +334,550 @@ export function Composer({
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`
|
||||
}, [value])
|
||||
|
||||
const modelBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const permBtnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const anchorOf = (el: HTMLElement | null): { top: number; left: number; right: number } | null => {
|
||||
if (!el) return null
|
||||
const rect = el.getBoundingClientRect()
|
||||
return { top: rect.top, left: rect.left, right: window.innerWidth - rect.right }
|
||||
}
|
||||
|
||||
const openModelMenu = (): void => {
|
||||
if (modelOpen) {
|
||||
setModelOpen(false)
|
||||
return
|
||||
}
|
||||
setModelPos(anchorOf(modelBtnRef.current))
|
||||
setModelOpen((open) => {
|
||||
if (!open && !catalogLoadedRef.current) {
|
||||
catalogLoadedRef.current = true
|
||||
setCatalogLoading(true)
|
||||
window.api
|
||||
.getModelCatalog()
|
||||
.then(setCatalog)
|
||||
.catch(() => undefined)
|
||||
.finally(() => setCatalogLoading(false))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const togglePermMenu = (): void => {
|
||||
if (permOpen) {
|
||||
setPermOpen(false)
|
||||
return
|
||||
}
|
||||
setPermPos(anchorOf(permBtnRef.current))
|
||||
setPermOpen(true)
|
||||
}
|
||||
|
||||
const pickMenuItem = (index: number): void => {
|
||||
if (index < commandItems.length) {
|
||||
const item = commandItems[index]
|
||||
setValue('')
|
||||
textareaRef.current?.focus()
|
||||
if (item.insert) {
|
||||
setValue(item.insert)
|
||||
textareaRef.current?.focus()
|
||||
return
|
||||
}
|
||||
if (item.enabled !== false) item.run?.()
|
||||
return
|
||||
}
|
||||
const file = fileItems[index - commandItems.length]
|
||||
if (!file) return
|
||||
setValue((current) => current.replace(/@[^\s@]*$/, `@${file.path} `))
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
const addAttachments = (): void => {
|
||||
if (picking) return
|
||||
setPicking(true)
|
||||
window.api
|
||||
.pickAttachments()
|
||||
.then((picked) => {
|
||||
setAttachments((cur) => {
|
||||
const seen = new Set(cur.map((a) => a.path))
|
||||
return [...cur, ...picked.filter((p) => !seen.has(p.path))]
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setPicking(false))
|
||||
}
|
||||
|
||||
const submit = (): void => {
|
||||
if (disabled) return
|
||||
const text = value.trim()
|
||||
if (!text || disabled) return
|
||||
onSend(text)
|
||||
const images = attachments.filter((a) => a.data && a.mimeType.startsWith('image/'))
|
||||
const fileRefs = attachments.filter((a) => !(a.data && a.mimeType.startsWith('image/')))
|
||||
if (!text && images.length === 0) return
|
||||
const refLines = fileRefs.map((f) => `@${f.path}`)
|
||||
const full = refLines.length > 0 ? `${text}${text ? '\n\n' : ''}${refLines.join('\n')}` : text
|
||||
onSend(full, images.map((img) => ({ mimeType: img.mimeType, data: img.data })))
|
||||
setValue('')
|
||||
setAttachments([])
|
||||
}
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.nativeEvent.isComposing) return
|
||||
if (menuOpen && menuCount > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIdx((i) => (i + 1) % menuCount)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIdx((i) => (i - 1 + menuCount) % menuCount)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
pickMenuItem(activeIdx)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setValue(atMatch ? value.replace(/@[^\s@]*$/, '') : '')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
const dictation = useDictation((text) => setValue((current) => (current ? `${current} ${text}` : text)))
|
||||
|
||||
const permMeta = PERMISSION_MODES.find((m) => m.id === permissionMode) ?? PERMISSION_MODES[0]
|
||||
|
||||
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
|
||||
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
|
||||
const canSend = !disabled && (value.trim().length > 0 || attachments.some((a) => a.data && a.mimeType.startsWith('image/')))
|
||||
|
||||
// 菜单分段偏移(键盘高亮跨段连续)
|
||||
const commandEnd = commandItems.length
|
||||
|
||||
const renderMenuItem = (item: (typeof commandItems)[number], index: number): ReactNode => {
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => pickMenuItem(index)}
|
||||
onMouseEnter={() => setActiveIdx(index)}
|
||||
disabled={item.enabled === false}
|
||||
className={`flex h-8 w-full items-center gap-2 rounded-[6px] px-2 text-left text-[13px] transition-colors disabled:opacity-40 ${
|
||||
index === activeIdx ? 'bg-hover' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 shrink-0 font-mono text-[12px] text-ink-2">{item.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-ink-3">{item.hint}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-6 pb-5 pt-1">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/70 shadow-xl shadow-black/30 transition-colors focus-within:border-zinc-700">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
spellCheck={false}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? 'Agent 会话未就绪…' : '描述你的目标,或直接下达指令(Enter 发送,Shift+Enter 换行)'}
|
||||
className="block max-h-[200px] w-full bg-transparent px-4 pt-3.5 pb-1 text-[14px] leading-6 text-zinc-100 caret-emerald-400 outline-none placeholder:text-zinc-600 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-3 pb-2.5 pt-1">
|
||||
<div className="flex items-center gap-2 text-[11px] text-zinc-600">
|
||||
{streaming ? (
|
||||
queuedCount > 0 ? (
|
||||
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-zinc-400">队列中 {queuedCount} 条</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<CornerDownLeft size={11} />
|
||||
回复中,Enter 可排队追加
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<CornerDownLeft size={11} />
|
||||
Enter 发送 · Shift+Enter 换行
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{streaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-800 text-zinc-300 transition-colors hover:border-red-800 hover:bg-red-950/60 hover:text-red-300"
|
||||
title="停止生成"
|
||||
{/* Prompt Bar 主体(命令/文件菜单为 portal 覆盖层,不占布局) */}
|
||||
<div className="relative" ref={barRef}>
|
||||
<div className="relative isolate flex flex-col overflow-hidden gap-1.5 rounded-[14px] border border-line bg-surface p-1.5 shadow-card transition-[border-color] duration-150 focus-within:border-line-strong">
|
||||
|
||||
{/* 排队消息:拖拽排序 / 立即 / 编辑 / 删除 */}
|
||||
{queueItems.length > 0 && (
|
||||
<div className="flex flex-col gap-1 px-0.5 pt-0.5">
|
||||
{queueItems.map((item, i) => (
|
||||
<div
|
||||
key={`${i}-${item.text.slice(0, 24)}`}
|
||||
draggable={editingIdx !== i}
|
||||
onDragStart={() => {
|
||||
dragFromRef.current = i
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOverIdx(i)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
if (dragFromRef.current !== null) reorderQueued(dragFromRef.current, i)
|
||||
dragFromRef.current = null
|
||||
setDragOverIdx(null)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragFromRef.current = null
|
||||
setDragOverIdx(null)
|
||||
}}
|
||||
className={`group/qrow flex h-9 items-center gap-1 rounded-[8px] border border-line bg-canvas pl-1 pr-1.5 ${
|
||||
dragOverIdx === i ? 'ring-1 ring-accent' : ''
|
||||
} ${dragFromRef.current === i ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<Square size={12} className="fill-current" />
|
||||
</button>
|
||||
)}
|
||||
<span className="cursor-grab select-none text-ink-3 active:cursor-grabbing" title="拖拽排序">
|
||||
<LuGripVertical size={13} />
|
||||
</span>
|
||||
{editingIdx === i ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
saveQueuedEdit(i, editValue)
|
||||
setEditingIdx(null)
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingIdx(null)
|
||||
}
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded-[6px] bg-field px-2 py-1 text-[12.5px] text-ink outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="保存修改"
|
||||
title="保存"
|
||||
onClick={() => {
|
||||
saveQueuedEdit(i, editValue)
|
||||
setEditingIdx(null)
|
||||
}}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-[6px] text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<LuCheck size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="取消编辑"
|
||||
title="取消"
|
||||
onClick={() => setEditingIdx(null)}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-[6px] text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<LuX size={13} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink-2">{item.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => promoteQueued(i)}
|
||||
disabled={i === 0}
|
||||
title="插队,下一个送达"
|
||||
className="flex h-6 shrink-0 items-center gap-0.5 rounded-[6px] border border-line bg-surface px-1.5 text-[11px] text-ink-2 transition-colors hover:bg-hover hover:text-ink disabled:cursor-default disabled:opacity-40 disabled:hover:bg-surface"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 19V5M5 12l7-7 7 7" />
|
||||
</svg>
|
||||
立即
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="编辑排队消息"
|
||||
title="编辑"
|
||||
onClick={() => {
|
||||
setEditingIdx(i)
|
||||
setEditValue(item.text)
|
||||
}}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-[6px] text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<LuPencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="移除排队消息"
|
||||
title="移除"
|
||||
onClick={() => removeQueued(i)}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-[6px] text-ink-3 transition-colors hover:bg-red-tint hover:text-red"
|
||||
>
|
||||
<LuTrash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 附件 chips:图片缩略图 / 文件图标 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-0.5 pt-0.5">
|
||||
{attachments.map((att) => (
|
||||
<span
|
||||
key={att.path}
|
||||
className="group/chip flex items-center gap-1.5 rounded-[8px] border border-line bg-canvas py-1 pl-1 pr-1.5"
|
||||
>
|
||||
{att.data && att.mimeType.startsWith('image/') ? (
|
||||
<img
|
||||
src={`data:${att.mimeType};base64,${att.data}`}
|
||||
alt={att.name}
|
||||
className="size-7 rounded-[5px] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-7 items-center justify-center rounded-[5px] bg-inset text-ink-3">
|
||||
<LuFileText size={14} />
|
||||
</span>
|
||||
)}
|
||||
<span className="max-w-[140px] truncate text-[11.5px] text-ink-2">{att.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除附件 ${att.name}`}
|
||||
onClick={() => setAttachments((cur) => cur.filter((a) => a.path !== att.path))}
|
||||
className="flex size-4 items-center justify-center rounded-full text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink"
|
||||
>
|
||||
<LuX size={11} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[28px_minmax(0,1fr)_auto_auto_28px_28px] items-end gap-x-1 gap-y-1.5">
|
||||
{/* +:添加附件 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="添加附件"
|
||||
onClick={addAttachments}
|
||||
disabled={disabled || picking}
|
||||
title="添加附件(图片随消息发送,其他文件以 @路径 引用)"
|
||||
className="col-start-1 row-start-1 flex size-7 shrink-0 justify-self-start items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={1}
|
||||
spellCheck={false}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? 'Agent 会话未就绪…' : streaming ? '继续输入以排队后续修改…' : '写下消息,/ 命令与技能、@ 引用文件'}
|
||||
aria-label="Prompt"
|
||||
className="col-start-2 row-start-1 min-h-7 w-full min-w-0 resize-none bg-transparent px-1 py-[5px] text-[13px] leading-[18px] text-ink outline-none [overflow-wrap:anywhere] placeholder:text-ink-3 disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<button
|
||||
ref={modelBtnRef}
|
||||
type="button"
|
||||
aria-expanded={modelOpen}
|
||||
aria-label="Choose model"
|
||||
onClick={openModelMenu}
|
||||
disabled={disabled}
|
||||
className="col-start-3 row-start-1 flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="max-w-[160px] truncate">{model?.name ?? '选择模型'}</span>
|
||||
<span className="text-ink-3">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 权限模式(图标) */}
|
||||
<button
|
||||
ref={permBtnRef}
|
||||
type="button"
|
||||
aria-expanded={permOpen}
|
||||
aria-label={`权限模式:${permMeta.label}`}
|
||||
onClick={togglePermMenu}
|
||||
disabled={disabled}
|
||||
title={`权限模式:${permMeta.label}(${permMeta.desc})`}
|
||||
className={`col-start-4 row-start-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
|
||||
}`}
|
||||
>
|
||||
<permMeta.icon size={14} />
|
||||
</button>
|
||||
|
||||
{/* 语音输入 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={dictation.listening ? '停止语音输入' : '开始语音输入'}
|
||||
aria-pressed={dictation.listening}
|
||||
onClick={dictation.toggle}
|
||||
disabled={!dictation.supported}
|
||||
title={dictation.supported ? '语音输入' : '当前环境不支持语音输入'}
|
||||
className={`col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-150 active:scale-[0.94] ${
|
||||
dictation.listening ? 'bg-red-tint text-red' : 'text-ink-3 hover:bg-hover hover:text-ink'
|
||||
} disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent`}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 发送 / 停止 */}
|
||||
{streaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={disabled || !value.trim()}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-600 text-white transition-all hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-zinc-800 disabled:text-zinc-600"
|
||||
title="发送"
|
||||
aria-label="停止生成"
|
||||
onClick={onStop}
|
||||
className="col-start-6 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] bg-ink text-canvas transition-[background-color,color,transform] duration-200 hover:opacity-90 active:scale-[0.94]"
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Send"
|
||||
onClick={submit}
|
||||
disabled={!canSend}
|
||||
className="col-start-6 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] disabled:cursor-not-allowed"
|
||||
style={canSend ? { background: 'var(--ink)', color: 'var(--canvas)' } : { background: 'var(--line-strong)', color: 'var(--ink-2)' }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 19V5M5 12l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 命令 / 技能 / 文件 菜单(portal 覆盖层,向上弹出) */}
|
||||
{menuOpen && menuPos && (
|
||||
<ComposerMenu
|
||||
anchor={{
|
||||
top: menuPos.top,
|
||||
left: menuPos.left,
|
||||
right: window.innerWidth - menuPos.left - menuPos.width
|
||||
}}
|
||||
align="left"
|
||||
onClose={closeMenu}
|
||||
>
|
||||
<div style={{ width: menuPos.width }} className="overflow-hidden">
|
||||
{commandItems.length > 0 && (
|
||||
<div className="p-1">
|
||||
{commandItems.map((item, i) => (
|
||||
<div key={item.key}>
|
||||
{(i === 0 || commandItems[i - 1].group !== item.group) && (
|
||||
<div className="px-2 pb-1 pt-1.5 text-[10.5px] font-medium uppercase tracking-wide text-ink-3">
|
||||
{item.group}
|
||||
</div>
|
||||
)}
|
||||
{renderMenuItem(item, i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fileItems.length > 0 && (
|
||||
<div className={`p-1 ${commandItems.length > 0 ? 'border-t border-line' : ''}`}>
|
||||
<div className="px-2 pb-1 pt-1.5 text-[10.5px] font-medium uppercase tracking-wide text-ink-3">文件</div>
|
||||
{fileItems.map((file, i) => {
|
||||
const index = commandEnd + i
|
||||
return (
|
||||
<button
|
||||
key={file.path}
|
||||
type="button"
|
||||
onClick={() => pickMenuItem(index)}
|
||||
onMouseEnter={() => setActiveIdx(index)}
|
||||
className={`flex h-8 w-full items-center gap-2 rounded-[6px] px-2 text-left text-[13px] text-ink transition-colors ${
|
||||
index === activeIdx ? 'bg-hover' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 text-ink-3">@</span>
|
||||
<span className="shrink-0 font-medium">{file.name}</span>
|
||||
{file.path.includes('/') && (
|
||||
<span className="min-w-0 flex-1 truncate text-[11.5px] text-ink-3">{file.path}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ComposerMenu>
|
||||
)}
|
||||
|
||||
{/* 权限模式菜单(portal,向上弹出,右缘对齐) */}
|
||||
{permOpen && permPos && (
|
||||
<ComposerMenu anchor={permPos} align="right" onClose={() => setPermOpen(false)}>
|
||||
<div className="w-72 p-1.5">
|
||||
{PERMISSION_MODES.map((mode) => {
|
||||
const active = mode.id === permissionMode
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPermissionMode(mode.id)
|
||||
setPermOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-start gap-2.5 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover ${
|
||||
active ? 'bg-hover' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-[6px] bg-inset text-ink-2">
|
||||
<mode.icon size={13} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[12.5px] font-medium text-ink">{mode.label}</span>
|
||||
<span className="block text-[11.5px] leading-4 text-ink-3">{mode.desc}</span>
|
||||
</span>
|
||||
{active && <LuCheck size={14} className="mt-1 shrink-0 text-accent-ink" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ComposerMenu>
|
||||
)}
|
||||
|
||||
{/* 模型选择菜单(portal,向上弹出) */}
|
||||
{modelOpen && modelPos && (
|
||||
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
|
||||
<div className="w-72">
|
||||
<div className="max-h-80 overflow-y-auto p-1.5">
|
||||
{catalogLoading && <div className="px-2.5 py-3 text-[12px] text-ink-3">加载模型中…</div>}
|
||||
{!catalogLoading && providers.length === 0 && (
|
||||
<div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div>
|
||||
)}
|
||||
{providers.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-medium uppercase tracking-wide text-ink-3">
|
||||
{provider.name}
|
||||
</div>
|
||||
{provider.models.map((m) => {
|
||||
const selected = model?.provider === provider.id && model?.id === m.id
|
||||
return (
|
||||
<button
|
||||
key={`${provider.id}/${m.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSetModel(provider.id, m.id)
|
||||
setModelOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span>
|
||||
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-line p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModelOpen(false)
|
||||
onOpenSettings()
|
||||
}}
|
||||
className="flex w-full items-center rounded-control px-2.5 py-1.5 text-[12.5px] text-ink-2 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
管理模型与密钥…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ComposerMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
/** 单一悬浮高亮层在菜单行之间滑移(beautifului.dev 的 glide hover)。
|
||||
* 行元素需带 data-menu-row,高亮层 top/height 220ms expo-out、透明度 150ms。 */
|
||||
export function GlideMenu({
|
||||
children,
|
||||
className = '',
|
||||
highlightClassName = 'inset-x-0 rounded-[6px] bg-hover',
|
||||
rowSelector = '[data-menu-row]'
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
highlightClassName?: string
|
||||
rowSelector?: string
|
||||
}): ReactNode {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [box, setBox] = useState<{ top: number; height: number } | null>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
const moveTo = (target: EventTarget | null): void => {
|
||||
const container = ref.current
|
||||
if (!(target instanceof Element) || !container) return
|
||||
const row = target.closest(rowSelector)
|
||||
if (!(row instanceof HTMLElement) || !container.contains(row)) return
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
setBox({ top: rowRect.top - containerRect.top, height: rowRect.height })
|
||||
setVisible(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseOver={(event) => moveTo(event.target)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
onFocusCapture={(event) => moveTo(event.target)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false)
|
||||
}}
|
||||
className={`group/glide-menu relative flex flex-col gap-px ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute ${highlightClassName}`}
|
||||
style={{
|
||||
top: box?.top ?? 0,
|
||||
height: box?.height ?? 0,
|
||||
opacity: box && visible ? 1 : 0,
|
||||
transition:
|
||||
'top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease'
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 像素网格加载器 + shimmer 标签 + 计时(beautifului.dev Loading State)。
|
||||
* Drive=方格 / Dots=圆点 / Orbit=绕圈。 */
|
||||
const chevron = Array.from({ length: 9 }, (_, i) => {
|
||||
const r = Math.floor(i / 3)
|
||||
const c = i % 3
|
||||
return (c + Math.abs(r - 1)) * 90
|
||||
})
|
||||
|
||||
const ORBIT_ORDER = [0, 1, 2, 5, 8, 7, 6, 3]
|
||||
const orbit = Array.from({ length: 9 }, (_, i) => {
|
||||
const k = ORBIT_ORDER.indexOf(i)
|
||||
return k === -1 ? null : k * 110
|
||||
})
|
||||
|
||||
const PATTERNS: Record<string, { delays: (number | null)[]; dur: number; round: boolean }> = {
|
||||
Drive: { delays: chevron, dur: 650, round: false },
|
||||
Dots: { delays: chevron, dur: 650, round: true },
|
||||
Orbit: { delays: orbit, dur: 950, round: false }
|
||||
}
|
||||
|
||||
function LoaderGrid({
|
||||
delays,
|
||||
dur,
|
||||
round
|
||||
}: {
|
||||
delays: (number | null)[]
|
||||
dur: number
|
||||
round: boolean
|
||||
}): ReactNode {
|
||||
return (
|
||||
<span aria-hidden className="grid shrink-0 grid-cols-[repeat(3,4px)] gap-[1.5px]">
|
||||
{delays.map((delay, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`size-[4px] bg-ink ${round ? 'rounded-full' : 'rounded-[1px]'}`}
|
||||
style={{
|
||||
opacity: delay === null ? 0.07 : 0.15,
|
||||
animation: delay === null ? 'none' : `pixel-on ${dur}ms ease-in-out ${delay}ms infinite`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Loading({ label = '处理中', variant = 'Drive' }: { label?: string; variant?: keyof typeof PATTERNS | string }): ReactNode {
|
||||
const [ds, setDs] = useState(0)
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setDs((d) => d + 1), 100)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
const total = ds / 10
|
||||
const elapsed = total < 60 ? `${total.toFixed(1)}s` : `${Math.floor(total / 60)}m ${(total % 60).toFixed(1)}s`
|
||||
const { delays, dur, round } = PATTERNS[variant] ?? PATTERNS.Drive
|
||||
|
||||
return (
|
||||
<div role="status" className="flex w-fit items-center gap-2.5">
|
||||
<LoaderGrid delays={delays} dur={dur} round={round} />
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium text-transparent"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer-text 1.4s linear infinite'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-mono text-[12px] text-ink-3 tabular-nums">{elapsed}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,224 +1,279 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import {
|
||||
useLLMOutput,
|
||||
type BlockMatch,
|
||||
type LLMOutputBlock,
|
||||
type LLMOutputComponent,
|
||||
type LLMOutputFallbackBlock
|
||||
} from '@llm-ui/react'
|
||||
import { markdownLookBack } from '@llm-ui/markdown'
|
||||
import {
|
||||
codeBlockLookBack,
|
||||
findCompleteCodeBlock,
|
||||
findPartialCodeBlock,
|
||||
parseCompleteMarkdownCodeBlock,
|
||||
parsePartialMarkdownCodeBlock
|
||||
} from '@llm-ui/code'
|
||||
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
|
||||
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
|
||||
import { bundledLanguages, type BundledLanguage } from 'shiki/langs'
|
||||
import { bundledThemes } from 'shiki/themes'
|
||||
import { CodeBlock, type ShikiLine } from './CodeBlock'
|
||||
|
||||
/**
|
||||
* 轻量 Markdown 渲染:
|
||||
* fenced code block、标题(#~###)、无序/有序列表、引用块、链接、行内 code、加粗。
|
||||
* 全部构建 React 节点,不使用 dangerouslySetInnerHTML。
|
||||
* 链接点击由外层(Electron main 的 setWindowOpenHandler)转交系统浏览器。
|
||||
* Markdown 渲染:基于 llm-ui(github.com/richardgill/llm-ui)做块级解析 ——
|
||||
* 围栏代码块由 @llm-ui/code 的匹配器拆出(流式中未闭合的围栏也能正确归属),
|
||||
* 其余内容作为 markdown 回退块交给 react-markdown + remark-gfm(表格/删除线/任务列表)。
|
||||
* 视觉样式通过 components 映射到 beautifului 设计令牌;
|
||||
* 代码块用 Shiki 做真实语法高亮(按需加载语言),保留本项目 CodeBlock 的行号/复制/diff 视图。
|
||||
*/
|
||||
|
||||
interface CodeBlock {
|
||||
lang: string
|
||||
code: string
|
||||
/** Shiki 高亮器:github-light 主题 + 常用语言(grammar 在运行时按需动态加载) */
|
||||
const HIGHLIGHT_LANGS = [
|
||||
'typescript',
|
||||
'javascript',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'python',
|
||||
'rust',
|
||||
'go',
|
||||
'java',
|
||||
'kotlin',
|
||||
'c',
|
||||
'cpp',
|
||||
'csharp',
|
||||
'ruby',
|
||||
'php',
|
||||
'swift',
|
||||
'bash',
|
||||
'powershell',
|
||||
'json',
|
||||
'yaml',
|
||||
'toml',
|
||||
'html',
|
||||
'css',
|
||||
'sql',
|
||||
'markdown',
|
||||
'dockerfile',
|
||||
'diff'
|
||||
]
|
||||
|
||||
const highlighterPromise: Promise<HighlighterCore> = createHighlighterCore({
|
||||
themes: [bundledThemes['github-light']],
|
||||
langs: HIGHLIGHT_LANGS.map((name) => bundledLanguages[name as BundledLanguage]),
|
||||
engine: createOnigurumaEngine(import('shiki/wasm'))
|
||||
})
|
||||
|
||||
/** 常见语言别名 → shiki 语法名 */
|
||||
const LANG_ALIAS: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
jsx: 'jsx',
|
||||
ts: 'typescript',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
rs: 'rust',
|
||||
golang: 'go',
|
||||
'c++': 'cpp',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
zsh: 'bash',
|
||||
console: 'bash',
|
||||
terminal: 'bash',
|
||||
yml: 'yaml',
|
||||
htm: 'html',
|
||||
scss: 'css',
|
||||
less: 'css',
|
||||
postgres: 'sql',
|
||||
mysql: 'sql',
|
||||
text: '',
|
||||
txt: '',
|
||||
plaintext: ''
|
||||
}
|
||||
|
||||
function splitFences(text: string): Array<{ type: 'text'; text: string } | { type: 'code'; block: CodeBlock }> {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'code'; block: CodeBlock }> = []
|
||||
const regex = /```(\w*)\n?([\s\S]*?)(?:```|$)/g
|
||||
let last = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(text))) {
|
||||
if (match.index > last) parts.push({ type: 'text', text: text.slice(last, match.index) })
|
||||
parts.push({ type: 'code', block: { lang: match[1] ?? '', code: match[2] ?? '' } })
|
||||
last = regex.lastIndex
|
||||
}
|
||||
if (last < text.length) parts.push({ type: 'text', text: text.slice(last) })
|
||||
return parts
|
||||
}
|
||||
|
||||
function renderInline(text: string, keyPrefix: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = []
|
||||
const regex = /(\*\*[^*\n]+\*\*|`[^`\n]+`|\[[^\]\n]+\]\((https?:\/\/[^)\s]+)\))/g
|
||||
let last = 0
|
||||
let match: RegExpExecArray | null
|
||||
let i = 0
|
||||
while ((match = regex.exec(text))) {
|
||||
if (match.index > last) nodes.push(text.slice(last, match.index))
|
||||
const token = match[0]
|
||||
if (token.startsWith('**')) {
|
||||
nodes.push(
|
||||
<strong key={`${keyPrefix}-b${i}`} className="font-semibold text-zinc-50">
|
||||
{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">
|
||||
{token.slice(1, -1)}
|
||||
</code>
|
||||
)
|
||||
const mdComponents: Components = {
|
||||
p({ children }) {
|
||||
return <p className="leading-normal">{children}</p>
|
||||
},
|
||||
h1({ children }) {
|
||||
return <h3 className="mb-1 mt-3 text-[17px] font-semibold text-ink first:mt-0">{children}</h3>
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h4 className="mb-1 mt-3 text-[15.5px] font-semibold text-ink first:mt-0">{children}</h4>
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h5 className="mb-1 mt-2.5 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h5>
|
||||
},
|
||||
h4({ children }) {
|
||||
return <h6 className="mb-1 mt-2.5 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h6>
|
||||
},
|
||||
h5: ({ children }) => <h6 className="mb-1 mt-2.5 text-[14px] font-semibold text-ink">{children}</h6>,
|
||||
h6: ({ children }) => <h6 className="mb-1 mt-2.5 text-[13.5px] font-semibold text-ink-2">{children}</h6>,
|
||||
ul({ children }) {
|
||||
return <ul className="my-2 list-disc space-y-1.5 pl-5 marker:text-ink-3">{children}</ul>
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="my-2 list-decimal space-y-1.5 pl-5 marker:text-ink-3">{children}</ol>
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="leading-normal">{children}</li>
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="my-3 border-l-2 border-line pl-3 text-[13.5px] italic text-ink-3">
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13.5px]">{children}</table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead>{children}</thead>
|
||||
},
|
||||
th({ children, style }) {
|
||||
return (
|
||||
<th
|
||||
style={style}
|
||||
className="border-b border-line bg-inset px-3 py-1.5 text-[12.5px] font-semibold text-ink first:rounded-l-control last:rounded-r-control"
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
td({ children, style }) {
|
||||
return (
|
||||
<td style={style} className="border-b border-line px-3 py-1.5 align-top leading-normal text-ink-2 last:border-b-0">
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
},
|
||||
tr({ children }) {
|
||||
return <tr className="border-b border-line last:border-b-0">{children}</tr>
|
||||
},
|
||||
code({ className, children }) {
|
||||
if (typeof className === 'string' && className.includes('language-')) {
|
||||
// 围栏代码块正常由 llm-ui 拆走;这里兜底缩进式/未识别的块级代码
|
||||
return <code className="block font-mono text-[12.5px] leading-relaxed">{children}</code>
|
||||
}
|
||||
last = regex.lastIndex
|
||||
i += 1
|
||||
return (
|
||||
<code className="rounded bg-inset px-1.5 py-0.5 font-mono text-[0.85em] text-ink-2">{children}</code>
|
||||
)
|
||||
},
|
||||
pre({ children }) {
|
||||
return (
|
||||
<pre className="my-3 overflow-x-auto rounded-card bg-inset p-3 text-ink-2">{children}</pre>
|
||||
)
|
||||
},
|
||||
a({ children, href }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-accent-ink underline decoration-accent/50 underline-offset-2 hover:text-accent"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
strong({ children }) {
|
||||
return <strong className="font-semibold text-ink">{children}</strong>
|
||||
},
|
||||
del({ children }) {
|
||||
return <del className="text-ink-3">{children}</del>
|
||||
},
|
||||
img({ src, alt }) {
|
||||
return <img src={src} alt={alt} className="my-2 max-w-full rounded-card border border-line" />
|
||||
},
|
||||
hr() {
|
||||
return <hr className="my-3 border-line" />
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last))
|
||||
return nodes
|
||||
}
|
||||
|
||||
const INLINE = /^(\s*)([-*+]\s+|\d+[.)]\s+|>\s?|(#{1,3}\s+))?(.*)$/
|
||||
const MarkdownComponent: LLMOutputComponent = ({ blockMatch }) => (
|
||||
<div className="space-y-5 text-base text-ink">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{blockMatch.output}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface Block {
|
||||
kind: 'p' | 'h1' | 'h2' | 'h3' | 'ul' | 'ol' | 'quote'
|
||||
lines: string[]
|
||||
}
|
||||
const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
|
||||
const parse = blockMatch.isComplete ? parseCompleteMarkdownCodeBlock : parsePartialMarkdownCodeBlock
|
||||
const { language, code } = parse(blockMatch.output)
|
||||
const source = code ?? ''
|
||||
const rawLang = (language ?? '').toLowerCase()
|
||||
const shikiLang = LANG_ALIAS[rawLang] ?? (HIGHLIGHT_LANGS.includes(rawLang) ? rawLang : '')
|
||||
|
||||
/** 把普通文本切成块级结构(段落/标题/列表/引用) */
|
||||
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
|
||||
const [tokens, setTokens] = useState<ShikiLine[] | null>(null)
|
||||
useEffect(() => {
|
||||
if (!source.trim() || !shikiLang) {
|
||||
setTokens(null)
|
||||
return
|
||||
}
|
||||
|
||||
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-2.5 text-[14.5px] text-zinc-200">
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === 'code') {
|
||||
return (
|
||||
<div key={i} className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
{part.block.lang && (
|
||||
<div className="border-b border-zinc-800 px-3 py-1 font-mono text-[11px] text-zinc-500">{part.block.lang}</div>
|
||||
)}
|
||||
<pre className="overflow-x-auto p-3 font-mono text-[12.5px] leading-relaxed text-zinc-200">
|
||||
<code>{part.block.code.replace(/\n$/, '')}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
let alive = true
|
||||
highlighterPromise
|
||||
.then((highlighter) => {
|
||||
if (!alive) return
|
||||
try {
|
||||
const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: 'github-light' })
|
||||
setTokens(result.tokens)
|
||||
} catch {
|
||||
setTokens(null)
|
||||
}
|
||||
const trimmed = part.text.replace(/^\n+|\n+$/g, '')
|
||||
if (!trimmed) return null
|
||||
return (
|
||||
<div key={i}>
|
||||
{parseBlocks(trimmed).map((block, j) => (
|
||||
<BlockView key={j} block={block} k={j} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [source, shikiLang])
|
||||
|
||||
return (
|
||||
<div className="my-2.5">
|
||||
<CodeBlock code={source} lang={language ?? ''} tokens={tokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 匹配器在模块级创建一次,避免每条消息/每帧重建
|
||||
const markdownFallback: LLMOutputFallbackBlock = {
|
||||
component: MarkdownComponent,
|
||||
lookBack: markdownLookBack()
|
||||
}
|
||||
|
||||
const codeBlock: LLMOutputBlock = {
|
||||
findPartialMatch: findPartialCodeBlock(),
|
||||
findCompleteMatch: findCompleteCodeBlock(),
|
||||
component: CodeBlockComponent,
|
||||
lookBack: codeBlockLookBack()
|
||||
}
|
||||
|
||||
function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
|
||||
const Component = blockMatch.block.component
|
||||
return <Component blockMatch={blockMatch} />
|
||||
}
|
||||
|
||||
export function Markdown({ text }: { text: string }): ReactNode {
|
||||
const { blockMatches } = useLLMOutput({
|
||||
llmOutput: text,
|
||||
isStreamFinished: true,
|
||||
fallbackBlock: markdownFallback,
|
||||
blocks: [codeBlock]
|
||||
})
|
||||
if (!text.trim()) return null
|
||||
return (
|
||||
<>
|
||||
{blockMatches.map((blockMatch, i) => (
|
||||
<BlockView key={i} blockMatch={blockMatch} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { Brain, ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
|
||||
import { Markdown } from './Markdown'
|
||||
import { ToolCard, ToolPendingApproval, useEntrance } from './ToolCard'
|
||||
import { Loading } from './Loading'
|
||||
import { StreamingText } from './StreamingText'
|
||||
import { Thinking, thinkingRows } from './Thinking'
|
||||
import { ToolChips, type ToolCallView } from './ToolChips'
|
||||
import { useEntrance } from '../hooks/useEntrance'
|
||||
|
||||
interface ContentBlock {
|
||||
type?: string
|
||||
text?: string
|
||||
/** pi 的 thinking 块字段(thinkingSignature 标记来源) */
|
||||
thinking?: string
|
||||
id?: string
|
||||
name?: string
|
||||
arguments?: unknown
|
||||
@@ -24,94 +28,177 @@ function readBlocks(raw: unknown): ContentBlock[] {
|
||||
return []
|
||||
}
|
||||
|
||||
function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }): JSX.Element | null {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!text.trim()) return null
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[12px] text-zinc-500 transition-colors hover:bg-zinc-800/60 hover:text-zinc-400"
|
||||
>
|
||||
<Brain size={13} className={streaming ? 'text-emerald-400/80' : ''} />
|
||||
<span>{streaming ? '思考中…' : '思考过程'}</span>
|
||||
<ChevronRight size={12} className={`transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<pre className="mt-1 max-h-72 overflow-y-auto whitespace-pre-wrap rounded-lg border border-zinc-800/70 bg-zinc-900/40 p-3 font-mono text-[12px] leading-relaxed text-zinc-500">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
/** 工作计时:working=true 起表,结束后冻结读数 */
|
||||
function useWorkingSeconds(working: boolean): string {
|
||||
const startRef = useRef<number | null>(null)
|
||||
const [finalSecs, setFinalSecs] = useState<number | null>(null)
|
||||
const [, tick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!working) return
|
||||
if (startRef.current === null) startRef.current = Date.now()
|
||||
const timer = setInterval(() => tick((n) => n + 1), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [working])
|
||||
|
||||
useEffect(() => {
|
||||
if (!working && startRef.current !== null && finalSecs === null) {
|
||||
setFinalSecs(Math.max(1, Math.round((Date.now() - startRef.current) / 1000)))
|
||||
}
|
||||
}, [working, finalSecs])
|
||||
|
||||
const secs =
|
||||
finalSecs !== null
|
||||
? finalSecs
|
||||
: startRef.current !== null
|
||||
? Math.max(1, Math.round((Date.now() - startRef.current) / 1000))
|
||||
: null
|
||||
if (secs === null) return ''
|
||||
if (secs < 60) return `已工作 ${secs} 秒`
|
||||
return `已工作 ${Math.floor(secs / 60)} 分 ${secs % 60} 秒`
|
||||
}
|
||||
|
||||
function AssistantBody({
|
||||
message,
|
||||
/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
|
||||
* 过程可折叠:头部为工作计时/调用统计(ZCode 式),收起只留头部 + 正文,
|
||||
* 展开按时间顺序铺出全部思考/工具行;流式期间自动展开,结束自动收起。 */
|
||||
export function AssistantTurn({
|
||||
messages,
|
||||
tools,
|
||||
streaming,
|
||||
pendingApprovals
|
||||
}: {
|
||||
message: UiMessage
|
||||
messages: UiMessage[]
|
||||
tools: Record<string, ToolState>
|
||||
streaming: boolean
|
||||
pendingApprovals: ApprovalRequest[]
|
||||
}): JSX.Element {
|
||||
if (message.stream) {
|
||||
return (
|
||||
<div>
|
||||
<ThinkingBlock text={message.stream.thinking} streaming />
|
||||
{message.stream.text ? (
|
||||
<div className="text-[14.5px] leading-7 text-zinc-200">
|
||||
<Markdown text={message.stream.text} />
|
||||
{streaming && <span className="stream-caret" />}
|
||||
</div>
|
||||
) : streaming && !message.stream.thinking ? (
|
||||
<div className="flex items-center gap-1.5 py-1 text-zinc-500">
|
||||
<span className="thinking-dot h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
<span className="thinking-dot h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
<span className="thinking-dot h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
</div>
|
||||
) : null}
|
||||
}): ReactNode {
|
||||
const units: ReactNode[] = []
|
||||
const textUnits: ReactNode[] = []
|
||||
const labelCount = new Map<string, number>()
|
||||
let calls = 0
|
||||
let thinking = 0
|
||||
let callsAndThinking: ToolCallView[] = []
|
||||
let thinkingTexts: string[] = []
|
||||
let stream: { text: string; thinking: string } | null = null
|
||||
|
||||
/** 连续的思考/工具行作为一组裸行铺出(组内保留顺序),不缩进 */
|
||||
const flushRows = (): void => {
|
||||
if (!callsAndThinking.length && !thinkingTexts.length) return
|
||||
const group = { calls: callsAndThinking, thinkingTexts }
|
||||
callsAndThinking = []
|
||||
thinkingTexts = []
|
||||
units.push(
|
||||
<div key={`rows-${units.length}`} className="my-1.5">
|
||||
<ToolChips calls={group.calls} thinking={group.thinkingTexts} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const blocks = readBlocks(message.raw)
|
||||
return (
|
||||
<div>
|
||||
{blocks.map((block, i) => {
|
||||
if (block.type === 'thinking' && typeof block.text === 'string') {
|
||||
return <ThinkingBlock key={`t${i}`} text={block.text} streaming={false} />
|
||||
}
|
||||
if (block.type === 'text' && block.text) {
|
||||
return (
|
||||
<div key={`x${i}`} className="text-[14.5px] leading-7 text-zinc-200">
|
||||
<Markdown text={block.text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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)
|
||||
for (const message of messages) {
|
||||
if (message.stream) {
|
||||
stream = message.stream
|
||||
continue
|
||||
}
|
||||
for (const block of readBlocks(message.raw)) {
|
||||
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
|
||||
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
|
||||
thinkingTexts.push(thinkingText)
|
||||
thinking += 1
|
||||
continue
|
||||
}
|
||||
if (block.type === 'text' && block.text) {
|
||||
flushRows()
|
||||
const textNode = (
|
||||
<div key={`x-${units.length}`} className="my-3">
|
||||
<Markdown text={block.text} />
|
||||
</div>
|
||||
)
|
||||
units.push(textNode)
|
||||
textUnits.push(textNode)
|
||||
continue
|
||||
}
|
||||
if (block.type === 'toolCall') {
|
||||
const name = block.name ?? 'tool'
|
||||
const tool = block.id ? tools[block.id] : undefined
|
||||
if (!tool) {
|
||||
const pending = pendingApprovals.find((a) => a.toolName === name)
|
||||
if (pending) {
|
||||
return <ToolPendingApproval key={`c${i}`} toolName={block.name ?? 'tool'} args={pending.args} />
|
||||
callsAndThinking.push({ id: block.id, name, pendingApproval: { args: pending.args } })
|
||||
calls += 1
|
||||
continue
|
||||
}
|
||||
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'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
const dup = labelCount.get(name) ?? 0
|
||||
labelCount.set(name, dup + 1)
|
||||
callsAndThinking.push({ id: block.id, name, tool })
|
||||
calls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
flushRows()
|
||||
|
||||
const hasProcess = calls > 0 || thinking > 0
|
||||
|
||||
// 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => !streaming && hasProcess)
|
||||
const prevStreaming = useRef(streaming)
|
||||
useEffect(() => {
|
||||
if (prevStreaming.current && !streaming) setCollapsed(true)
|
||||
prevStreaming.current = streaming
|
||||
}, [streaming])
|
||||
|
||||
const workedFor = useWorkingSeconds(streaming)
|
||||
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
|
||||
const streamRows = stream ? thinkingRows(stream.thinking) : []
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-full">
|
||||
{hasProcess && (
|
||||
<div className="border-b border-line pb-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="-mx-1.5 flex h-7 w-fit items-center gap-1.5 rounded-control px-1.5 text-[12.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-transform"
|
||||
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
<span className="tabular-nums">{headerLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开态:过程行(缩进)+ 正文按时间交错;收起态:只保留正文(不缩进) */}
|
||||
{!collapsed || !hasProcess ? units : textUnits}
|
||||
|
||||
{stream && (
|
||||
<div>
|
||||
{streamRows.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<Thinking rows={streamRows} streaming={streaming} />
|
||||
</div>
|
||||
)}
|
||||
{stream.text ? (
|
||||
<StreamingText streaming={streaming} text={stream.text}>
|
||||
<Markdown text={stream.text} />
|
||||
</StreamingText>
|
||||
) : streamRows.length === 0 ? (
|
||||
<Loading label="思考中" variant="Dots" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -126,7 +213,7 @@ export function MessageItem({
|
||||
tools: Record<string, ToolState>
|
||||
streaming: boolean
|
||||
pendingApprovals?: ApprovalRequest[]
|
||||
}): JSX.Element | null {
|
||||
}): ReactNode | null {
|
||||
const ref = useEntrance<HTMLDivElement>()
|
||||
|
||||
if (message.role === 'user') {
|
||||
@@ -134,7 +221,7 @@ export function MessageItem({
|
||||
const text = blocks.map((b) => b.text ?? '').join('') || ''
|
||||
return (
|
||||
<div ref={ref} className="flex justify-end">
|
||||
<div className="max-w-[78%] whitespace-pre-wrap rounded-2xl rounded-br-md bg-emerald-950/50 px-4 py-2.5 text-[14.5px] leading-7 text-emerald-50 ring-1 ring-emerald-900/60">
|
||||
<div className="max-w-[85%] rounded-[10px] bg-inset px-4 py-3 text-base whitespace-pre-wrap leading-normal text-ink">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,8 +230,8 @@ export function MessageItem({
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
return (
|
||||
<div ref={ref} className="max-w-full">
|
||||
<AssistantBody message={message} tools={tools} streaming={streaming} pendingApprovals={pendingApprovals} />
|
||||
<div ref={ref} className="min-w-0 max-w-full">
|
||||
<AssistantTurn messages={[message]} tools={tools} streaming={streaming} pendingApprovals={pendingApprovals} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { LuMessageSquare, LuPlus, LuRefreshCw, LuSettings } from 'react-icons/lu'
|
||||
import { GlideMenu } from '@/components/GlideMenu'
|
||||
import type { SessionMeta } from '@shared/types'
|
||||
|
||||
/** 命令搜索面板(beautifului.dev Search)。
|
||||
* 输入即时过滤;↑↓ 移动、Enter 执行、Esc 关闭;无结果显示空态。 */
|
||||
|
||||
export interface SearchAction {
|
||||
id: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
run: () => void
|
||||
}
|
||||
|
||||
function defaultActions(onNewSession: () => void, onRefreshSessions: () => void, onOpenSettings: () => void): SearchAction[] {
|
||||
return [
|
||||
{ id: 'new', label: '新会话', icon: <LuPlus size={14} strokeWidth={2} />, run: onNewSession },
|
||||
{ id: 'refresh', label: '刷新会话列表', icon: <LuRefreshCw size={14} strokeWidth={2} />, run: onRefreshSessions },
|
||||
{ id: 'settings', label: '打开设置', icon: <LuSettings size={14} strokeWidth={2} />, run: onOpenSettings }
|
||||
]
|
||||
}
|
||||
|
||||
function sessionLabel(s: SessionMeta): string {
|
||||
if (s.name?.trim()) return s.name.trim()
|
||||
const first = s.firstMessage?.trim()
|
||||
return first ? (first.length > 48 ? `${first.slice(0, 48)}…` : first) : '(空会话)'
|
||||
}
|
||||
|
||||
export function Search({
|
||||
open,
|
||||
onClose,
|
||||
sessions,
|
||||
onNewSession,
|
||||
onRefreshSessions,
|
||||
onOpenSettings,
|
||||
onOpenSession
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
sessions: SessionMeta[]
|
||||
onNewSession: () => void
|
||||
onRefreshSessions: () => void
|
||||
onOpenSettings: () => void
|
||||
onOpenSession: (path: string) => void
|
||||
}): ReactNode {
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const actions = useMemo(
|
||||
() => defaultActions(onNewSession, onRefreshSessions, onOpenSettings),
|
||||
[onNewSession, onRefreshSessions, onOpenSettings]
|
||||
)
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const filteredActions = q ? actions.filter((a) => a.label.toLowerCase().includes(q)) : actions
|
||||
const filteredSessions = q
|
||||
? sessions.filter((s) => sessionLabel(s).toLowerCase().includes(q) || s.firstMessage?.toLowerCase().includes(q))
|
||||
: sessions
|
||||
|
||||
const entries: ({ kind: 'action'; item: SearchAction } | { kind: 'session'; item: SessionMeta })[] = [
|
||||
...filteredActions.map((item) => ({ kind: 'action' as const, item })),
|
||||
...filteredSessions.map((item) => ({ kind: 'session' as const, item }))
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('')
|
||||
setActive(0)
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => setActive(0), [query])
|
||||
|
||||
// 键盘导航
|
||||
const onKey = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.min(i + 1, entries.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const entry = entries[active]
|
||||
if (!entry) return
|
||||
if (entry.kind === 'action') entry.item.run()
|
||||
else onOpenSession(entry.item.path)
|
||||
onClose()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 活动行滚动进视野
|
||||
useEffect(() => {
|
||||
const row = listRef.current?.querySelector(`[data-idx="${active}"]`)
|
||||
row?.scrollIntoView({ block: 'nearest' })
|
||||
}, [active])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-black/25 pt-[12vh]" style={{ animation: 'fade-in 150ms ease-out both' }} onMouseDown={onClose}>
|
||||
<div className="flex justify-center px-6" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="w-full max-w-[440px] self-start overflow-hidden rounded-card bg-surface shadow-overlay"
|
||||
style={{ animation: 'pop-in 200ms cubic-bezier(0.23,1,0.32,1) both' }}
|
||||
>
|
||||
{/* 输入行 */}
|
||||
<div className="flex h-10 items-center gap-2 border-b border-line px-3 transition-colors duration-100 hover:bg-hover">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="搜索会话或命令…"
|
||||
aria-label="搜索会话或命令"
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
<kbd className="rounded-[5px] bg-inset px-1.5 py-0.5 text-[10.5px] text-ink-3">Esc</kbd>
|
||||
</div>
|
||||
|
||||
{/* 结果列表(glide hover + 键盘活动态) */}
|
||||
<div className="p-1" ref={listRef}>
|
||||
{entries.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-1.5 px-4 py-8 text-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="1.8" strokeLinecap="round">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
<p className="text-[12.5px] text-ink-2">没有匹配的结果</p>
|
||||
<p className="font-mono text-[11px] text-ink-3">试试其他关键词</p>
|
||||
</div>
|
||||
) : (
|
||||
<GlideMenu>
|
||||
{entries.map((entry, i) => {
|
||||
const isActive = i === active
|
||||
const label = entry.kind === 'action' ? entry.item.label : sessionLabel(entry.item)
|
||||
const icon =
|
||||
entry.kind === 'action' ? (
|
||||
entry.item.icon
|
||||
) : (
|
||||
<LuMessageSquare size={14} strokeWidth={2} className="text-ink-3" />
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={entry.kind === 'action' ? entry.item.id : entry.item.id}
|
||||
data-menu-row
|
||||
data-idx={i}
|
||||
type="button"
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => {
|
||||
if (entry.kind === 'action') entry.item.run()
|
||||
else onOpenSession(entry.item.path)
|
||||
onClose()
|
||||
}}
|
||||
className={`relative z-10 flex h-8 w-full items-center gap-2 rounded-[6px] px-2 text-left text-[13px] text-ink transition-colors ${isActive ? 'bg-hover' : ''}`}
|
||||
style={{ animation: 'fade-in 200ms ease-out both' }}
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">{icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{entry.kind === 'session' && isActive && (
|
||||
<span className="shrink-0 text-[10.5px] text-ink-3">Enter 打开</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</GlideMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="flex items-center gap-3 border-t border-line px-3 py-1.5 text-[10.5px] text-ink-3">
|
||||
<span>↑↓ 选择</span>
|
||||
<span>Enter 执行</span>
|
||||
<span className="ml-auto">{sessions.length} 个会话</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
|
||||
import { LuCircleHelp, LuScissors, LuSmile, LuSpellCheck, LuWandSparkles } from 'react-icons/lu'
|
||||
|
||||
/** 选区操作条(beautifului.dev Selection Actions)。
|
||||
* 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
|
||||
* 「描述修改」输入框 + 解释/改进,更多按钮展开 精简/语气/语法;
|
||||
* 输入内容后出现发送钮。预设动作与自定义指令都经 onApply 交给上层。 */
|
||||
|
||||
const PRESET_MAIN: { label: string; icon: ReactNode }[] = [
|
||||
{ label: '解释', icon: <LuCircleHelp size={14} strokeWidth={1.8} /> },
|
||||
{ label: '改进', icon: <LuWandSparkles size={14} strokeWidth={1.8} /> }
|
||||
]
|
||||
|
||||
const PRESET_MORE: { label: string; icon: ReactNode }[] = [
|
||||
{ label: '精简', icon: <LuScissors size={14} strokeWidth={1.8} /> },
|
||||
{ label: '语气', icon: <LuSmile size={14} strokeWidth={1.8} /> },
|
||||
{ label: '语法', icon: <LuSpellCheck size={14} strokeWidth={1.8} /> }
|
||||
]
|
||||
|
||||
const pillButton =
|
||||
'inline-flex items-center justify-center select-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none text-ink hover:bg-hover h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1 shrink-0'
|
||||
|
||||
export function SelectionActions({
|
||||
containerRef,
|
||||
onApply
|
||||
}: {
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
onApply: (instruction: string, selection: string) => void
|
||||
}): ReactNode {
|
||||
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selection, setSelection] = useState('')
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const pillRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const dismiss = (): void => {
|
||||
setAnchor(null)
|
||||
setSelection('')
|
||||
setInstruction('')
|
||||
setMoreOpen(false)
|
||||
}
|
||||
|
||||
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
|
||||
useEffect(() => {
|
||||
const read = (): void => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const sel = window.getSelection()
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
setAnchor(null)
|
||||
setSelection('')
|
||||
return
|
||||
}
|
||||
const range = sel.getRangeAt(0)
|
||||
const text = sel.toString().replace(/\s+/g, ' ').trim()
|
||||
if (!text || text.length < 2 || !container.contains(range.commonAncestorContainer)) {
|
||||
setAnchor(null)
|
||||
setSelection('')
|
||||
return
|
||||
}
|
||||
const rect = range.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
if (rect.width < 4) return
|
||||
const x = Math.min(Math.max(rect.left + rect.width / 2 - containerRect.left, 130), Math.max(containerRect.width - 130, 130))
|
||||
const y = Math.max(rect.top - containerRect.top - 46, 4)
|
||||
setAnchor({ x, y })
|
||||
setSelection(text)
|
||||
}
|
||||
const onUp = (): void => {
|
||||
setTimeout(read, 0)
|
||||
}
|
||||
document.addEventListener('mouseup', onUp)
|
||||
document.addEventListener('keyup', onUp)
|
||||
const onScroll = (event: Event): void => {
|
||||
if (containerRef.current && (event.target === containerRef.current || containerRef.current.contains(event.target as Node))) {
|
||||
setAnchor(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('scroll', onScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
document.removeEventListener('keyup', onUp)
|
||||
document.removeEventListener('scroll', onScroll, true)
|
||||
}
|
||||
}, [containerRef])
|
||||
|
||||
// Esc 收起
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape' && anchor) dismiss()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [anchor])
|
||||
|
||||
const apply = (label: string): void => {
|
||||
if (!selection) return
|
||||
onApply(label, selection)
|
||||
dismiss()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
const submitInstruction = (): void => {
|
||||
const text = instruction.trim()
|
||||
if (!text || !selection) return
|
||||
onApply(`${text}(针对以下选中内容)`, selection)
|
||||
dismiss()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
const visible = anchor !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pillRef}
|
||||
className="absolute top-0 left-0 z-20"
|
||||
style={{
|
||||
transform: `translate3d(${anchor?.x ?? 0}px, ${anchor?.y ?? 0}px, 0) translateX(-50%)`,
|
||||
transition: 'transform 320ms cubic-bezier(0.77,0,0.175,1), opacity 180ms ease-out',
|
||||
opacity: visible ? 1 : 0,
|
||||
pointerEvents: visible ? undefined : 'none'
|
||||
}}
|
||||
>
|
||||
<div className="flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 font-sans font-normal text-ink shadow-overlay">
|
||||
{/* 描述修改输入框(常显,站点默认态) */}
|
||||
<div className="flex w-fit shrink-0 items-center justify-center gap-0.5 overflow-hidden" style={{ maxWidth: 152 }}>
|
||||
<form
|
||||
className="flex h-7 shrink-0 items-center"
|
||||
style={{ width: 152 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submitInstruction()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
placeholder="描述修改…"
|
||||
aria-label="描述修改"
|
||||
className="h-7 w-full bg-transparent pr-2.5 pl-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<span className="mx-1 h-4 w-px shrink-0 bg-line-strong" />
|
||||
|
||||
{/* 预设动作 + 更多 */}
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-hidden">
|
||||
{PRESET_MAIN.map((action) => (
|
||||
<button key={action.label} type="button" className={pillButton} onClick={() => apply(action.label)}>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity] duration-[400ms]"
|
||||
style={{ maxWidth: moreOpen ? 224 : 0, opacity: moreOpen ? 1 : 0, transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}
|
||||
>
|
||||
{PRESET_MORE.map((action) => (
|
||||
<button key={action.label} type="button" className={pillButton} onClick={() => apply(action.label)}>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="更多操作"
|
||||
aria-expanded={moreOpen}
|
||||
onClick={() => setMoreOpen((v) => !v)}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full text-ink transition-[background-color,transform] duration-200 hover:bg-hover active:scale-[0.96]"
|
||||
>
|
||||
<span className="flex transition-transform duration-[400ms]" style={{ transform: moreOpen ? 'rotate(180deg)' : 'rotate(0deg)', transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}>
|
||||
<svg width="14" height="14" strokeWidth="1.8" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M9 6L15 12L9 18" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 发送钮:输入内容后出现 */}
|
||||
<div
|
||||
className="flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-[400ms]"
|
||||
style={{ maxWidth: instruction.trim() ? 28 : 0, opacity: instruction.trim() ? 1 : 0, transform: instruction.trim() ? 'scale(1)' : 'scale(0.88)', transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="发送修改指令"
|
||||
onClick={submitInstruction}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-ink text-surface transition-[opacity,transform] duration-200 active:scale-[0.94]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" strokeWidth="2.4" fill="none" aria-hidden>
|
||||
<path d="M12 21L12 3M12 3L20.5 11.5M12 3L3.5 11.5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,88 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { Check, Cpu, KeyRound, LogOut, Search, Sparkles, X } from 'lucide-react'
|
||||
import {
|
||||
LuBot,
|
||||
LuBrain,
|
||||
LuCheck,
|
||||
LuCpu,
|
||||
LuKeyRound,
|
||||
LuLogOut,
|
||||
LuPuzzle,
|
||||
LuSearch,
|
||||
LuServer,
|
||||
LuSettings2,
|
||||
LuSparkles,
|
||||
LuTerminal,
|
||||
LuWebhook,
|
||||
LuX
|
||||
} from 'react-icons/lu'
|
||||
import type { IconType } from 'react-icons'
|
||||
import type { ModelCatalog, ProviderCatalogEntry, SessionState } from '@shared/types'
|
||||
|
||||
type Tab = 'model' | 'prompt'
|
||||
type Tab =
|
||||
| 'general'
|
||||
| 'model'
|
||||
| 'memory'
|
||||
| 'subagent'
|
||||
| 'plugins'
|
||||
| 'mcp'
|
||||
| 'skills'
|
||||
| 'commands'
|
||||
| 'hooks'
|
||||
|
||||
/** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */
|
||||
const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: IconType }> }> = [
|
||||
{
|
||||
group: '基础设置',
|
||||
items: [
|
||||
{ id: 'general', label: '常规', icon: LuSettings2 },
|
||||
{ id: 'model', label: '模型设置', icon: LuCpu }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Agent 能力',
|
||||
items: [
|
||||
{ id: 'memory', label: '记忆', icon: LuBrain },
|
||||
{ id: 'subagent', label: '子智能体', icon: LuBot },
|
||||
{ id: 'plugins', label: '插件', icon: LuPuzzle },
|
||||
{ id: 'mcp', label: 'MCP 服务器', icon: LuServer },
|
||||
{ id: 'skills', label: '技能', icon: LuSparkles },
|
||||
{ id: 'commands', label: '命令', icon: LuTerminal },
|
||||
{ id: 'hooks', label: '钩子', icon: LuWebhook }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const PLACEHOLDER: Record<string, string> = {
|
||||
memory: '让 Agent 在会话之间记住偏好与事实,越用越懂你的店铺。',
|
||||
subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
|
||||
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
|
||||
mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
|
||||
skills: '管理可复用的技能包(SOP),让 Agent 按流程执行任务。',
|
||||
commands: '自定义斜杠命令,把常用操作变成一键指令。',
|
||||
hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
|
||||
}
|
||||
|
||||
const TAB_META: Record<Tab, { title: string; desc: string }> = {
|
||||
general: { title: '常规', desc: '管理 Agent 的系统提示词与基础行为。' },
|
||||
model: { title: '模型设置', desc: '管理模型供应商与密钥,配置后可在聊天时选择使用。' },
|
||||
memory: { title: '记忆', desc: PLACEHOLDER.memory },
|
||||
subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
|
||||
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
|
||||
mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp },
|
||||
skills: { title: '技能', desc: PLACEHOLDER.skills },
|
||||
commands: { title: '命令', desc: PLACEHOLDER.commands },
|
||||
hooks: { title: '钩子', desc: PLACEHOLDER.hooks }
|
||||
}
|
||||
|
||||
function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element {
|
||||
if (provider.hasApiKey) {
|
||||
return <span className="rounded-full bg-emerald-950/70 px-1.5 py-0.5 text-[10px] text-emerald-300">API Key</span>
|
||||
return <span className="rounded-control bg-green-tint px-1.5 py-0.5 text-[10px] text-green">API Key</span>
|
||||
}
|
||||
if (provider.isOAuth) {
|
||||
return <span className="rounded-full bg-sky-950/70 px-1.5 py-0.5 text-[10px] text-sky-300">OAuth</span>
|
||||
return <span className="rounded-control bg-accent-tint px-1.5 py-0.5 text-[10px] text-accent-ink">OAuth</span>
|
||||
}
|
||||
return <span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">未配置</span>
|
||||
return <span className="rounded-control bg-zinc-100 px-1.5 py-0.5 text-[10px] text-zinc-500">未配置</span>
|
||||
}
|
||||
|
||||
function formatContext(n: number): string {
|
||||
@@ -21,6 +91,18 @@ function formatContext(n: number): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function ComingSoon({ icon: Icon, label }: { icon: IconType; label: string }): ReactNode {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-card border border-dashed border-zinc-200 py-16 text-center">
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-zinc-100 text-zinc-400">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<div className="text-[13px] font-medium text-zinc-600">{label}</div>
|
||||
<div className="text-[12px] text-zinc-400">该能力正在建设中,敬请期待</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
open,
|
||||
sessionState,
|
||||
@@ -102,224 +184,243 @@ export function SettingsModal({
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const meta = TAB_META[tab]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/25 backdrop-blur-[2px]" onClick={onClose}>
|
||||
<div
|
||||
className="flex h-[600px] w-[720px] max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 shadow-2xl"
|
||||
className="flex h-[640px] w-[920px] max-w-[94vw] overflow-hidden rounded-card border border-zinc-200 bg-white shadow-overlay"
|
||||
style={{ animation: 'pop-in 200ms cubic-bezier(0.23,1,0.32,1) both' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-1 border-b border-zinc-800/80 px-5 py-3">
|
||||
<div className="mr-3 text-[14px] font-medium text-zinc-100">设置</div>
|
||||
{(
|
||||
[
|
||||
['model', '模型与密钥', Cpu],
|
||||
['prompt', '系统提示词', Sparkles]
|
||||
] as const
|
||||
).map(([id, label, Icon]) => (
|
||||
{/* 左侧:分组导航 */}
|
||||
<nav className="w-[196px] shrink-0 overflow-y-auto border-r border-zinc-200 bg-zinc-50/70 p-2.5">
|
||||
{NAV.map((group) => (
|
||||
<div key={group.group} className="mb-2">
|
||||
<div className="px-2 pb-1 pt-2 text-[11px] font-medium text-zinc-400">{group.group}</div>
|
||||
{group.items.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex h-8 w-full items-center gap-2 rounded-control px-2 text-left text-[12.5px] transition-colors ${
|
||||
tab === id
|
||||
? 'bg-zinc-200/80 font-medium text-zinc-900'
|
||||
: 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} className={tab === id ? 'text-zinc-700' : 'text-zinc-400'} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 右侧:内容页 */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-start gap-3 px-6 pb-3 pt-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[16px] font-semibold text-zinc-900">{meta.title}</div>
|
||||
<div className="mt-0.5 text-[12px] leading-5 text-zinc-500">{meta.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12.5px] transition-colors ${
|
||||
tab === id ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-300'
|
||||
onClick={onClose}
|
||||
className="rounded-control p-1.5 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<LuX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-4">
|
||||
{tab === 'general' && (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => {
|
||||
setPrompt(e.target.value)
|
||||
setPromptDirty(true)
|
||||
}}
|
||||
spellCheck={false}
|
||||
placeholder={'例如:你是店铺运营助手,负责热点词分析、商品文案生成与上架流程编排…'}
|
||||
className="min-h-0 flex-1 resize-none rounded-card border border-zinc-300 bg-white p-3.5 font-mono text-[12.5px] leading-6 text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !promptDirty}
|
||||
onClick={() => {
|
||||
if (streaming) {
|
||||
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再保存(保存会重建运行时)' })
|
||||
return
|
||||
}
|
||||
run('系统提示词已保存,运行时已重建', () => window.api.setSystemPrompt(prompt), () =>
|
||||
setPromptDirty(false)
|
||||
)
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-control bg-zinc-900 px-3.5 py-1.5 text-[12.5px] text-white transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<LuCheck size={13} />
|
||||
保存并应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'model' && (
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
<div className="relative flex-1">
|
||||
<LuSearch size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索模型 / 服务商…"
|
||||
className="w-full rounded-control border border-zinc-300 bg-white py-1.5 pl-8 pr-3 text-[12.5px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
{loading && <span className="text-[11px] text-zinc-400">加载中…</span>}
|
||||
<label className="flex shrink-0 cursor-pointer select-none items-center gap-1.5 text-[11.5px] text-zinc-500 hover:text-zinc-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyConfigured}
|
||||
onChange={(e) => setOnlyConfigured(e.target.checked)}
|
||||
className="h-3 w-3 accent-accent"
|
||||
/>
|
||||
仅显示已配置
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{filtered.map((provider) => (
|
||||
<div key={provider.id} className="rounded-card border border-zinc-200 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[12.5px] font-medium text-zinc-800">{provider.name}</span>
|
||||
<ProviderBadge provider={provider} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKeyProvider(provider.id)
|
||||
setKeyValue('')
|
||||
}}
|
||||
className="ml-auto flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<LuKeyRound size={11} />
|
||||
{provider.hasApiKey ? '更换密钥' : '设置密钥'}
|
||||
</button>
|
||||
{(provider.hasApiKey || provider.isOAuth) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('已移除凭据', () => window.api.logoutProvider(provider.id), refreshCatalog)
|
||||
}
|
||||
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<LuLogOut size={11} />
|
||||
移除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{keyProvider === provider.id && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-control border border-zinc-200 bg-zinc-50 p-2">
|
||||
<input
|
||||
type="password"
|
||||
value={keyValue}
|
||||
onChange={(e) => setKeyValue(e.target.value)}
|
||||
placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
|
||||
className="flex-1 rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 font-mono text-[12px] text-zinc-900 outline-none focus:border-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !keyValue.trim()}
|
||||
onClick={() =>
|
||||
run('密钥已保存', () => window.api.setApiKey(provider.id, keyValue.trim()), () => {
|
||||
setKeyProvider(null)
|
||||
setKeyValue('')
|
||||
refreshCatalog()
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-1 rounded-control bg-zinc-900 px-2.5 py-1.5 text-[12px] text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
|
||||
>
|
||||
<LuCheck size={12} />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKeyProvider(null)
|
||||
setKeyValue('')
|
||||
}}
|
||||
className="rounded-control px-2 py-1.5 text-[12px] text-zinc-500 hover:bg-zinc-100"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{provider.models.map((m) => {
|
||||
const selected = provider.id === currentProvider && m.id === currentModel
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => run(`已切换到 ${m.name}`, () => window.api.setModel(provider.id, m.id))}
|
||||
className={`flex items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors ${
|
||||
selected ? 'bg-accent-tint ring-1 ring-accent' : 'hover:bg-zinc-100'
|
||||
}`}
|
||||
>
|
||||
{selected ? (
|
||||
<LuCheck size={13} className="shrink-0 text-accent-ink" />
|
||||
) : (
|
||||
<span className="h-[13px] w-[13px] shrink-0 rounded-full border border-zinc-300" />
|
||||
)}
|
||||
<span className={`text-[12.5px] ${selected ? 'font-medium text-zinc-900' : 'text-zinc-700'}`}>
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10.5px] text-zinc-400">{m.id}</span>
|
||||
<span className="ml-auto flex items-center gap-2 text-[10.5px] text-zinc-400">
|
||||
{m.reasoning && <span>推理</span>}
|
||||
<span>{formatContext(m.contextWindow)} ctx</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{provider.models.length === 0 && (
|
||||
<div className="px-2.5 py-2 text-[11.5px] text-zinc-400">配置密钥后显示可用模型</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="py-10 text-center text-[12.5px] text-zinc-400">没有匹配的模型</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab !== 'general' && tab !== 'model' && (
|
||||
<ComingSoon icon={NAV[1].items.find((i) => i.id === tab)?.icon ?? LuPuzzle} label={meta.title} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 通知条 */}
|
||||
{notice && (
|
||||
<div
|
||||
className={`border-t px-6 py-2 text-[12px] ${
|
||||
notice.kind === 'ok'
|
||||
? 'border-green bg-green-tint text-green'
|
||||
: 'border-red-200 bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto rounded-lg p-1.5 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
{notice.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
{tab === 'model' ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 px-5 pb-2 pt-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索模型 / 服务商…"
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-900/60 py-1.5 pl-8 pr-3 text-[12.5px] text-zinc-200 outline-none placeholder:text-zinc-600 focus:border-zinc-700"
|
||||
/>
|
||||
</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) => (
|
||||
<div key={provider.id}>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="text-[12px] font-medium text-zinc-300">{provider.name}</span>
|
||||
<ProviderBadge provider={provider} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKeyProvider(provider.id)
|
||||
setKeyValue('')
|
||||
}}
|
||||
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
<KeyRound size={11} />
|
||||
{provider.hasApiKey ? '更换密钥' : '设置密钥'}
|
||||
</button>
|
||||
{(provider.hasApiKey || provider.isOAuth) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('已移除凭据', () => window.api.logoutProvider(provider.id), refreshCatalog)
|
||||
}
|
||||
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-950/60 hover:text-red-300"
|
||||
>
|
||||
<LogOut size={11} />
|
||||
移除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{keyProvider === provider.id && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-2">
|
||||
<input
|
||||
type="password"
|
||||
value={keyValue}
|
||||
onChange={(e) => setKeyValue(e.target.value)}
|
||||
placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
|
||||
className="flex-1 rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-1.5 font-mono text-[12px] text-zinc-200 outline-none focus:border-emerald-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !keyValue.trim()}
|
||||
onClick={() =>
|
||||
run('密钥已保存', () => window.api.setApiKey(provider.id, keyValue.trim()), () => {
|
||||
setKeyProvider(null)
|
||||
setKeyValue('')
|
||||
refreshCatalog()
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-1 rounded-md bg-emerald-600 px-2.5 py-1.5 text-[12px] text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
<Check size={12} />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKeyProvider(null)
|
||||
setKeyValue('')
|
||||
}}
|
||||
className="rounded-md px-2 py-1.5 text-[12px] text-zinc-500 hover:bg-zinc-800"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{provider.models.map((m) => {
|
||||
const selected = provider.id === currentProvider && m.id === currentModel
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => run(`已切换到 ${m.name}`, () => window.api.setModel(provider.id, m.id))}
|
||||
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors ${
|
||||
selected ? 'bg-emerald-950/50 ring-1 ring-emerald-900/60' : 'hover:bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
{selected ? (
|
||||
<Check size={13} className="shrink-0 text-emerald-400" />
|
||||
) : (
|
||||
<span className="h-[13px] w-[13px] shrink-0 rounded-full border border-zinc-700" />
|
||||
)}
|
||||
<span className={`text-[12.5px] ${selected ? 'text-emerald-100' : 'text-zinc-300'}`}>
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10.5px] text-zinc-600">{m.id}</span>
|
||||
<span className="ml-auto flex items-center gap-2 text-[10.5px] text-zinc-600">
|
||||
{m.reasoning && <span>推理</span>}
|
||||
<span>{formatContext(m.contextWindow)} ctx</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{provider.models.length === 0 && (
|
||||
<div className="px-2.5 py-2 text-[11.5px] text-zinc-600">配置密钥后显示可用模型</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="py-10 text-center text-[12.5px] text-zinc-600">没有匹配的模型</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col px-5 py-4">
|
||||
<div className="mb-2 text-[12px] leading-5 text-zinc-500">
|
||||
覆盖 pi 默认系统提示词。留空使用默认(面向编码的提示词)。保存后运行时重建,当前最近会话的历史会保留。
|
||||
</div>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => {
|
||||
setPrompt(e.target.value)
|
||||
setPromptDirty(true)
|
||||
}}
|
||||
spellCheck={false}
|
||||
placeholder={'例如:你是店铺运营助手,负责热点词分析、商品文案生成与上架流程编排…'}
|
||||
className="min-h-0 flex-1 resize-none rounded-xl border border-zinc-800 bg-zinc-900/60 p-3.5 font-mono text-[12.5px] leading-6 text-zinc-200 outline-none placeholder:text-zinc-700 focus:border-zinc-700"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !promptDirty}
|
||||
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} />
|
||||
保存并应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通知条 */}
|
||||
{notice && (
|
||||
<div
|
||||
className={`border-t px-5 py-2 text-[12px] ${
|
||||
notice.kind === 'ok'
|
||||
? 'border-emerald-900/50 bg-emerald-950/40 text-emerald-300'
|
||||
: 'border-red-900/50 bg-red-950/40 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{notice.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { Check, Cpu, MessageSquare, Pencil, RefreshCw, Settings, Store, Trash2, X } from 'lucide-react'
|
||||
import type { SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
interface VersionInfo {
|
||||
app: string
|
||||
electron: string
|
||||
node: string
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
if (cost >= 1) return `$${cost.toFixed(2)}`
|
||||
if (cost > 0) return `$${cost.toFixed(4)}`
|
||||
return '$0'
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatModified(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
if (sameDay) {
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
const yesterday = new Date(now.getTime() - 86400000)
|
||||
if (d.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
function sessionTitle(s: SessionMeta): string {
|
||||
if (s.name?.trim()) return s.name.trim()
|
||||
const first = s.firstMessage?.trim()
|
||||
if (first) return first.length > 36 ? `${first.slice(0, 36)}…` : first
|
||||
return '(空会话)'
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
active,
|
||||
onOpen,
|
||||
onRename,
|
||||
onDelete
|
||||
}: {
|
||||
session: SessionMeta
|
||||
active: boolean
|
||||
onOpen: () => void
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 transition-colors ${
|
||||
active ? 'bg-zinc-800/80' : 'hover:bg-zinc-800/40'
|
||||
}`}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{active && <span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-emerald-400" />}
|
||||
<MessageSquare size={13} className={`shrink-0 ${active ? 'text-emerald-400' : 'text-zinc-600'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate text-[12.5px] ${active ? 'text-zinc-100' : 'text-zinc-400'}`}>{sessionTitle(session)}</div>
|
||||
<div className="text-[10.5px] text-zinc-600">
|
||||
{formatModified(session.modified)} · {session.messageCount} 条
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
{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="删除(移入回收站)"
|
||||
className="rounded p-1 text-zinc-500 transition-colors hover:bg-red-950/60 hover:text-red-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
sessionState,
|
||||
stats,
|
||||
connected,
|
||||
sessions,
|
||||
onNewSession,
|
||||
onOpenSession,
|
||||
onRenameSession,
|
||||
onDeleteSession,
|
||||
onOpenSettings,
|
||||
onRefreshSessions
|
||||
}: {
|
||||
sessionState: SessionState | null
|
||||
stats: SessionStatsInfo | null
|
||||
connected: boolean
|
||||
sessions: SessionMeta[]
|
||||
onNewSession: () => void
|
||||
onOpenSession: (path: string) => void
|
||||
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)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const entranceRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void window.api.versions().then(setVersions).catch(() => setVersions(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = entranceRef.current
|
||||
if (!el) return
|
||||
let tween: { kill(): void } | null = null
|
||||
let cancelled = false
|
||||
void import('gsap').then((gsapModule) => {
|
||||
if (cancelled) return
|
||||
tween = gsapModule.gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, x: -12 },
|
||||
{ opacity: 1, x: 0, duration: 0.35, ease: 'power2.out' }
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
tween?.kill()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) renameInputRef.current?.focus()
|
||||
}, [renaming])
|
||||
|
||||
const submitRename = (): void => {
|
||||
const name = renameValue.trim()
|
||||
if (name) onRenameSession(name)
|
||||
setRenaming(false)
|
||||
}
|
||||
|
||||
const activeSessionId = sessionState?.sessionId
|
||||
|
||||
return (
|
||||
<aside ref={entranceRef} className="relative flex w-[260px] shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-950">
|
||||
{/* 品牌 */}
|
||||
<div className="flex items-center gap-2.5 px-4 pb-4 pt-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-950/60 ring-1 ring-emerald-900/60">
|
||||
<Store size={16} className="text-emerald-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-medium text-zinc-100">店铺运营 Agent</div>
|
||||
<div className="text-[11px] text-zinc-600">powered by pi</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新会话 */}
|
||||
<div className="px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSession}
|
||||
className="flex w-full items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-[13px] text-zinc-300 transition-colors hover:border-emerald-900 hover:bg-emerald-950/30 hover:text-emerald-200"
|
||||
>
|
||||
<span className="text-[15px] leading-none text-zinc-500">+</span>
|
||||
新会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<div className="mt-4 flex min-h-0 flex-1 flex-col px-3">
|
||||
<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">
|
||||
暂无历史会话
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
active={s.id === activeSessionId}
|
||||
onOpen={() => onOpenSession(s.path)}
|
||||
onRename={() => {
|
||||
setRenameValue(sessionTitle(s))
|
||||
setRenaming(true)
|
||||
}}
|
||||
onDelete={() => setConfirmDelete(s.path)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
{renaming && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[220px] rounded-xl border border-zinc-800 bg-zinc-900 p-3 shadow-2xl">
|
||||
<div className="mb-2 text-[12.5px] font-medium text-zinc-200">重命名当前会话</div>
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
if (e.key === 'Escape') setRenaming(false)
|
||||
}}
|
||||
className="mb-2.5 w-full rounded-lg border border-zinc-700 bg-zinc-950 px-2.5 py-1.5 text-[12.5px] text-zinc-100 outline-none focus:border-emerald-700"
|
||||
placeholder="会话名称"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenaming(false)}
|
||||
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[12px] text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
<X size={12} />
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitRename}
|
||||
className="flex items-center gap-1 rounded-lg bg-emerald-600 px-2 py-1 text-[12px] text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
<Check size={12} />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
{confirmDelete && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[230px] rounded-xl border border-zinc-800 bg-zinc-900 p-3.5 shadow-2xl">
|
||||
<div className="mb-1 flex items-center gap-2 text-[12.5px] font-medium text-zinc-200">
|
||||
<Trash2 size={13} className="text-red-400" />
|
||||
删除这个会话?
|
||||
</div>
|
||||
<div className="mb-3 text-[11.5px] leading-5 text-zinc-500">会话文件将移入系统回收站,可随时恢复。</div>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="rounded-lg px-2 py-1 text-[12px] text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDeleteSession(confirmDelete)
|
||||
setConfirmDelete(null)
|
||||
}}
|
||||
className="rounded-lg bg-red-600 px-2 py-1 text-[12px] text-white transition-colors hover:bg-red-500"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部状态 */}
|
||||
<div className="border-t border-zinc-800/80 px-4 py-3.5">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
title={sessionState?.model ? `${sessionState.model.provider}/${sessionState.model.id}` : '未配置模型'}
|
||||
>
|
||||
<Cpu size={13} className="shrink-0 text-zinc-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] text-zinc-300">
|
||||
{sessionState?.model?.name ?? (connected ? '模型加载中…' : '未连接')}
|
||||
</div>
|
||||
<div className="text-[10.5px] text-zinc-600">{sessionState?.model ? `${sessionState.model.provider}` : '—'}</div>
|
||||
</div>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${connected ? 'bg-emerald-400' : 'bg-zinc-600'}`} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
title="设置(模型 / 密钥 / 系统提示词)"
|
||||
className="shrink-0 rounded-md p-1 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<Settings size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{stats && (
|
||||
<div className="mt-2.5 flex items-center justify-between text-[10.5px] text-zinc-600">
|
||||
<span>上下文 {stats.contextPercent !== undefined ? `${stats.contextPercent}%` : '—'}</span>
|
||||
<span>{formatTokens(stats.tokens.total)} tokens</span>
|
||||
<span>{formatCost(stats.cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{versions && (
|
||||
<div className="mt-2 text-[10px] text-zinc-700">
|
||||
v{versions.app} · Electron {versions.electron} · Node {versions.node}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuPencil,
|
||||
LuPlus,
|
||||
LuSearch,
|
||||
LuSettings,
|
||||
LuSquarePen,
|
||||
LuTrash2,
|
||||
LuX
|
||||
} from 'react-icons/lu'
|
||||
import { LuPanelLeft } from 'react-icons/lu'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
import { GlideMenu } from '@/components/GlideMenu'
|
||||
import type { SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
|
||||
|
||||
/** 可折叠工作区导航(beautifului.dev Sidebar Nav)。
|
||||
* 224px ↔ 56px 宽度过渡,文案随折叠淡出;导航行与会话行共用一个
|
||||
* 滑移高亮层(glide hover);会话区支持内联搜索即时过滤。 */
|
||||
|
||||
const EXPANDED_W = 224
|
||||
const COLLAPSED_W = 52
|
||||
const COLLAPSE_KEY = 'aso-sidebar-collapsed'
|
||||
|
||||
function sessionTitle(s: SessionMeta): string {
|
||||
if (s.name?.trim()) return s.name.trim()
|
||||
const first = s.firstMessage?.trim()
|
||||
if (first) return first.length > 36 ? `${first.slice(0, 36)}…` : first
|
||||
return '(空会话)'
|
||||
}
|
||||
|
||||
/* 官方 RailButton 形态:行宽由 .sidebar-row 固定(展开 208 / 收起 36),
|
||||
* 收起时图标恰好落在 52px 轨道正中。 */
|
||||
const navRowClass =
|
||||
'sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left transition-[width,background-color,color,transform] duration-150 active:scale-[0.98] [-webkit-app-region:no-drag]'
|
||||
|
||||
/** 行内操作浮层:portal 到 body,锚定在触发按钮下方,点外部关闭。
|
||||
* 形态与官方 WorkspaceMenu 一致(rounded-[14px] + pop-in + shadow-overlay)。 */
|
||||
function RowActionPortal({
|
||||
rect,
|
||||
onClose,
|
||||
children
|
||||
}: {
|
||||
rect: { top: number; left: number }
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}): ReactNode {
|
||||
const closeRef = useRef(onClose)
|
||||
closeRef.current = onClose
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent): void => {
|
||||
const target = event.target as Element
|
||||
if (!target.closest('[data-row-menu]')) closeRef.current()
|
||||
}
|
||||
document.addEventListener('pointerdown', close)
|
||||
return () => document.removeEventListener('pointerdown', close)
|
||||
}, [])
|
||||
|
||||
// 视口内收拢:贴近底部时向上展开,横向不出界
|
||||
const top = Math.min(rect.top + 6, window.innerHeight - 176)
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - 256))
|
||||
return createPortal(
|
||||
<div
|
||||
data-row-menu
|
||||
className="fixed z-50 w-60 rounded-[14px] bg-surface p-1.5 shadow-overlay"
|
||||
style={{
|
||||
top,
|
||||
left,
|
||||
animation: 'pop-in 180ms cubic-bezier(0.23,1,0.32,1) both',
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarNav({
|
||||
sessionState,
|
||||
sessions,
|
||||
onNewSession,
|
||||
onOpenSession,
|
||||
onRenameSession,
|
||||
onDeleteSession,
|
||||
onOpenSearch,
|
||||
onOpenSettings,
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
onOpenWorkspace
|
||||
}: {
|
||||
sessionState: SessionState | null
|
||||
sessions: SessionMeta[]
|
||||
onNewSession: () => void
|
||||
onOpenSession: (path: string) => void
|
||||
onRenameSession: (name: string) => void
|
||||
onDeleteSession: (path: string) => void
|
||||
onOpenSearch: () => void
|
||||
onOpenSettings: () => void
|
||||
workspaces: WorkspaceMeta[]
|
||||
activeWorkspace?: WorkspaceMeta
|
||||
onOpenWorkspace: (path: string) => void
|
||||
}): ReactNode {
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const [chatsOpen, setChatsOpen] = useState(true)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
/** 行内操作弹出层:锚定在触发按钮旁(官方 WorkspaceMenu 的浮层形态) */
|
||||
const [actionMenu, setActionMenu] = useState<{
|
||||
kind: 'rename' | 'delete'
|
||||
session: SessionMeta
|
||||
rect: { top: number; left: number }
|
||||
} | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [wsMenuOpen, setWsMenuOpen] = useState(false)
|
||||
const [expandedWs, setExpandedWs] = useState<Set<string>>(new Set())
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0')
|
||||
} catch {
|
||||
/* 忽略持久化失败 */
|
||||
}
|
||||
}, [collapsed])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) searchInputRef.current?.focus()
|
||||
}, [searchOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) setWsMenuOpen(false)
|
||||
}, [collapsed])
|
||||
|
||||
useEffect(() => {
|
||||
if (actionMenu?.kind === 'rename') renameInputRef.current?.focus()
|
||||
}, [actionMenu])
|
||||
|
||||
const submitRename = (): void => {
|
||||
const name = renameValue.trim()
|
||||
if (name) onRenameSession(name)
|
||||
setActionMenu(null)
|
||||
}
|
||||
|
||||
const activeSessionId = sessionState?.sessionId
|
||||
const q = query.trim().toLowerCase()
|
||||
const filtered = q
|
||||
? sessions.filter((s) => sessionTitle(s).toLowerCase().includes(q) || s.firstMessage?.toLowerCase().includes(q))
|
||||
: sessions
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-sidebar-collapsed={collapsed}
|
||||
aria-label="工作区导航"
|
||||
className="relative flex h-full shrink-0 overflow-hidden border-r border-line bg-canvas transition-[width]"
|
||||
style={{
|
||||
width: collapsed ? COLLAPSED_W : EXPANDED_W,
|
||||
transitionDuration: '280ms',
|
||||
transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
['--sidebar-copy-duration' as string]: '180ms',
|
||||
['--sidebar-copy-offset' as string]: '8px',
|
||||
['--sidebar-easing' as string]: 'cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 w-[224px] shrink-0 flex-col">
|
||||
{/* 顶部:工作区标识 + 折叠钮(拖拽区) */}
|
||||
<div className="relative mb-2.5 h-10 shrink-0 [-webkit-app-region:drag]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWsMenuOpen((v) => !v)}
|
||||
aria-expanded={wsMenuOpen}
|
||||
title="切换工作区"
|
||||
className="sidebar-workspace-control absolute left-2 top-1 flex h-8 w-[164px] items-center rounded-[8px] px-2 text-left transition-[background-color,transform] duration-100 hover:bg-hover-2 active:scale-[0.99] [-webkit-app-region:no-drag]"
|
||||
>
|
||||
<span className="sidebar-logo flex size-5 shrink-0 items-center justify-center rounded-[6px] bg-ink text-[11px] font-semibold leading-none text-canvas">
|
||||
{(activeWorkspace?.name ?? 'Default').charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium text-ink-2">
|
||||
{activeWorkspace?.name ?? 'Default'}
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1 flex shrink-0 text-ink-3">
|
||||
<LuChevronDown size={16} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 折叠 / 展开互斥钮 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠侧栏"
|
||||
aria-hidden={collapsed}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="sidebar-collapse-control absolute right-2 top-1 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink [-webkit-app-region:no-drag]"
|
||||
>
|
||||
<LuPanelLeft size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开侧栏"
|
||||
aria-hidden={!collapsed}
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="sidebar-expand-control absolute left-2 top-0.5 flex size-9 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink [-webkit-app-region:no-drag]"
|
||||
>
|
||||
<LuPanelLeft size={18} strokeWidth={2} className="rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 工作区菜单(点击头部弹出,对齐站点形态:当前✓ / 列表切换 / 新建 / 设置) */}
|
||||
{wsMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setWsMenuOpen(false)} />
|
||||
<div
|
||||
className="absolute left-2 top-11 z-40 w-[208px] overflow-hidden rounded-card bg-surface p-1 shadow-overlay"
|
||||
style={{ animation: 'pop-in 200ms cubic-bezier(0.23,1,0.32,1) both' }}
|
||||
>
|
||||
{
|
||||
<>
|
||||
{/* 工作区列表:当前项打 ✓;行尾箭头展开目录详情;整行点击即切换 */}
|
||||
{workspaces.map((ws) => {
|
||||
const active = activeWorkspace ? ws.path === activeWorkspace.path : ws.name === 'Default'
|
||||
const expanded = expandedWs.has(ws.path)
|
||||
return (
|
||||
<div key={ws.path}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onOpenWorkspace(ws.path)
|
||||
setWsMenuOpen(false)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onOpenWorkspace(ws.path)
|
||||
}}
|
||||
className={`flex h-9 w-full cursor-pointer items-center gap-2 rounded-[6px] px-1.5 text-left transition-colors hover:bg-hover ${active ? 'bg-hover' : ''}`}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-[6px] bg-ink text-[11px] font-semibold leading-none text-canvas">
|
||||
{ws.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-ink">{ws.name}</span>
|
||||
{active && <LuCheck size={14} className="shrink-0 text-ink" />}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? '收起详情' : '展开详情'}
|
||||
aria-expanded={expanded}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpandedWs((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(ws.path)) next.delete(ws.path)
|
||||
else next.add(ws.path)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded-[4px] text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink"
|
||||
>
|
||||
<LuChevronDown
|
||||
size={13}
|
||||
strokeWidth={2}
|
||||
className={`transition-transform duration-150 ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="px-1.5 pb-1.5">
|
||||
<p className="break-all rounded-[6px] bg-inset px-2 py-1 font-mono text-[10.5px] leading-[1.6] text-ink-3">
|
||||
{ws.path}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="my-1 border-t border-line" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
// 直接调起系统目录选择器(对话框内可新建文件夹);取消则不动
|
||||
if (typeof window.api.browseWorkspace !== 'function') return
|
||||
try {
|
||||
const dir = await window.api.browseWorkspace()
|
||||
if (dir) {
|
||||
onOpenWorkspace(dir)
|
||||
setWsMenuOpen(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[sidebar] browse workspace failed:', err)
|
||||
}
|
||||
}}
|
||||
className="flex h-8 w-full items-center gap-2 rounded-[6px] px-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-3">
|
||||
<LuPlus size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="text-[13px] text-ink">新建工作区</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setWsMenuOpen(false)
|
||||
onOpenSettings()
|
||||
}}
|
||||
className="flex h-8 w-full items-center gap-2 rounded-[6px] px-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-3">
|
||||
<LuSettings size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="text-[13px] text-ink">设置</span>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 主导航(glide hover) */}
|
||||
<GlideMenu className="gap-px" highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
|
||||
<button type="button" data-row onClick={onNewSession} className={navRowClass}>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">
|
||||
<LuSquarePen size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">新会话</span>
|
||||
</button>
|
||||
<button type="button" data-row onClick={onOpenSearch} title="搜索(Ctrl+K)" className={navRowClass}>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">
|
||||
<LuSearch size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">搜索</span>
|
||||
</button>
|
||||
</GlideMenu>
|
||||
|
||||
{/* 会话列表区 */}
|
||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="sidebar-copy relative mx-2 mb-1 h-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatsOpen((v) => !v)}
|
||||
aria-expanded={chatsOpen}
|
||||
className="absolute inset-0 flex items-center gap-1.5 px-2 text-[12.5px] font-medium text-ink-3"
|
||||
>
|
||||
<LuChevronDown
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
className={`shrink-0 transition-transform duration-150 ${chatsOpen ? '' : '-rotate-90'}`}
|
||||
/>
|
||||
<span>会话</span>
|
||||
</button>
|
||||
|
||||
{/* 内联搜索:放大镜 ↔ 输入框宽度过渡 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="搜索会话"
|
||||
aria-expanded={searchOpen}
|
||||
onClick={() => setSearchOpen((v) => !v)}
|
||||
className="absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color,transform] hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
|
||||
style={{ opacity: searchOpen ? 0 : 1, pointerEvents: searchOpen ? 'none' : undefined }}
|
||||
>
|
||||
<LuSearch size={16} strokeWidth={2} />
|
||||
</button>
|
||||
<div
|
||||
className="absolute right-0 top-0 z-20 flex h-8 items-center overflow-hidden rounded-[8px] bg-field text-ink-3 shadow-hairline transition-[width,opacity] focus-within:text-ink-2"
|
||||
style={{ width: searchOpen ? '100%' : 28, opacity: searchOpen ? 1 : 0, pointerEvents: searchOpen ? undefined : 'none', transitionDuration: '180ms', transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||
>
|
||||
<span className="ml-2 flex shrink-0 items-center justify-center">
|
||||
<LuSearch size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setQuery('')
|
||||
setSearchOpen(false)
|
||||
}
|
||||
}}
|
||||
placeholder="搜索会话"
|
||||
aria-label="搜索会话历史"
|
||||
className="ml-1.5 min-w-0 flex-1 bg-transparent text-[13px] font-medium text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭搜索"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setSearchOpen(false)
|
||||
}}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
|
||||
>
|
||||
<LuX size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chatsOpen && (
|
||||
<GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="sidebar-copy mx-2 mb-1 rounded-[8px] border border-dashed border-line px-3 py-4 text-center text-[12px] leading-5 text-ink-3">
|
||||
{q ? '没有匹配的会话' : '暂无历史会话'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((s) => {
|
||||
const active = s.id === activeSessionId
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
data-row
|
||||
title={sessionTitle(s)}
|
||||
onClick={() => onOpenSession(s.path)}
|
||||
className={`${navRowClass} group/row cursor-pointer ${active ? 'text-ink' : 'text-ink-2'}`}
|
||||
>
|
||||
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px */}
|
||||
<span className="sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium">{sessionTitle(s)}</span>
|
||||
{/* 悬停浮动操作:quiet 图标钮,无底板,贴近官方 primitive-icon-button */}
|
||||
<span className="absolute right-0.5 hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
|
||||
{active && (
|
||||
<button
|
||||
type="button"
|
||||
title="重命名会话"
|
||||
className="flex size-6 items-center justify-center rounded-control text-ink-3 transition-colors duration-100 hover:bg-line-strong hover:text-ink"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// 锚定所在行(按钮悬停才可见,display:none 时 rect 为 0)
|
||||
const rect =
|
||||
e.currentTarget.closest('[data-row]')?.getBoundingClientRect() ??
|
||||
e.currentTarget.getBoundingClientRect()
|
||||
setRenameValue(sessionTitle(s))
|
||||
setActionMenu({ kind: 'rename', session: s, rect: { top: rect.bottom, left: rect.left } })
|
||||
}}
|
||||
>
|
||||
<LuPencil size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="删除会话"
|
||||
className="flex size-6 items-center justify-center rounded-control text-ink-3 transition-colors duration-100 hover:bg-red-tint hover:text-red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const rect =
|
||||
e.currentTarget.closest('[data-row]')?.getBoundingClientRect() ??
|
||||
e.currentTarget.getBoundingClientRect()
|
||||
setActionMenu({ kind: 'delete', session: s, rect: { top: rect.bottom, left: rect.left } })
|
||||
}}
|
||||
>
|
||||
<LuTrash2 size={13} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</GlideMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
|
||||
{actionMenu && (
|
||||
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
|
||||
{actionMenu.kind === 'rename' ? (
|
||||
<form
|
||||
className="p-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submitRename()
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 text-[13px] font-medium text-ink">重命名会话</div>
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setActionMenu(null)
|
||||
}}
|
||||
placeholder="会话名称"
|
||||
aria-label="会话名称"
|
||||
className="mb-2.5 w-full rounded-control bg-field px-2.5 py-1.5 text-[12.5px] text-ink outline-none shadow-inset-field focus:shadow-[inset_0_0_0_1.5px_var(--accent)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => setActionMenu(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={submitRename}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="p-1.5">
|
||||
<div className="mb-1 flex items-center gap-2 text-[13px] font-medium text-ink">
|
||||
<LuTrash2 size={13} className="text-red" />
|
||||
删除这个会话?
|
||||
</div>
|
||||
<div className="mb-3 text-[11.5px] leading-5 text-ink-2">会话文件将移入系统回收站,可随时恢复。</div>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => setActionMenu(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="bg-red hover:bg-red/90"
|
||||
onClick={() => {
|
||||
onDeleteSession(actionMenu.session.path)
|
||||
setActionMenu(null)
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RowActionPortal>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
/** 流式回答(beautifului.dev Streaming Text)。
|
||||
* 正文随 delta 增长并带竖线光标;流式结束后光标消失、
|
||||
* 操作行淡入(复制);followUps 由调用方提供时逐条列出可点。
|
||||
* 站点演示的 refresh/点赞等操作在本应用无后端,未接入。 */
|
||||
export function StreamingText({
|
||||
children,
|
||||
streaming,
|
||||
text,
|
||||
followUps,
|
||||
onFollowUp
|
||||
}: {
|
||||
/** 正文内容(通常为 <Markdown/>) */
|
||||
children?: ReactNode
|
||||
streaming: boolean
|
||||
/** 已渲染的纯文本,用于复制;为空时不显示操作行 */
|
||||
text?: string
|
||||
followUps?: string[]
|
||||
onFollowUp?: (text: string) => void
|
||||
}): ReactNode {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const t = setTimeout(() => setCopied(false), 1600)
|
||||
return () => clearTimeout(t)
|
||||
}, [copied])
|
||||
|
||||
const copy = (): void => {
|
||||
void navigator.clipboard.writeText(text ?? '').then(() => setCopied(true))
|
||||
}
|
||||
|
||||
const done = !streaming && Boolean(text)
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-base leading-normal text-ink">
|
||||
{children}
|
||||
<span
|
||||
aria-hidden={!streaming}
|
||||
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink align-baseline"
|
||||
style={{
|
||||
animation: 'fade-in 150ms ease-out both',
|
||||
opacity: streaming ? undefined : 0,
|
||||
transition: 'opacity 400ms ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作行:流式结束后淡入 */}
|
||||
<div
|
||||
className="mt-2 flex items-center gap-0.5 transition-opacity duration-[400ms]"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? undefined : 'none' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copied ? '已复制' : '复制回答'}
|
||||
onClick={copy}
|
||||
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
|
||||
>
|
||||
{copied ? (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2.5" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Follow-ups:完成后的跟进问题列表 */}
|
||||
{done && followUps && followUps.length > 0 && (
|
||||
<div className="mt-2.5" style={{ animation: 'fade-in 400ms ease both' }}>
|
||||
<p className="text-[12px] font-medium text-ink-2">继续问</p>
|
||||
<div className="mt-0.5 flex flex-col">
|
||||
{followUps.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
type="button"
|
||||
onClick={() => onFollowUp?.(q)}
|
||||
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M9 10l-5 5 5 5" />
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||
</svg>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 可展开的思考轨迹(beautifului.dev Thinking · Reasoning 形态)。
|
||||
* 流式期间自动展开并 shimmer「思考中…」;结束后收起为「思考过程」,
|
||||
* 点击表头可手动展开/收起。rows 由真实思考文本逐行喂入。 */
|
||||
export function Thinking({
|
||||
rows,
|
||||
streaming,
|
||||
activeLabel = '思考中…',
|
||||
doneLabel = '思考过程'
|
||||
}: {
|
||||
rows: { primary: string }[]
|
||||
streaming: boolean
|
||||
activeLabel?: string
|
||||
doneLabel?: string
|
||||
}): ReactNode {
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
||||
const expanded = manualExpanded ?? streaming
|
||||
|
||||
const traceRef = useRef<HTMLDivElement>(null)
|
||||
const [lineHeight, setLineHeight] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight)
|
||||
}, [rows, expanded])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full max-w-95 flex-col"
|
||||
style={{
|
||||
minHeight: expanded ? Math.min(60 + rows.length * 28, 220) : undefined,
|
||||
transition: 'min-height 400ms ease'
|
||||
}}
|
||||
>
|
||||
{/* 表头:状态词 + 旋转箭头 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setManualExpanded((current) => !(current ?? streaming))}
|
||||
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}>
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
||||
</svg>
|
||||
<span role="status" className="contents">
|
||||
{streaming ? (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer-text 1.4s linear infinite'
|
||||
}}
|
||||
>
|
||||
{activeLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
|
||||
style={{ animation: 'fade-in 350ms ease-out both' }}
|
||||
>
|
||||
{doneLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--ink-3)"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-transform"
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0)' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 展开轨迹:左侧 hairline + 逐行 fade-up */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity]"
|
||||
style={{
|
||||
gridTemplateRows: expanded ? '1fr' : '0fr',
|
||||
opacity: expanded ? 1 : 0,
|
||||
transitionTimingFunction: 'ease'
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative mt-1 ml-[5px] pl-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[3px] w-px bg-line"
|
||||
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: 'height 400ms ease' }}
|
||||
/>
|
||||
<div ref={traceRef} className="flex flex-col gap-1 py-1">
|
||||
{rows.map((row, i) => (
|
||||
<div
|
||||
key={`${i}-${row.primary.slice(0, 12)}`}
|
||||
className="flex min-h-7 w-full items-start gap-2 rounded-control px-1.5 py-0.5 text-left"
|
||||
style={{ animation: `fade-up 400ms ease ${Math.min(i, 8) * 120}ms both` }}
|
||||
>
|
||||
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">{row.primary}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 思考文本 → 行数组(空行剔除,超长行截断) */
|
||||
export function thinkingRows(text: string): { primary: string }[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => ({ primary: line.length > 300 ? `${line.slice(0, 300)}…` : line }))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState, type JSX } from 'react'
|
||||
|
||||
/**
|
||||
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
||||
* 整条是拖拽区;左上角展示当前会话名称(无名称时显示「新会话」);
|
||||
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
|
||||
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||
*/
|
||||
export function TitleBar({ title }: { title?: string }): JSX.Element {
|
||||
const name = title?.trim()
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
|
||||
useEffect(() => window.api.onWindowState((maximized) => setMaximized(maximized)), [])
|
||||
|
||||
return (
|
||||
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-zinc-200 bg-white pl-4 [-webkit-app-region:drag]">
|
||||
<div className="min-w-0 truncate text-[12.5px] font-medium text-zinc-800">{name || '新会话'}</div>
|
||||
<div className="ml-auto flex h-full shrink-0 [-webkit-app-region:no-drag]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="最小化"
|
||||
onClick={() => void window.api.minimize()}
|
||||
className="flex h-full w-[46px] items-center justify-center text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||
<path d="M0 5h10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={maximized ? '还原' : '最大化'}
|
||||
onClick={() => void window.api.toggleMaximize()}
|
||||
className="flex h-full w-[46px] items-center justify-center text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
{maximized ? (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||
<path d="M2.5 2.5V0h7.5v7.5H7.5M0 2.5h7.5V10H0z" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||
<path d="M.5.5h9v9H.5z" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={() => void window.api.close()}
|
||||
className="flex h-full w-[46px] items-center justify-center text-zinc-500 transition-colors hover:bg-[#e81123] hover:text-white"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||
<path d="M0 0l10 10M10 0L0 10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX, RefObject } from 'react'
|
||||
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
|
||||
import type { ToolState } from '../hooks/useAgent'
|
||||
|
||||
/** 从工具结果中提取可读文本(防御性读取,结构未知时回退 JSON) */
|
||||
function extractResultText(result: unknown): string {
|
||||
if (result == null) return ''
|
||||
if (typeof result === 'string') return result
|
||||
if (typeof result === 'object') {
|
||||
const r = result as Record<string, unknown>
|
||||
if (Array.isArray(r.content)) {
|
||||
const texts = r.content
|
||||
.filter((c): c is { type: string; text?: string } => typeof c === 'object' && c !== null)
|
||||
.map((c) => (c.type === 'text' && typeof c.text === 'string' ? c.text : ''))
|
||||
.filter(Boolean)
|
||||
if (texts.length) return texts.join('\n')
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(result, null, 2)
|
||||
} catch {
|
||||
return String(result)
|
||||
}
|
||||
}
|
||||
|
||||
function pretty(value: unknown): string {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'string') {
|
||||
// 尝试美化 JSON 字符串参数
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text: string, max = 4000): string {
|
||||
if (text.length <= max) return text
|
||||
return `${text.slice(0, max)}\n…(已截断,共 ${text.length} 字符)`
|
||||
}
|
||||
|
||||
export function ToolCard({ toolCallId, tool }: { toolCallId: string; tool: ToolState }): JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const resultText = extractResultText(tool.result)
|
||||
const argsText = pretty(tool.args)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-zinc-800/50"
|
||||
>
|
||||
{tool.status === 'running' ? (
|
||||
<Loader2 size={14} className="shrink-0 animate-spin text-emerald-400" />
|
||||
) : tool.status === 'error' ? (
|
||||
<X size={14} className="shrink-0 text-red-400" />
|
||||
) : (
|
||||
<Check size={14} className="shrink-0 text-emerald-400" />
|
||||
)}
|
||||
<span className="font-mono text-[12.5px] text-zinc-300">{tool.toolName}</span>
|
||||
{tool.status === 'running' && <span className="text-[11px] text-zinc-500">执行中…</span>}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`ml-auto shrink-0 text-zinc-500 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="space-y-2 border-t border-zinc-800/80 px-3 py-2">
|
||||
{argsText && (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-medium text-zinc-500">参数</div>
|
||||
<pre className="overflow-x-auto rounded bg-zinc-950/60 p-2 font-mono text-[11.5px] leading-relaxed text-zinc-400">
|
||||
{truncate(argsText, 1500)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{(resultText || tool.status !== 'running') && (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-medium text-zinc-500">
|
||||
{tool.status === 'error' ? '错误' : '结果'} · {toolCallId.slice(0, 8)}
|
||||
</div>
|
||||
<pre
|
||||
className={`overflow-x-auto rounded bg-zinc-950/60 p-2 font-mono text-[11.5px] leading-relaxed ${
|
||||
tool.status === 'error' ? 'text-red-300/90' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{truncate(resultText)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 等待用户审批中的工具占位卡(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)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
let tween: { kill(): void } | null = null
|
||||
let cancelled = false
|
||||
void import('gsap').then((gsapModule) => {
|
||||
if (cancelled) return
|
||||
tween = gsapModule.gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 10 },
|
||||
{ opacity: 1, y: 0, duration: 0.28, ease: 'power2.out', clearProps: 'transform' }
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
tween?.kill()
|
||||
}
|
||||
}, [])
|
||||
return ref
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { LuFileCode2 } from 'react-icons/lu'
|
||||
import { SiHtml5, SiJavascript, SiJson, SiMarkdown, SiPython, SiReact, SiTypescript } from 'react-icons/si'
|
||||
import type { ToolState } from '@/hooks/useAgent'
|
||||
|
||||
/** 工具调用轨迹行(beautifului.dev Tool Chips 的裸行形态)。
|
||||
* 每次思考/工具调用 = 一行:图标 + 名称 + 目标(文件名/命令)+ 增删统计,
|
||||
* 无卡片底、无分组头;悬停显示箭头、点击行展开实际执行了什么。 */
|
||||
|
||||
const STEP_ICONS: Record<string, ReactNode> = {
|
||||
think: <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />,
|
||||
write: (
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z" />
|
||||
</g>
|
||||
),
|
||||
run: (
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 17l6-5-6-5M12 19h8" />
|
||||
</g>
|
||||
),
|
||||
read: (
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
/** 文件类型图标(ZCode 式彩色品牌标),未知类型回退灰色代码文件图标 */
|
||||
function FileTypeIcon({ filename }: { filename: string }): ReactNode {
|
||||
const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase()
|
||||
const common = 'shrink-0 text-[13px] leading-none'
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
return <SiTypescript className={`${common} text-[#3178c6]`} />
|
||||
case 'tsx':
|
||||
return <SiReact className={`${common} text-[#61dafb]`} />
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return <SiJavascript className={`${common} text-[#c9a227]`} />
|
||||
case 'py':
|
||||
return <SiPython className={`${common} text-[#3776ab]`} />
|
||||
case 'json':
|
||||
return <SiJson className={`${common} text-ink-3`} />
|
||||
case 'html':
|
||||
return <SiHtml5 className={`${common} text-[#e34f26]`} />
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
return <SiMarkdown className={`${common} text-ink-2`} />
|
||||
default:
|
||||
return <LuFileCode2 className={`${common} text-ink-3`} />
|
||||
}
|
||||
}
|
||||
|
||||
/** 从工具参数里提取一句话摘要(命令 / 关键词…) */
|
||||
function summarizeToolArgs(args: unknown): string {
|
||||
if (!args || typeof args !== 'object') return ''
|
||||
const a = args as Record<string, unknown>
|
||||
const str = (...keys: string[]): string => {
|
||||
for (const k of keys) {
|
||||
const v = a[k]
|
||||
if (typeof v === 'string' && v.trim()) return v.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const command = str('command', 'cmd', 'script')
|
||||
if (command) return command.replace(/\s+/g, ' ').slice(0, 80)
|
||||
const pattern = str('pattern', 'query', 'keyword', 'search')
|
||||
if (pattern) return pattern.slice(0, 60)
|
||||
const url = str('url')
|
||||
if (url) return url.replace(/^https?:\/\//, '').slice(0, 60)
|
||||
try {
|
||||
const flat = JSON.stringify(a).replace(/\s+/g, ' ')
|
||||
return flat.length > 60 ? `${flat.slice(0, 60)}…` : flat
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 从工具结果中提取可读文本(防御性读取,结构未知时回退 JSON) */
|
||||
function extractResultText(result: unknown): string {
|
||||
if (result == null) return ''
|
||||
if (typeof result === 'string') return result
|
||||
if (typeof result === 'object') {
|
||||
const r = result as Record<string, unknown>
|
||||
if (Array.isArray(r.content)) {
|
||||
const texts = r.content
|
||||
.filter((c): c is { type: string; text?: string } => typeof c === 'object' && c !== null)
|
||||
.map((c) => (c.type === 'text' && typeof c.text === 'string' ? c.text : ''))
|
||||
.filter(Boolean)
|
||||
if (texts.length) return texts.join('\n')
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(result, null, 2)
|
||||
} catch {
|
||||
return String(result)
|
||||
}
|
||||
}
|
||||
|
||||
/** 按工具名挑图标 + 中文标签(编辑/终端/读取,未知工具保留原名) */
|
||||
function toolMeta(name: string): { icon: keyof typeof STEP_ICONS; label: string } {
|
||||
if (/^read|get|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' }
|
||||
if (/^write|^edit|^multiedit|写|改|删|建|核/i.test(name)) return { icon: 'write', label: '编辑' }
|
||||
if (/^bash|^shell|^terminal|终端/i.test(name)) return { icon: 'run', label: '终端' }
|
||||
if (/read|get|search|query|fetch|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' }
|
||||
if (/write|edit|update|create|delete|patch|save|写|改|删|建|核/i.test(name)) return { icon: 'write', label: '编辑' }
|
||||
return { icon: 'run', label: name }
|
||||
}
|
||||
|
||||
/** 从参数里拆出文件路径 → 文件名 + 目录 */
|
||||
function splitFilePath(args: unknown): { file: string; dir: string } | null {
|
||||
if (!args || typeof args !== 'object') return null
|
||||
const a = args as Record<string, unknown>
|
||||
for (const k of ['file_path', 'path', 'file', 'filename', 'notebook_path']) {
|
||||
const v = a[k]
|
||||
if (typeof v === 'string' && v.trim()) {
|
||||
const normalized = v.trim().replace(/\\/g, '/')
|
||||
const idx = normalized.lastIndexOf('/')
|
||||
if (idx >= 0) {
|
||||
return { dir: normalized.slice(0, idx + 1), file: normalized.slice(idx + 1) }
|
||||
}
|
||||
return { dir: '', file: normalized }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function lineCount(s: unknown): number {
|
||||
return typeof s === 'string' && s.length ? s.split('\n').length : 0
|
||||
}
|
||||
|
||||
/** 从参数估算增删行数(write=全文新增;edit=old/new 串行数;edits 数组求和) */
|
||||
function diffStat(args: unknown): { add: number; del: number } | null {
|
||||
if (!args || typeof args !== 'object') return null
|
||||
const a = args as Record<string, unknown>
|
||||
let add = 0
|
||||
let del = 0
|
||||
if (typeof a.content === 'string') {
|
||||
add = lineCount(a.content)
|
||||
}
|
||||
if (typeof a.new_string === 'string' || typeof a.old_string === 'string') {
|
||||
add += lineCount(a.new_string)
|
||||
del += lineCount(a.old_string)
|
||||
}
|
||||
if (Array.isArray(a.edits)) {
|
||||
for (const e of a.edits as Array<Record<string, unknown>>) {
|
||||
add += lineCount(e?.new_string)
|
||||
del += lineCount(e?.old_string)
|
||||
}
|
||||
}
|
||||
if (add === 0 && del === 0) return null
|
||||
return { add, del }
|
||||
}
|
||||
|
||||
/** 展开行里的一行明细 */
|
||||
type DetailLine = { text: string; tone?: 'ok' | 'error' | 'pending' | 'plain' }
|
||||
|
||||
/** 轨迹行的统一形状(思考行 file/dir/add/del 为空) */
|
||||
type ToolRow = {
|
||||
key: string
|
||||
icon: keyof typeof STEP_ICONS
|
||||
label: string
|
||||
file: string
|
||||
dir: string
|
||||
add?: number
|
||||
del?: number
|
||||
chip: string
|
||||
detail: DetailLine[]
|
||||
tool?: ToolState
|
||||
pending: boolean
|
||||
}
|
||||
|
||||
export interface ToolCallView {
|
||||
id?: string
|
||||
name: string
|
||||
tool?: ToolState
|
||||
/** toolCall 已产生但审批未决(等待用户允许) */
|
||||
pendingApproval?: { args?: unknown }
|
||||
}
|
||||
|
||||
function buildRow(view: ToolCallView, dupIndex: number): ToolRow {
|
||||
const { name, tool } = view
|
||||
const meta = toolMeta(name)
|
||||
const detail: DetailLine[] = []
|
||||
if (view.pendingApproval) {
|
||||
detail.push({ text: '等待你的审批…', tone: 'pending' })
|
||||
} else if (!tool) {
|
||||
detail.push({ text: '等待执行…', tone: 'pending' })
|
||||
} else if (tool.status === 'running') {
|
||||
detail.push({ text: '执行中…', tone: 'pending' })
|
||||
} else {
|
||||
detail.push(
|
||||
tool.status === 'error' ? { text: '✕ 执行出错', tone: 'error' } : { text: '✓ 已完成', tone: 'ok' }
|
||||
)
|
||||
const resultText = extractResultText(tool.result)
|
||||
if (resultText) {
|
||||
const lines = resultText.split('\n').slice(0, 10)
|
||||
for (const line of lines) detail.push({ text: line.slice(0, 160), tone: 'plain' })
|
||||
if (resultText.split('\n').length > 10) detail.push({ text: '…(结果已截断)', tone: 'plain' })
|
||||
}
|
||||
}
|
||||
const args = tool?.args ?? view.pendingApproval?.args
|
||||
const fileMeta = meta.icon === 'write' || meta.icon === 'read' ? splitFilePath(args) : null
|
||||
const stat = meta.icon === 'write' ? diffStat(args) : null
|
||||
const dupSuffix = dupIndex > 0 ? ` (${dupIndex + 1})` : ''
|
||||
return {
|
||||
key: view.id ?? `${name}-${dupIndex}`,
|
||||
icon: meta.icon,
|
||||
label: `${meta.label}${dupSuffix}`,
|
||||
file: fileMeta?.file ?? '',
|
||||
dir: fileMeta?.dir ?? '',
|
||||
add: stat?.add,
|
||||
del: stat?.del,
|
||||
chip: fileMeta ? '' : summarizeToolArgs(args),
|
||||
detail,
|
||||
tool,
|
||||
pending: Boolean(view.pendingApproval)
|
||||
}
|
||||
}
|
||||
|
||||
function buildThinkingRows(texts: string[]): ToolRow[] {
|
||||
const rows: ToolRow[] = []
|
||||
texts.forEach((text, i) => {
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
if (!lines.length) return
|
||||
rows.push({
|
||||
key: `think-${i}-${lines[0].slice(0, 12)}`,
|
||||
icon: 'think',
|
||||
label: i > 0 ? `思考 (${i + 1})` : '思考',
|
||||
file: '',
|
||||
dir: '',
|
||||
chip: lines[0].length > 60 ? `${lines[0].slice(0, 60)}…` : lines[0],
|
||||
detail: lines.slice(0, 12).map((line) => ({ text: line.slice(0, 160), tone: 'plain' as const })),
|
||||
tool: undefined,
|
||||
pending: false
|
||||
})
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
const DETONE_CLASS: Record<NonNullable<DetailLine['tone']>, string> = {
|
||||
ok: 'text-green',
|
||||
error: 'text-red',
|
||||
pending: 'text-orange',
|
||||
plain: 'text-ink-2'
|
||||
}
|
||||
|
||||
export function ToolChips({ calls = [], thinking = [], className = '' }: {
|
||||
calls?: ToolCallView[]
|
||||
thinking?: string[]
|
||||
className?: string
|
||||
}): ReactNode | null {
|
||||
const rows = [
|
||||
...buildThinkingRows(thinking),
|
||||
...calls.map((c) => buildRow(c, calls.filter((x) => x.name === c.name).indexOf(c)))
|
||||
]
|
||||
const [openRows, setOpenRows] = useState<Set<string>>(new Set())
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const toggleRow = (key: string): void => {
|
||||
setOpenRows((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex min-w-0 w-full flex-col gap-0.5 ${className}`}>
|
||||
{rows.map((row) => {
|
||||
const rowOpen = openRows.has(row.key)
|
||||
return (
|
||||
<div key={row.key} style={{ animation: 'fade-up 400ms ease both' }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={rowOpen}
|
||||
onClick={() => toggleRow(row.key)}
|
||||
className="group/row -mx-1.5 flex h-7 w-fit max-w-full items-center gap-2 rounded-control px-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span className="relative flex size-4 shrink-0 items-center justify-center text-ink-3">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill={row.icon === 'think' ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
className={`transition-opacity group-hover/row:opacity-0 ${rowOpen ? 'opacity-0' : ''}`}
|
||||
>
|
||||
{STEP_ICONS[row.icon]}
|
||||
</svg>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`absolute transition-[opacity,transform] group-hover/row:opacity-100 ${rowOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{ transform: rowOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="shrink-0 text-[12.5px] text-ink-2">{row.label}</span>
|
||||
{row.file && (
|
||||
<>
|
||||
<FileTypeIcon filename={row.file} />
|
||||
<span className="shrink-0 font-mono text-[12.5px] font-medium text-ink-2">{row.file}</span>
|
||||
{row.dir && <span className="min-w-0 truncate font-mono text-[12px] text-ink-3">{row.dir}</span>}
|
||||
{(row.add !== undefined || row.del !== undefined) && (
|
||||
<span className="shrink-0 font-mono text-[12px] tabular-nums">
|
||||
{row.add ? <span className="text-green">+{row.add}</span> : null}
|
||||
{row.del ? <span className="ml-1 text-red">−{row.del}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{row.chip && (
|
||||
<span
|
||||
className={`min-w-0 max-w-full truncate text-[12.5px] text-ink-3 ${row.icon === 'think' ? '' : 'font-mono'}`}
|
||||
>
|
||||
{row.chip}
|
||||
</span>
|
||||
)}
|
||||
{row.icon !== 'think' && <StatusMark tool={row.tool} pending={row.pending} />}
|
||||
</button>
|
||||
|
||||
{/* 展开明细 */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity]"
|
||||
style={{ gridTemplateRows: rowOpen ? '1fr' : '0fr', opacity: rowOpen ? 1 : 0, transitionTimingFunction: 'ease' }}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="mt-0.5 mb-1 ml-2 flex flex-col gap-0.5 border-l border-line py-0.5 pl-3.5">
|
||||
{row.detail.map((line, lineIndex) => (
|
||||
<span
|
||||
key={`${lineIndex}-${line.text.slice(0, 12)}`}
|
||||
className={`truncate text-[11.5px] leading-[1.6] ${row.icon === 'think' ? '' : 'font-mono'} ${DETONE_CLASS[line.tone ?? 'plain']}`}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 行尾状态:仅运行中/待审批时显示(spinner/橙点)。
|
||||
* 完成/出错保持安静——灰阶行不与正文抢视觉,结果在展开明细里看(✓/✕)。 */
|
||||
function StatusMark({ tool, pending }: { tool?: ToolState; pending: boolean }): ReactNode {
|
||||
if (pending) return <span className="size-1.5 shrink-0 rounded-full bg-orange" />
|
||||
if (!tool || tool.status === 'running')
|
||||
return (
|
||||
<span
|
||||
className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2"
|
||||
style={{ animation: 'spin 700ms linear infinite' }}
|
||||
/>
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/* 设计规范:矩形控件统一 5px 圆角(rounded-control),rounded-full 仅限
|
||||
* icon 圆钮/状态点/头像。动效走全局默认 400ms + ease(@theme 注入)。 */
|
||||
export const buttonVariants = cva(
|
||||
`inline-flex items-center justify-center font-medium select-none
|
||||
transition-[transform,background-color,opacity]
|
||||
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none`,
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-ink text-canvas hover:opacity-90",
|
||||
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
|
||||
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
|
||||
accent: "bg-accent text-white hover:bg-accent-ink",
|
||||
success: "bg-green text-white hover:brightness-95",
|
||||
/* transparent until hovered — for dense toolbars/action rows */
|
||||
quiet: "text-ink hover:bg-hover",
|
||||
},
|
||||
size: {
|
||||
/* compact toolbar control — fixed height, lighter weight */
|
||||
xs: "h-7 rounded-control px-2.5 text-[12px] font-normal leading-none gap-1",
|
||||
/* canonical action button */
|
||||
sm: "h-[27px] px-3 text-[13px] leading-none rounded-control gap-1.5",
|
||||
md: "px-4 py-[9px] text-sm leading-none rounded-control gap-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "secondary", size: "md" },
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
|
||||
|
||||
export function Button({
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
|
||||
import type { AgentUiEvent, ApprovalRequest, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
import type { AgentUiEvent, ApprovalRequest, PermissionMode, SendImage, SessionMeta, SessionState, SessionStatsInfo, WorkspaceMeta } from '@shared/types'
|
||||
|
||||
export type { ApprovalRequest }
|
||||
|
||||
@@ -273,7 +273,8 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
export interface UseAgent {
|
||||
state: AgentUiState
|
||||
sessions: SessionMeta[]
|
||||
send: (text: string) => void
|
||||
workspaces: WorkspaceMeta[]
|
||||
send: (text: string, images?: SendImage[]) => void
|
||||
stop: () => void
|
||||
clearError: () => void
|
||||
refreshSessions: () => void
|
||||
@@ -281,18 +282,31 @@ export interface UseAgent {
|
||||
openSession: (path: string) => void
|
||||
renameSession: (name: string) => void
|
||||
deleteSession: (path: string) => void
|
||||
setModel: (providerId: string, modelId: string) => void
|
||||
resolveApproval: (id: string, approved: boolean) => void
|
||||
setPermissionMode: (mode: PermissionMode) => void
|
||||
createWorkspace: (name: string) => void
|
||||
openWorkspace: (path: string) => void
|
||||
}
|
||||
|
||||
export function useAgent(): UseAgent {
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
const [sessions, setSessions] = useState<SessionMeta[]>([])
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceMeta[]>([])
|
||||
|
||||
const refreshWorkspaces = useCallback((): void => {
|
||||
void window.api
|
||||
.listWorkspaces()
|
||||
.then(setWorkspaces)
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.api.onEvent((event) => {
|
||||
dispatch({ type: 'event', event })
|
||||
// 会话集合可能在后台变化(新建/删除/重命名),切换后刷新列表
|
||||
if (event.type === 'session_switched') {
|
||||
// 会话集合可能在后台变化(新建/删除/重命名),切换后刷新列表;
|
||||
// 每个回合结束(stats)也刷新一次,让新会话首条消息落盘后的元数据及时更新
|
||||
if (event.type === 'session_switched' || event.type === 'stats') {
|
||||
void window.api
|
||||
.listSessions()
|
||||
.then(setSessions)
|
||||
@@ -311,7 +325,8 @@ export function useAgent(): UseAgent {
|
||||
|
||||
useEffect(() => {
|
||||
refreshSessions()
|
||||
}, [refreshSessions])
|
||||
refreshWorkspaces()
|
||||
}, [refreshSessions, refreshWorkspaces])
|
||||
|
||||
const dispatchError = useCallback((err: unknown): void => {
|
||||
dispatch({
|
||||
@@ -321,11 +336,11 @@ export function useAgent(): UseAgent {
|
||||
}, [])
|
||||
|
||||
const send = useCallback(
|
||||
(text: string) => {
|
||||
(text: string, images?: SendImage[]) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
dispatch({ type: 'optimistic-user', key: nextKey('u'), text: trimmed })
|
||||
window.api.send(trimmed).catch(dispatchError)
|
||||
if (!trimmed && !(images && images.length)) return
|
||||
dispatch({ type: 'optimistic-user', key: nextKey('u'), text: trimmed || '(附件)' })
|
||||
window.api.send(trimmed, images).catch(dispatchError)
|
||||
},
|
||||
[dispatchError]
|
||||
)
|
||||
@@ -342,6 +357,8 @@ export function useAgent(): UseAgent {
|
||||
|
||||
const openSession = useCallback(
|
||||
(path: string): void => {
|
||||
// 未落盘的活跃会话 path 为空,点击它无需切换
|
||||
if (!path) return
|
||||
window.api.openSession(path).catch(dispatchError)
|
||||
},
|
||||
[dispatchError]
|
||||
@@ -365,6 +382,17 @@ export function useAgent(): UseAgent {
|
||||
[dispatchError]
|
||||
)
|
||||
|
||||
const setModel = useCallback(
|
||||
(providerId: string, modelId: string) => {
|
||||
window.api.setModel(providerId, modelId).catch(dispatchError)
|
||||
},
|
||||
[dispatchError]
|
||||
)
|
||||
|
||||
const setPermissionMode = useCallback((mode: PermissionMode) => {
|
||||
window.api.setPermissionMode(mode).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const resolveApproval = useCallback(
|
||||
(id: string, approved: boolean): void => {
|
||||
window.api.resolveApproval(id, approved).catch(dispatchError)
|
||||
@@ -372,10 +400,39 @@ export function useAgent(): UseAgent {
|
||||
[dispatchError]
|
||||
)
|
||||
|
||||
/** 新建工作区并切换过去(会话随目录隔离,session_switched 事件自动刷新列表) */
|
||||
const createWorkspace = useCallback(
|
||||
(name: string): void => {
|
||||
window.api
|
||||
.createWorkspace(name)
|
||||
.then((ws) => {
|
||||
refreshWorkspaces()
|
||||
return window.api.openWorkspace(ws.path)
|
||||
})
|
||||
.then(() => refreshSessions())
|
||||
.catch(dispatchError)
|
||||
},
|
||||
[dispatchError, refreshSessions, refreshWorkspaces]
|
||||
)
|
||||
|
||||
const openWorkspace = useCallback(
|
||||
(path: string): void => {
|
||||
window.api
|
||||
.openWorkspace(path)
|
||||
.then(() => {
|
||||
refreshWorkspaces()
|
||||
refreshSessions()
|
||||
})
|
||||
.catch(dispatchError)
|
||||
},
|
||||
[dispatchError, refreshSessions, refreshWorkspaces]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
state,
|
||||
sessions,
|
||||
workspaces,
|
||||
send,
|
||||
stop,
|
||||
clearError,
|
||||
@@ -384,11 +441,16 @@ export function useAgent(): UseAgent {
|
||||
openSession,
|
||||
renameSession,
|
||||
deleteSession,
|
||||
resolveApproval
|
||||
setModel,
|
||||
resolveApproval,
|
||||
setPermissionMode,
|
||||
createWorkspace,
|
||||
openWorkspace
|
||||
}),
|
||||
[
|
||||
state,
|
||||
sessions,
|
||||
workspaces,
|
||||
send,
|
||||
stop,
|
||||
clearError,
|
||||
@@ -397,7 +459,11 @@ export function useAgent(): UseAgent {
|
||||
openSession,
|
||||
renameSession,
|
||||
deleteSession,
|
||||
resolveApproval
|
||||
setModel,
|
||||
resolveApproval,
|
||||
setPermissionMode,
|
||||
createWorkspace,
|
||||
openWorkspace
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
|
||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
||||
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
||||
const ref = useRef<T | null>(null)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
let tween: { kill(): void } | null = null
|
||||
let cancelled = false
|
||||
void import('gsap').then((gsapModule) => {
|
||||
if (cancelled) return
|
||||
tween = gsapModule.gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 10 },
|
||||
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', clearProps: 'transform' }
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
tween?.kill()
|
||||
}
|
||||
}, [])
|
||||
return ref
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
AttachmentInfo,
|
||||
ChatResources,
|
||||
ModelCatalog,
|
||||
ModelInfo,
|
||||
PermissionMode,
|
||||
SendImage,
|
||||
SessionMeta,
|
||||
SessionState,
|
||||
SessionStatsInfo
|
||||
SessionStatsInfo,
|
||||
WorkspaceFile,
|
||||
WorkspaceMeta
|
||||
} from '@shared/types'
|
||||
|
||||
export type { AgentUiEvent, AppSettings, ModelCatalog, ModelInfo, SessionMeta, SessionState, SessionStatsInfo }
|
||||
@@ -13,16 +19,29 @@ export type { AgentUiEvent, AppSettings, ModelCatalog, ModelInfo, SessionMeta, S
|
||||
declare global {
|
||||
interface Window {
|
||||
api: {
|
||||
send: (text: string) => Promise<void>
|
||||
minimize: () => Promise<void>
|
||||
toggleMaximize: () => Promise<void>
|
||||
close: () => Promise<void>
|
||||
onWindowState: (callback: (maximized: boolean) => void) => () => void
|
||||
send: (text: string, images?: SendImage[]) => Promise<void>
|
||||
steer: (text: string) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
getState: () => Promise<SessionState>
|
||||
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }>
|
||||
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string; user: string }>
|
||||
listSessions: () => Promise<SessionMeta[]>
|
||||
newSession: () => Promise<void>
|
||||
openSession: (path: string) => Promise<void>
|
||||
renameSession: (name: string) => Promise<void>
|
||||
deleteSession: (path: string) => Promise<void>
|
||||
listWorkspaces: () => Promise<WorkspaceMeta[]>
|
||||
createWorkspace: (name: string) => Promise<WorkspaceMeta>
|
||||
openWorkspace: (path: string) => Promise<void>
|
||||
browseWorkspace: () => Promise<string | null>
|
||||
listChatResources: () => Promise<ChatResources>
|
||||
setQueue: (steering: string[], followUp: string[]) => Promise<void>
|
||||
setPermissionMode: (mode: PermissionMode) => Promise<void>
|
||||
listWorkspaceFiles: () => Promise<WorkspaceFile[]>
|
||||
pickAttachments: () => Promise<AttachmentInfo[]>
|
||||
getModelCatalog: () => Promise<ModelCatalog>
|
||||
setModel: (providerId: string, modelId: string) => Promise<void>
|
||||
setApiKey: (providerId: string, apiKey: string) => Promise<void>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/* Beautiful UI foundation — design tokens 按项目设计规范(OpenAI 浅色体系)重映射。
|
||||
* 规范要点:浅色界面;accent #8e8ea0(muted purple-gray);正文近黑;8px 间距网格;
|
||||
* 圆角统一 5px(圆形仅限 icon 按钮/状态点/头像);动效统一 400ms + ease;
|
||||
* 平面层级:surface 之间用 hairline 边框分层,仅浮层(modal/popover/审批卡)允许一层中性软阴影。
|
||||
* 层级语义:--accent 负责焦点环/选区/选中态/装饰 tint;--accent-ink(加深版)负责
|
||||
* 链接与可交互文本(保证 AA 对比度);--ink-3 是弱化文本的 AA 安全灰。 */
|
||||
@import "shadow-plugin/unprefixed";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ──────────────────────────────────────────────────────────
|
||||
* OpenAI light — design tokens
|
||||
* ────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* motion — 规范统一值 */
|
||||
--duration-fast: 400ms;
|
||||
--duration-base: 400ms;
|
||||
--duration-slow: 400ms;
|
||||
--ease-standard: ease;
|
||||
|
||||
/* surfaces — 中性灰阶,白底为主 */
|
||||
--page: #ffffff;
|
||||
--canvas: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--inset: #f4f4f5;
|
||||
--hover: #f0f0f1;
|
||||
--hover-2: #e6e6e8;
|
||||
|
||||
/* ink ramp — 正文近黑(规范 #000 的 a11y 修正版),弱化层为 AA 安全灰 */
|
||||
--ink: #0d0d0d;
|
||||
--ink-2: #5d5d5d;
|
||||
--ink-3: #75757f;
|
||||
|
||||
/* borders — 实色 hairline,不用 alpha */
|
||||
--line: #ececec;
|
||||
--line-strong: #dcdce0;
|
||||
--line-soft: #f4f4f5;
|
||||
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
|
||||
--field: #f4f4f5;
|
||||
--stripe: rgba(13, 13, 13, 0.04);
|
||||
--stripe-bg: #f7f7f8;
|
||||
|
||||
/* accent — 规范主色 #8e8ea0 及其加深可交互版 */
|
||||
--accent: #8e8ea0;
|
||||
--accent-ink: #707084;
|
||||
--accent-tint: rgba(142, 142, 160, 0.12);
|
||||
|
||||
/* semantic — 唯一允许的彩色例外(状态语义),已降饱和融入中性界面 */
|
||||
--green: #2e7d5b;
|
||||
--green-tint: #e9f3ee;
|
||||
--orange: #b45309;
|
||||
--orange-tint: #faf1e3;
|
||||
--red: #b91c1c;
|
||||
--red-tint: #fbecec;
|
||||
|
||||
/* chart overlays */
|
||||
--tooltip-bg: #2a2a2e;
|
||||
--tooltip-fg: #f7f7f8;
|
||||
--tooltip-muted: #a5a5af;
|
||||
--tooltip-border: #454549;
|
||||
|
||||
/* shadows — 平面优先:hairline 分层;仅浮层保留一层中性软阴影(文档化例外) */
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
--shadow-btn: 0 0 0 1px var(--line-strong);
|
||||
--shadow-card: 0 0 0 1px var(--line);
|
||||
--shadow-raised: 0 0 0 1px var(--line-strong);
|
||||
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 12px 32px rgba(13, 13, 13, 0.08);
|
||||
--shadow-inset-field: inset 0 0 0 1px var(--line-strong);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--page: #212121;
|
||||
--canvas: #212121;
|
||||
--surface: #2f2f2f;
|
||||
--inset: #181818;
|
||||
--hover: #2f2f2f;
|
||||
--hover-2: #424242;
|
||||
|
||||
--ink: #ececec;
|
||||
--ink-2: #b4b4b4;
|
||||
--ink-3: #8e8ea0;
|
||||
|
||||
--line: #383838;
|
||||
--line-strong: #4a4a4a;
|
||||
--line-soft: #2a2a2a;
|
||||
--field: #2f2f2f;
|
||||
--stripe: rgba(255, 255, 255, 0.05);
|
||||
--stripe-bg: #262626;
|
||||
|
||||
--accent: #a3a3b5;
|
||||
--accent-ink: #bcbccc;
|
||||
--accent-tint: rgba(142, 142, 160, 0.18);
|
||||
|
||||
--green: #6fbf95;
|
||||
--green-tint: rgba(111, 191, 149, 0.15);
|
||||
--orange: #d19a55;
|
||||
--orange-tint: rgba(209, 154, 85, 0.15);
|
||||
--red: #e07a70;
|
||||
--red-tint: rgba(224, 122, 112, 0.15);
|
||||
|
||||
--tooltip-bg: #171717;
|
||||
--tooltip-fg: #ececec;
|
||||
--tooltip-muted: #8e8ea0;
|
||||
--tooltip-border: #383838;
|
||||
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
--shadow-btn: 0 0 0 1px var(--line-strong);
|
||||
--shadow-card: 0 0 0 1px var(--line);
|
||||
--shadow-raised: 0 0 0 1px var(--line-strong);
|
||||
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
--shadow-inset-field: inset 0 0 0 1px var(--line-strong);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* colors */
|
||||
--color-page: var(--page);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-surface: var(--surface);
|
||||
--color-inset: var(--inset);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-2: var(--ink-2);
|
||||
--color-ink-3: var(--ink-3);
|
||||
--color-hover: var(--hover);
|
||||
--color-hover-2: var(--hover-2);
|
||||
--color-field: var(--field);
|
||||
--color-line: var(--line);
|
||||
--color-line-strong: var(--line-strong);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-accent-tint: var(--accent-tint);
|
||||
--color-green: var(--green);
|
||||
--color-green-tint: var(--green-tint);
|
||||
--color-orange: var(--orange);
|
||||
--color-orange-tint: var(--orange-tint);
|
||||
--color-red: var(--red);
|
||||
--color-red-tint: var(--red-tint);
|
||||
|
||||
/* shadows */
|
||||
--shadow-hairline: var(--shadow-hairline);
|
||||
--shadow-btn: var(--shadow-btn);
|
||||
--shadow-inset-field: var(--shadow-inset-field);
|
||||
--shadow-card: var(--shadow-card);
|
||||
--shadow-raised: var(--shadow-raised);
|
||||
--shadow-overlay: var(--shadow-overlay);
|
||||
|
||||
/* type 沿用 globals.css 中的 --font-sans / --font-mono,不覆盖 */
|
||||
|
||||
/* radii — 规范统一 5px;rounded-full 仅限 icon 圆钮/状态点/头像 */
|
||||
--radius-chip: 5px;
|
||||
--radius-control: 5px;
|
||||
--radius-card: 5px;
|
||||
--radius-window: 5px;
|
||||
|
||||
/* easing — 规范统一 ease;保留旧变量名以兼容 primitives */
|
||||
--ease-out-strong: ease;
|
||||
--ease-in-out-strong: ease;
|
||||
--ease-link: ease;
|
||||
|
||||
/* motion — Tailwind transition-* 工具类的全局默认:400ms + ease */
|
||||
--default-transition-duration: 400ms;
|
||||
--default-transition-timing-function: ease;
|
||||
}
|
||||
|
||||
/* ── base ─────────────────────────────────────────────── */
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Tailwind v4 resets buttons to cursor:default — anything
|
||||
* clickable must invite the click */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
label:has(input),
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[contenteditable]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.animated-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.animated-underline::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
a:hover .animated-underline::after,
|
||||
a:focus-visible .animated-underline::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* ── primitive spacing system ─────────────────────────────
|
||||
* 8px 网格上的共享间距。
|
||||
*/
|
||||
|
||||
.primitive-card-pad {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.primitive-card-bar {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.primitive-card-footer {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.primitive-table-cell {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.primitive-icon-button {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
/* ── sidebar nav(beautifului.dev Sidebar Nav,官方组件级 CSS)──
|
||||
* 收起态靠行宽切换保证图标对齐:.sidebar-row 固定 208px,
|
||||
* data-sidebar-collapsed 时收为 36px(size-5 图标 + px-2 ×2),
|
||||
* 恰好落在 52px 轨道正中;glide 高亮层同步收窄。
|
||||
* --sidebar-copy-duration / --sidebar-copy-offset / --sidebar-easing
|
||||
* 由 <aside data-sidebar-collapsed> 上注入。
|
||||
*/
|
||||
.sidebar-row {
|
||||
width: 208px;
|
||||
transition-duration: var(--sidebar-copy-duration, 180ms);
|
||||
transition-timing-function: var(--sidebar-easing, ease);
|
||||
}
|
||||
|
||||
.sidebar-copy {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
opacity var(--sidebar-copy-duration, 180ms) ease-out,
|
||||
transform var(--sidebar-copy-duration, 180ms) var(--sidebar-easing, ease);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-collapse-control,
|
||||
.sidebar-expand-control {
|
||||
transition-duration: var(--sidebar-copy-duration, 180ms);
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.sidebar-expand-control {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-glide-highlight {
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed='true'] .sidebar-row {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed='true'] .sidebar-copy {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--sidebar-copy-offset, 8px) * -1));
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed='true'] .sidebar-workspace-control {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed='true'] .sidebar-logo,
|
||||
[data-sidebar-collapsed='true'] .sidebar-collapse-control {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed='true'] .sidebar-expand-control {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed='true'] .sidebar-glide-highlight {
|
||||
right: auto;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
/* ── shared keyframes ─────────────────────────────────── */
|
||||
|
||||
@keyframes shimmer-text {
|
||||
from {
|
||||
background-position: 150% center;
|
||||
}
|
||||
to {
|
||||
background-position: -50% center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* pixel-grid loader — brighten plateau, staggered per cell */
|
||||
@keyframes pixel-on {
|
||||
0%, 100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
18%, 42% {
|
||||
opacity: 1;
|
||||
}
|
||||
62% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "./beautifului.css";
|
||||
|
||||
@theme {
|
||||
--font-sans: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
@@ -14,7 +15,7 @@ body,
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(113, 113, 122, 0.35) transparent;
|
||||
scrollbar-color: rgba(142, 142, 160, 0.35) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
@@ -23,12 +24,12 @@ body,
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(113, 113, 122, 0.35);
|
||||
background: rgba(142, 142, 160, 0.35);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(113, 113, 122, 0.55);
|
||||
background: rgba(142, 142, 160, 0.55);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
@@ -36,7 +37,7 @@ body,
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(16, 185, 129, 0.25);
|
||||
background: rgba(142, 142, 160, 0.28);
|
||||
}
|
||||
|
||||
/* 流式输出光标 */
|
||||
@@ -57,37 +58,11 @@ body,
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: rgb(16 185 129);
|
||||
background: var(--ink);
|
||||
border-radius: 1px;
|
||||
animation: caret-blink 0.9s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* 思考中指示 */
|
||||
@keyframes thinking-dot {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-dot {
|
||||
animation: thinking-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.thinking-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.thinking-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
/* 禁用 textarea 的默认 resize 与滚动条闪烁 */
|
||||
textarea {
|
||||
resize: none;
|
||||
|
||||
@@ -15,6 +15,16 @@ export interface SessionState {
|
||||
streaming: boolean
|
||||
activeTools: string[]
|
||||
messageCount: number
|
||||
/** 当前工作区目录(agent cwd) */
|
||||
workspace?: string
|
||||
/** 权限模式(Composer 权限 pill / 审批行为) */
|
||||
permissionMode?: PermissionMode
|
||||
}
|
||||
|
||||
/** 工作区 = 一个本地目录(agent 的工作目录,会话按目录隔离) */
|
||||
export interface WorkspaceMeta {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface SessionStatsInfo {
|
||||
@@ -61,8 +71,17 @@ export interface ModelCatalog {
|
||||
export interface AppSettings {
|
||||
/** 覆盖 pi 默认系统提示词(空串 = 不覆盖) */
|
||||
systemPrompt: string
|
||||
/** 当前工作区目录(缺省 = 默认工作区 Default) */
|
||||
workspace?: string
|
||||
/** 通过目录选择器添加的根目录之外的工作区 */
|
||||
customWorkspaces?: string[]
|
||||
/** 权限模式(缺省 confirm) */
|
||||
permissionMode?: PermissionMode
|
||||
}
|
||||
|
||||
/** 权限模式:confirm 变更前确认 / autoEdit 自动编辑 / plan 计划模式 / full 完全访问 */
|
||||
export type PermissionMode = 'confirm' | 'autoEdit' | 'plan' | 'full'
|
||||
|
||||
/** 审批请求(主进程 → 渲染层 Approval Card) */
|
||||
export interface ApprovalRequest {
|
||||
id: string
|
||||
@@ -70,6 +89,45 @@ export interface ApprovalRequest {
|
||||
args: unknown
|
||||
}
|
||||
|
||||
/** Chatbox "/" 命令面板条目:local = 应用内动作,prompt = pi 文件命令模板(发送时由 SDK 展开) */
|
||||
export interface ChatCommandInfo {
|
||||
name: string
|
||||
description: string
|
||||
kind: 'local' | 'prompt'
|
||||
}
|
||||
|
||||
/** 技能(发送 "/skill:<name> args" 由 pi 展开为技能内容) */
|
||||
export interface ChatSkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ChatResources {
|
||||
commands: ChatCommandInfo[]
|
||||
skills: ChatSkillInfo[]
|
||||
}
|
||||
|
||||
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
|
||||
export interface WorkspaceFile {
|
||||
path: string
|
||||
name: string
|
||||
isDir: boolean
|
||||
}
|
||||
|
||||
/** 附件:图片带 base64(真实 ImageContent),其他文件仅路径(发送时以 @path 引用) */
|
||||
export interface AttachmentInfo {
|
||||
name: string
|
||||
path: string
|
||||
mimeType: string
|
||||
data: string
|
||||
}
|
||||
|
||||
/** 发送图片附件(pi ImageContent 的可克隆子集) */
|
||||
export interface SendImage {
|
||||
mimeType: string
|
||||
data: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发到渲染进程的 agent 事件。
|
||||
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"useDefineForClassFields": true,
|
||||
"outDir": "out/tsconfig.web",
|
||||
"paths": {
|
||||
"@/*": ["./src/renderer/src/*"],
|
||||
"@renderer/*": ["./src/renderer/src/*"],
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// 预览页构建配置(独立于 electron-vite;仅用于视觉验收 beautifului 组件)
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
root: 'preview',
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src/renderer/src'),
|
||||
'@renderer': resolve(__dirname, 'src/renderer/src'),
|
||||
'@shared': resolve(__dirname, 'src/shared')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: '../out/preview',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user