Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb5dbe73e | ||
|
|
9c87953877 | ||
|
|
a3c556cd0a | ||
|
|
b2fbcf72bc | ||
|
|
59ffe044b5 | ||
|
|
01672deec5 |
+3
-1
@@ -3,7 +3,7 @@ import { auth } from './auth'
|
||||
import { api } from './api'
|
||||
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
|
||||
import { AdminUsersModal, ChangePasswordModal } from './components/AdminUsersModal'
|
||||
import { Icon } from './icons'
|
||||
import { Icon } from '@ui'
|
||||
import { store, useStore } from './store'
|
||||
import { TaskSidebar } from './components/TaskSidebar'
|
||||
import { Conversation } from './components/Conversation'
|
||||
@@ -11,6 +11,7 @@ import { Workbench } from './components/Workbench'
|
||||
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
|
||||
import { AgentTracker } from './components/AgentTracker'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||||
import { DocViewer } from './components/DocViewer'
|
||||
|
||||
// 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。
|
||||
@@ -146,6 +147,7 @@ export function App() {
|
||||
<Workspaces />
|
||||
{trackerRunId && <AgentTracker />}
|
||||
{ui.settingsOpen && <SettingsModal />}
|
||||
{ui.projectSettingsId && <ProjectSettingsModal />}
|
||||
{ui.adminUsersOpen && <AdminUsersModal onClose={() => store.setUI({ adminUsersOpen: false })} />}
|
||||
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
|
||||
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
|
||||
|
||||
+7
-1
@@ -26,7 +26,7 @@ export const api = {
|
||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
||||
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||||
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
|
||||
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
@@ -78,7 +78,13 @@ export const api = {
|
||||
},
|
||||
listArtifacts: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json<Artifact[]>(r)),
|
||||
artifactContentUrl: (id: string) => auth.withKey(`${BASE}/api/artifacts/${id}/content`),
|
||||
// 下载直链:后端按 ?download=1 回 Content-Disposition: attachment(RFC 5987 文件名)。
|
||||
artifactDownloadUrl: (id: string) => {
|
||||
const url = auth.withKey(`${BASE}/api/artifacts/${id}/content`)
|
||||
return url.includes('?') ? `${url}&download=1` : `${url}?download=1`
|
||||
},
|
||||
saveArtifactContent: (id: string, content: string) => auth.fetch(`${BASE}/api/artifacts/${id}/content`, { method: 'PUT', body: content }),
|
||||
deleteArtifact: (id: string) => auth.fetch(`${BASE}/api/artifacts/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getPlan: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/plan`).then(r => r.ok ? r.json() : null) as Promise<PlanSnapshot | null>,
|
||||
getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon } from '../icons'
|
||||
import { Icon, Avatar } from '@ui'
|
||||
import { randomPassword } from '../passwords'
|
||||
|
||||
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
|
||||
@@ -100,7 +100,7 @@ export function AdminUsersModal({ onClose }: { onClose: () => void }) {
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-mono">{u.owner}</td>
|
||||
<td className="ad-user-cell"><Avatar name={u.name || u.owner} size="sm" /><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
|
||||
import { Icon } from '../icons'
|
||||
import { Icon, Avatar, BrandMark } from '@ui'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
@@ -7,6 +7,7 @@ import { store, useStore, activeRun, type LiveState, type ContextObs } from '../
|
||||
import type { Message, Part, HITLCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { groupParts, artifactIcon } from '../messageparts'
|
||||
|
||||
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
|
||||
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
|
||||
@@ -33,11 +34,21 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
{part.toolCall?.name ?? part.toolResult?.callId} <span className={`status-${st}`}>{st === 'ok' ? '✓' : '✗'}</span>
|
||||
</span>
|
||||
}
|
||||
case 'artifact_ref':
|
||||
return <span className="artifact-chip" onClick={() => {
|
||||
const art = findArtifact(part.artifact!.artifactId)
|
||||
if (art) store.viewArtifact(art)
|
||||
}}>📄 {part.artifact?.name}</span>
|
||||
case 'artifact_ref': {
|
||||
const a = part.artifact
|
||||
const art = a ? findArtifact(a.artifactId) : undefined
|
||||
const name = a?.name ?? '工件'
|
||||
const icon = artifactIcon(name, a?.kind ?? art?.kind)
|
||||
return (
|
||||
<span className="artifact-chip" data-artifact-chip={a?.artifactId}>
|
||||
<button className="ac-main" title={`打开 ${name}`} onClick={() => { if (art) store.viewArtifact(art) }}>
|
||||
<Icon name={icon} size={14} /> {name}
|
||||
</button>
|
||||
<a className="ac-act" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
|
||||
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
case 'hitl':
|
||||
return <div className="tool-chip"><Icon name="message" size={12} /> {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
|
||||
case 'plan':
|
||||
@@ -86,44 +97,44 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
|
||||
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
||||
const parts = msg.parts ?? []
|
||||
const split = useMemo(() => {
|
||||
const out: { process: Part[]; content: Part[] } = { process: [], content: [] }
|
||||
for (const p of parts) (p.process ? out.process : out.content).push(p)
|
||||
return out
|
||||
}, [parts])
|
||||
// 顺序保真(渲染顺序 = 落库顺序):连续过程部件就地聚块,内容部件原位保留——
|
||||
// 工具调用显示在它真正发生的位置,不被抽到消息头部重排。
|
||||
const blocks = useMemo(() => groupParts(parts), [parts])
|
||||
|
||||
// 流式中:过程部件实时展开;run 终态后:按偏好折叠为摘要行(可回看)。
|
||||
const showProcessInline = live || !autoCollapse
|
||||
const lastContentIdx = (() => {
|
||||
for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].kind === 'content') return i
|
||||
return -1
|
||||
})()
|
||||
return (
|
||||
<div className="msg assistant">
|
||||
<div className="msg-role">
|
||||
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
||||
<span>助手</span>
|
||||
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
|
||||
</div>
|
||||
{split.process.length > 0 && (showProcessInline
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>)
|
||||
: <ProcessCollapse parts={split.process} />)}
|
||||
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)}
|
||||
{blocks.map((b, i) => b.kind === 'process'
|
||||
? (showProcessInline
|
||||
? <div className="part-group" key={i}>{b.parts.map((p, j) => <div className="part" key={j}><PartView part={p} live={!!live} /></div>)}</div>
|
||||
: <ProcessCollapse key={i} parts={b.parts} />)
|
||||
: <div className="part" key={i}>
|
||||
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
|
||||
</div>)}
|
||||
{live && <LiveAgentNotes live={live} />}
|
||||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息
|
||||
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);run 终态对账后由真消息替代。
|
||||
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息。
|
||||
// LiveState.parts 已按事件到达序维护(store.applyEvent),直接透传即保序。
|
||||
function liveToMessage(live: LiveState): Message {
|
||||
const parts: Part[] = []
|
||||
if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true })
|
||||
for (const tc of live.toolCalls) {
|
||||
parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } })
|
||||
if (tc.status !== undefined) {
|
||||
parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name },
|
||||
toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } })
|
||||
}
|
||||
if (live.parts.length === 0) {
|
||||
// 兜底(无事件到达的短暂窗口):只给 phase 占位,避免空白。
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts: [], createdAt: '' }
|
||||
}
|
||||
if (live.text) parts.push({ type: 'text', text: live.text })
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts: live.parts, createdAt: '' }
|
||||
}
|
||||
|
||||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||||
@@ -216,6 +227,7 @@ export function Conversation() {
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
|
||||
const messages = useStore(s => s.messages)
|
||||
const myName = useStore(s => s.me?.name || s.me?.owner || '你')
|
||||
const activeHitl = useStore(s => s.activeHitl)
|
||||
const live = useStore(s => s.live)
|
||||
const runs = useStore(s => s.runs)
|
||||
@@ -270,6 +282,13 @@ export function Conversation() {
|
||||
}}>
|
||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
title={conversation?.allowCrossRead === false
|
||||
? '本会话不读取其他会话记录(点击开启)'
|
||||
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
||||
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||||
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
|
||||
</button>
|
||||
<span title={connected ? '事件流已连接' : '事件流重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
|
||||
</div>
|
||||
@@ -290,7 +309,9 @@ export function Conversation() {
|
||||
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
|
||||
return (
|
||||
<div className="msg user" key={m.id}>
|
||||
<div className="msg-role">你</div>
|
||||
<div className="msg-role">
|
||||
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
|
||||
</div>
|
||||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||||
{anchored.map(c => (
|
||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// 右栏文档查看器(4.4):markdown 渲染 / 文本编辑保存 / 图片预览 / 其他下载。
|
||||
// 文档查看器(4.4 → 工作区文件管理升级):单层 OverlayHost——markdown 渲染 /
|
||||
// 文本编辑保存 / 图片与 pdf 预览 / csv·json 等宽 / 二进制信息卡 + 下载。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
import { store } from '../store'
|
||||
import { OverlayHost } from '@ui'
|
||||
import { OverlayHost, Icon, Btn, EmptyState } from '@ui'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
|
||||
export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
@@ -16,35 +18,49 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
|
||||
}, [artifact.id])
|
||||
|
||||
const isPdf = /\.pdf$/i.test(artifact.name)
|
||||
const isCsv = /\.csv$/i.test(artifact.name)
|
||||
const isJson = /\.json$/i.test(artifact.name)
|
||||
|
||||
return (
|
||||
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
📄 {artifact.name}
|
||||
<div className="docv">
|
||||
<div className="docv-toolbar">
|
||||
<span className="docv-meta">{artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''}</span>
|
||||
<span className="spacer" />
|
||||
{dirty && <span style={{ color: 'var(--warn)', fontSize: 12, marginRight: 8 }}>未保存</span>}
|
||||
{isTextKind(artifact) && !isBinaryKind(artifact) && (
|
||||
{dirty && <span className="docv-dirty">未保存</span>}
|
||||
{isTextKind(artifact) && (
|
||||
<>
|
||||
<button className="btn ghost" onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</button>
|
||||
{editing && <button className="btn" disabled={!dirty} onClick={async () => {
|
||||
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||||
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||||
await api.saveArtifactContent(artifact.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
}}>保存</button>
|
||||
}
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
<a className="btn ghost" style={{ textDecoration: 'none' }} href={api.artifactContentUrl(artifact.id)} download={artifact.name}>下载</a>
|
||||
<button className="btn ghost" onClick={() => store.viewArtifact(null)}>关闭</button>
|
||||
<a className="btn v-ghost docv-dl" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
|
||||
<Icon name="download" size={14} /> 下载
|
||||
</a>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="docv-body">
|
||||
{artifact.kind === 'image' ? (
|
||||
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%' }} />
|
||||
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%', borderRadius: 8 }} />
|
||||
) : isPdf ? (
|
||||
<iframe src={api.artifactContentUrl(artifact.id)} title={artifact.name} className="docv-pdf" />
|
||||
) : isBinaryKind(artifact) ? (
|
||||
<div style={{ color: 'var(--text-faint)' }}>二进制工件——请下载查看(docx 深度预览为远期项;md/txt 可在线查看)</div>
|
||||
<EmptyState icon={artifactIcon(artifact.name, artifact.kind)}>
|
||||
<b>{artifact.name}</b>
|
||||
<div>{humanSize(artifact.size) || '二进制文件'} · 浏览器不支持此格式的在线预览</div>
|
||||
<a className="btn v-primary" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
|
||||
<Icon name="download" size={14} /> 下载文件
|
||||
</a>
|
||||
</EmptyState>
|
||||
) : editing ? (
|
||||
<textarea className="search-input" style={{ height: '60vh', fontFamily: 'var(--mono)', fontSize: 12.5 }}
|
||||
value={content ?? ''} onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
<textarea className="search-input docv-editor" value={content ?? ''}
|
||||
onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
) : isCsv || isJson ? (
|
||||
<pre className="docv-pre">{content ?? '(加载中…'}</pre>
|
||||
) : (
|
||||
<div className="doc-viewer" dangerouslySetInnerHTML={{ __html: marked.parse(content ?? '(加载中…') as string }} />
|
||||
)}
|
||||
@@ -59,5 +75,5 @@ function isTextKind(a: Artifact): boolean {
|
||||
}
|
||||
|
||||
function isBinaryKind(a: Artifact): boolean {
|
||||
return /\.(docx|pdf|xlsx|pptx|png|jpg|jpeg|gif|webp)$/i.test(a.name)
|
||||
return /\.(docx|pdf|xlsx|pptx|doc|xls|ppt|zip|png|jpg|jpeg|gif|webp)$/i.test(a.name)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { useState } from 'react'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { Icon, BrandMark } from '../icons'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
|
||||
function Err({ msg }: { msg: string }) {
|
||||
if (!msg) return null
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
|
||||
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
|
||||
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
||||
|
||||
export function ProjectSettingsModal() {
|
||||
const projectId = useStore(s => s.ui.projectSettingsId)
|
||||
const projects = useStore(s => s.projects)
|
||||
const project = projects.find(p => p.id === projectId)
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
setLoaded(false)
|
||||
api.getSettings(projectId).then(s => {
|
||||
setSchema(s.schema)
|
||||
setDraft(flatten(s.project))
|
||||
setLoaded(true)
|
||||
}).catch(() => undefined)
|
||||
}, [projectId])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g: Record<string, SettingDesc[]> = {}
|
||||
for (const d of schema) (g[d.group] ??= []).push(d)
|
||||
return g
|
||||
}, [schema])
|
||||
|
||||
if (!projectId) return null
|
||||
|
||||
const save = async () => {
|
||||
if (!projectId) return
|
||||
try {
|
||||
await api.putProjectSettings(projectId, unflatten(draft))
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 1500)
|
||||
} catch (e) {
|
||||
store.toast(`项目设置保存失败:${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title={`项目设置 · ${project?.name ?? projectId}`}
|
||||
active="project"
|
||||
onSelect={() => undefined}
|
||||
onClose={() => store.openProjectSettings(null)}
|
||||
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||
>
|
||||
<div>
|
||||
{!loaded ? (
|
||||
<EmptyState icon="settings">加载中…</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||||
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
|
||||
</div>
|
||||
{Object.entries(groups).map(([g, descs]) => (
|
||||
<div className="settings-group" key={g}>
|
||||
<h4>{groupNames[g] ?? g}</h4>
|
||||
{descs.map(d => (
|
||||
<div className="setting-row" key={d.key}>
|
||||
<label title={d.key}>{d.label}</label>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
|
||||
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsShell>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
// 设置中心(4.9):schema 驱动渲染 + 生效时机标注 + 密钥掩码 + 插件管理 + 画像管理。
|
||||
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
|
||||
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile, SettingDesc } from '../contracts'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn, EmptyState } from '@ui'
|
||||
|
||||
type Section = 'settings' | 'profiles' | 'plugins'
|
||||
|
||||
// 分组显示名(项目设置面板共用)。
|
||||
export const groupNames: Record<string, string> = {
|
||||
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
|
||||
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
|
||||
}
|
||||
|
||||
export function SettingsModal() {
|
||||
const profiles = useStore(s => s.profiles)
|
||||
const [section, setSection] = useState<Section>('settings')
|
||||
@@ -28,11 +36,6 @@ export function SettingsModal() {
|
||||
return g
|
||||
}, [schema])
|
||||
|
||||
const groupNames: Record<string, string> = {
|
||||
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
|
||||
ui: '界面', plugins: '插件目录',
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const values = unflatten(draft)
|
||||
await api.putUserSettings(values)
|
||||
@@ -45,50 +48,38 @@ export function SettingsModal() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.openSettings(false) }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
⚙ 设置
|
||||
<div className="tabs" style={{ margin: '0 16px', borderBottom: 'none' }}>
|
||||
{(['settings', 'profiles', 'plugins'] as Section[]).map(s => (
|
||||
<button key={s} className={`tab ${section === s ? 'active' : ''}`} onClick={() => setSection(s)}>
|
||||
{s === 'settings' ? '设置' : s === 'profiles' ? '智能体画像' : '插件'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
{saved && <span style={{ color: 'var(--ok)', marginRight: 10, fontSize: 12.5 }}>已保存</span>}
|
||||
<button className="btn ghost" onClick={() => store.openSettings(false)}>关闭</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{section === 'settings' && (
|
||||
<>
|
||||
{Object.entries(groups).map(([g, descs]) => (
|
||||
<div className="settings-group" key={g}>
|
||||
<h4>{groupNames[g] ?? g}</h4>
|
||||
{descs.map(d => (
|
||||
<div className="setting-row" key={d.key}>
|
||||
<label title={d.key}>{d.label}</label>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
|
||||
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={save}>保存设置</button>
|
||||
</>
|
||||
)}
|
||||
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
|
||||
{section === 'plugins' && <PluginsSection />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsShell
|
||||
title="设置" active={section} onSelect={k => setSection(k as Section)}
|
||||
onClose={() => store.openSettings(false)}
|
||||
sections={[
|
||||
{ key: 'settings', label: '常规', icon: 'settings' },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', badge: profiles.length || undefined },
|
||||
{ key: 'plugins', label: '插件', icon: 'puzzle' },
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
||||
actions={section === 'settings' && schema.length > 0 ? (
|
||||
<>
|
||||
{saved && <span style={{ color: 'var(--ok)', fontSize: 12.5, marginRight: 8 }}>✓ 已保存</span>}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
{section === 'settings' && Object.entries(groups).map(([g, descs]) => (
|
||||
<SettingsGroup key={g} title={groupNames[g] ?? g}>
|
||||
{descs.map(d => (
|
||||
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
))}
|
||||
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
|
||||
{section === 'plugins' && <PluginsSection />}
|
||||
</SettingsShell>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
||||
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
||||
if (desc.type === 'boolean') {
|
||||
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||||
}
|
||||
@@ -111,12 +102,12 @@ function flat(m: Record<string, any>, prefix: string, out: Record<string, any>)
|
||||
else out[key] = v
|
||||
}
|
||||
}
|
||||
function flatten(m: Record<string, any>): Record<string, any> {
|
||||
export function flatten(m: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
flat(m, '', out)
|
||||
return out
|
||||
}
|
||||
function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
||||
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(flatMap)) {
|
||||
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
|
||||
@@ -135,7 +126,7 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="doc-toolbar">
|
||||
<button className="btn" onClick={() => { setEditing(newProfile()); setText('') }}>+ 新建画像</button>
|
||||
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
|
||||
<label className="btn ghost" style={{ cursor: 'pointer' }}>
|
||||
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
|
||||
const f = e.target.files?.[0]
|
||||
@@ -152,25 +143,22 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||||
</label>
|
||||
</div>
|
||||
{profiles.map(p => (
|
||||
<div className="setting-row" key={p.id}>
|
||||
<label>
|
||||
{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span>
|
||||
</label>
|
||||
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>{p.description}</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="btn ghost" onClick={() => {
|
||||
<SettingRow key={p.id}
|
||||
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
|
||||
desc={p.description}>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<Btn variant="ghost" icon="download" onClick={() => {
|
||||
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
|
||||
}}>导出</button>
|
||||
{!p.builtin && <button className="btn ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</button>}
|
||||
{!p.builtin && <button className="btn ghost" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</button>}
|
||||
}}>导出</Btn>
|
||||
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
|
||||
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
))}
|
||||
{editing && (
|
||||
<div className="settings-group" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<h4>编辑画像:{editing.name}</h4>
|
||||
<SettingsGroup title={`编辑画像:${editing.name}`}>
|
||||
<div className="ui-form">
|
||||
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
|
||||
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
|
||||
@@ -178,15 +166,15 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||||
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||
</div>
|
||||
<button className="btn" onClick={async () => {
|
||||
<Btn variant="primary" icon="check" onClick={async () => {
|
||||
try {
|
||||
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
|
||||
await store.refreshProfiles()
|
||||
setEditing(null)
|
||||
store.toast('画像已保存')
|
||||
} catch (e) { store.toast(`保存失败:${e}`) }
|
||||
}}>保存画像</button>
|
||||
</div>
|
||||
}}>保存画像</Btn>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -207,23 +195,21 @@ function PluginsSection() {
|
||||
return (
|
||||
<div>
|
||||
<div className="doc-toolbar">
|
||||
<button className="btn" onClick={async () => {
|
||||
<Btn variant="ghost" icon="undo" onClick={async () => {
|
||||
await api.reloadPlugins()
|
||||
await refresh()
|
||||
store.refreshPlugins()
|
||||
store.toast('插件已热重载')
|
||||
}}>⟳ 热重载</button>
|
||||
}}>热重载</Btn>
|
||||
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
|
||||
</div>
|
||||
{plugins.length === 0 && <div style={{ color: 'var(--text-faint)' }}>暂无已装载插件(目录:设置 → 插件目录;示例插件见 Dev 仓 plugins/example)</div>}
|
||||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(目录:常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||||
{plugins.map(p => (
|
||||
<div className="setting-row" key={p.manifest.id}>
|
||||
<label>{p.manifest.name} <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>v{p.manifest.version}</span></label>
|
||||
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>
|
||||
{[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}
|
||||
</div>
|
||||
<SettingRow key={p.manifest.id}
|
||||
label={`${p.manifest.name} v${p.manifest.version}`}
|
||||
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { Icon, BrandMark, workspaceIcon } from '../icons'
|
||||
import { Icon, BrandMark, workspaceIcon } from '@ui'
|
||||
import type { Conversation } from '../contracts'
|
||||
import { workspaceEntries } from './Workspaces'
|
||||
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
|
||||
@@ -93,6 +93,10 @@ export function TaskSidebar() {
|
||||
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||||
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
||||
</button>
|
||||
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
|
||||
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
|
||||
<Icon name="shield" size={13} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -101,6 +105,7 @@ export function TaskSidebar() {
|
||||
onClick={() => store.openTask(t.id)}
|
||||
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
||||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
|
||||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||
{badge && <span className="tsb-pbadge">{badge}</span>}
|
||||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||
{rowActions(t)}
|
||||
@@ -176,6 +181,7 @@ export function TaskSidebar() {
|
||||
<span className="tsb-group-name" title={g.label}>{g.label}</span>
|
||||
<span className="tsb-group-cnt">{g.tasks.length}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="tsb-group-add" title="项目设置(含跨会话读取)" onClick={e => { e.stopPropagation(); if (g.projectId) store.openProjectSettings(g.projectId) }}><Icon name="settings" size={13} /></button>
|
||||
<button className="tsb-group-add" title="在此项目新建任务" onClick={e => { e.stopPropagation(); store.createTask(g.projectId) }}><Icon name="plus" size={13} /></button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -224,6 +230,9 @@ export function TaskSidebar() {
|
||||
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
||||
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
|
||||
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="clock" onClick={() => { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看</MenuItem>
|
||||
</>}
|
||||
</Menu>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
|
||||
import { Icon } from '../icons'
|
||||
import { Icon } from '@ui'
|
||||
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
export const docBridge: DocBridge = {
|
||||
@@ -29,7 +29,7 @@ export function Workbench() {
|
||||
</div>
|
||||
<div className="tab-body">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
<InboxList cards={cards} callbacks={{
|
||||
<InboxList cards={cards} docBridge={docBridge} callbacks={{
|
||||
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
|
||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现;
|
||||
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { OverlayHost, type DocBridge } from '@ui'
|
||||
import type { Run, Artifact } from '../contracts'
|
||||
import { api } from '../api'
|
||||
import { OverlayHost, Icon, type DocBridge } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
|
||||
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区随后。 */
|
||||
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
|
||||
@@ -52,17 +55,76 @@ export function Workspaces() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 工件面板(自 Workbench tabs 迁入;点击进 DocViewer 深读)。 */
|
||||
/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内预览/下载/删除(带确认)。 */
|
||||
export function ArtifactsPanel() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
if (artifacts.length === 0) return <div className="empty-note">暂无工件——上传附件或让模型产出文档(mock:#doc)</div>
|
||||
return <>{artifacts.map(a => (
|
||||
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
|
||||
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
|
||||
<span className="name" title={a.name}>{a.name}</span>
|
||||
<span className="kind">{a.kind}</span>
|
||||
const [q, setQ] = useState('')
|
||||
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
|
||||
const [confirmDel, setConfirmDel] = useState<string | null>(null)
|
||||
|
||||
const kinds = ['all', 'document', 'data', 'image'] as const
|
||||
const visible = artifacts.filter(a =>
|
||||
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
|
||||
|
||||
const del = async (a: Artifact) => {
|
||||
try {
|
||||
await api.deleteArtifact(a.id)
|
||||
if (store.getState().ui.viewingArtifact?.id === a.id) store.viewArtifact(null)
|
||||
store.toast(`已删除 ${a.name}`)
|
||||
} catch (e) { store.toast(`删除失败:${e}`) }
|
||||
if (store.getState().currentProjectId) store.refreshArtifacts(store.getState().currentProjectId!)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="artifacts-panel">
|
||||
<div className="ap-toolbar">
|
||||
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
{kinds.map(k => (
|
||||
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
|
||||
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
|
||||
</button>
|
||||
))}
|
||||
<label className="btn ghost ap-upload" title="上传文件到当前项目">
|
||||
<Icon name="paperclip" size={14} /> 上传
|
||||
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||||
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载与删除</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}>
|
||||
<span className="ar-icon" onClick={() => store.viewArtifact(a)}><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main" onClick={() => store.viewArtifact(a)} title={a.name}>
|
||||
<span className="name">{a.name}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="预览" onClick={() => store.viewArtifact(a)}><Icon name="eye" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
{confirmDel === a.id
|
||||
? <button className="icon-btn danger" title="确认删除" onClick={() => { setConfirmDel(null); del(a) }}><Icon name="check" size={14} /></button>
|
||||
: <button className="icon-btn" title="删除" onClick={() => { setConfirmDel(a.id); setTimeout(() => setConfirmDel(c => c === a.id ? null : c), 2500) }}><Icon name="trash" size={14} /></button>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}</>
|
||||
)
|
||||
}
|
||||
|
||||
function shortTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
return d.toDateString() === now.toDateString()
|
||||
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
|
||||
|
||||
+3
-1
@@ -15,6 +15,7 @@ export interface Project {
|
||||
export interface Conversation {
|
||||
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
|
||||
pinned?: boolean; archived?: boolean;
|
||||
allowCrossRead?: boolean; crossPrivate?: boolean;
|
||||
createdAt: string; updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -153,7 +154,7 @@ export interface HITLResponse {
|
||||
|
||||
export type UINodeType =
|
||||
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
|
||||
| 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
|
||||
| 'avatar' | 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
|
||||
|
||||
export interface UINode {
|
||||
type: UINodeType;
|
||||
@@ -167,6 +168,7 @@ export interface UINode {
|
||||
chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
|
||||
children?: UINode[];
|
||||
|
||||
avatar?: { name?: string; sub?: string; tone?: string; stack?: { name?: string; sub?: string; tone?: string }[] };
|
||||
progress?: { label?: string; value: number; tone?: string };
|
||||
compare?: {
|
||||
left: { title?: string; tone?: string; nodes?: UINode[] };
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// 线性图标集(R4 视觉收敛;Lucide 风格:16px 画布、1.6 描边、圆角端点)。
|
||||
// 统一替换此前散落各处的 emoji/字符图标(emoji 跨平台渲染不一致且观感廉价)。
|
||||
import type { ButtonHTMLAttributes, ReactNode, SVGProps } from 'react'
|
||||
|
||||
export type IconName =
|
||||
| 'plus' | 'search' | 'panel-left' | 'inbox' | 'settings' | 'sun' | 'moon'
|
||||
| 'pin' | 'archive' | 'undo' | 'chevron-right' | 'chevron-down'
|
||||
| 'folder' | 'message' | 'package' | 'bot' | 'puzzle' | 'grid'
|
||||
| 'arrow-up' | 'paperclip' | 'more' | 'logout' | 'key' | 'user' | 'users'
|
||||
| 'shield' | 'x' | 'check' | 'alert' | 'send' | 'sparkles' | 'clock' | 'list'
|
||||
|
||||
const PATHS: Record<IconName, ReactNode> = {
|
||||
plus: <path d="M8 3.5v9M3.5 8h9" />,
|
||||
search: <><circle cx="7" cy="7" r="4.2" /><path d="m13.2 13.2-2.9-2.9" /></>,
|
||||
'panel-left': <><rect x="1.8" y="2.8" width="12.4" height="10.4" rx="2" /><path d="M6 2.8v10.4" /></>,
|
||||
inbox: <><path d="M1.8 9.4 3.6 3.6a1.6 1.6 0 0 1 1.5-1h5.8a1.6 1.6 0 0 1 1.5 1l1.8 5.8" /><path d="M1.8 9.4h3.3l.9 1.8h4l.9-1.8h3.3v2.9a1.5 1.5 0 0 1-1.5 1.5H3.3a1.5 1.5 0 0 1-1.5-1.5Z" /></>,
|
||||
settings: <><circle cx="8" cy="8" r="2.1" /><path d="M12.9 6.9a1 1 0 0 0 .2 1.1l.1.1a1.2 1.2 0 1 1-1.7 1.7l-.1-.1a1 1 0 0 0-1.7.7v.1a1.2 1.2 0 1 1-2.4 0v-.1a1 1 0 0 0-1.7-.7l-.1.1a1.2 1.2 0 1 1-1.7-1.7l.1-.1a1 1 0 0 0 .2-1.1 1 1 0 0 0-.6-.6H3.5a1.2 1.2 0 1 1 0-2.4h.1a1 1 0 0 0 .7-1.7l-.1-.1a1.2 1.2 0 1 1 1.7-1.7l.1.1a1 1 0 0 0 1.1.2h.1a1 1 0 0 0 .6-.6V1.9a1.2 1.2 0 1 1 2.4 0v.1a1 1 0 0 0 1.7.7l.1-.1a1.2 1.2 0 1 1 1.7 1.7l-.1.1a1 1 0 0 0-.2 1.1v.1a1 1 0 0 0 .6.6h.1a1.2 1.2 0 1 1 0 2.4h-.1a1 1 0 0 0-.6.6Z" /></>,
|
||||
sun: <><circle cx="8" cy="8" r="3" /><path d="M8 1.3v1.4M8 13.3v1.4M1.3 8h1.4M13.3 8h1.4M3.3 3.3l1 1M11.7 11.7l1 1M12.7 3.3l-1 1M4.3 11.7l-1 1" /></>,
|
||||
moon: <path d="M13.4 9.6A6 6 0 0 1 6.4 2.6a6 6 0 1 0 7 7Z" />,
|
||||
pin: <path d="M9.3 1.9 14.1 6.7l-1.4 1.4-.7-.2-2.9 2.9.3 1.6-1.2 1.2L6 10.9l-3.3 3.4-1-1L5 9.9 2.4 7.4l1.2-1.2 1.6.3L8.1 3.6l-.2-.7Z" />,
|
||||
archive: <><rect x="1.8" y="2.5" width="12.4" height="3" rx="1" /><path d="M3 5.5v7a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5v-7M6.5 8.8h3" /></>,
|
||||
undo: <><path d="M2.7 6.8h6.5a3.6 3.6 0 1 1 0 7.2H6" /><path d="m5.4 4-2.7 2.8 2.7 2.8" /></>,
|
||||
'chevron-right': <path d="m6 3.5 4.5 4.5L6 12.5" />,
|
||||
'chevron-down': <path d="m3.5 6 4.5 4.5L12.5 6" />,
|
||||
folder: <path d="M1.8 4.8a1.5 1.5 0 0 1 1.5-1.5h3l1.5 1.7h4.9a1.5 1.5 0 0 1 1.5 1.5v5.7a1.5 1.5 0 0 1-1.5 1.5H3.3a1.5 1.5 0 0 1-1.5-1.5Z" />,
|
||||
message: <path d="M14 8.7c0 2.6-2.7 4.7-6 4.7a7 7 0 0 1-2-.3L2.3 14.3l.9-2.8A4.4 4.4 0 0 1 2 8.7c0-2.6 2.7-4.7 6-4.7s6 2.1 6 4.7Z" />,
|
||||
package: <><path d="m8 1.7 5.8 3.1v6.4L8 14.3l-5.8-3.1V4.8Z" /><path d="m2.2 4.8 5.8 3.1 5.8-3.1M8 7.9v6.4" /></>,
|
||||
bot: <><rect x="3.2" y="5.4" width="9.6" height="7" rx="2" /><path d="M8 5.4V3.2M1.7 8.8v1.6M14.3 8.8v1.6" /><path d="M6 8.5v.1M10 8.5v.1" /></>,
|
||||
puzzle: <path d="M9.8 2a1.5 1.5 0 0 1 1.5 1.5v.6h.7A1.5 1.5 0 0 1 13.5 5.6v1.9h-.6a1.5 1.5 0 0 0 0 3h.6v1.9a1.5 1.5 0 0 1-1.5 1.5h-1.9v-.6a1.5 1.5 0 0 0-3 0v.6H5.2a1.5 1.5 0 0 1-1.5-1.5v-1.9H3a1.5 1.5 0 0 1 0-3h.7V5.6A1.5 1.5 0 0 1 5.2 4.1h3.1v-.6A1.5 1.5 0 0 1 9.8 2Z" />,
|
||||
grid: <><rect x="2" y="2" width="5" height="5" rx="1" /><rect x="9" y="2" width="5" height="5" rx="1" /><rect x="2" y="9" width="5" height="5" rx="1" /><rect x="9" y="9" width="5" height="5" rx="1" /></>,
|
||||
'arrow-up': <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" />,
|
||||
paperclip: <path d="m13.1 7.7-5 5a3.7 3.7 0 0 1-5.2-5.2l5.4-5.4a2.4 2.4 0 0 1 3.4 3.4L6.3 10.9a1.2 1.2 0 0 1-1.7-1.7l4.9-4.9" />,
|
||||
more: <><circle cx="3.2" cy="8" r="1.1" fill="currentColor" stroke="none" /><circle cx="8" cy="8" r="1.1" fill="currentColor" stroke="none" /><circle cx="12.8" cy="8" r="1.1" fill="currentColor" stroke="none" /></>,
|
||||
logout: <><path d="M6.2 13.5H3.5a1.5 1.5 0 0 1-1.5-1.5V4a1.5 1.5 0 0 1 1.5-1.5h2.7" /><path d="m9.8 10.7 2.7-2.7-2.7-2.7M12.5 8H6" /></>,
|
||||
key: <><circle cx="5.6" cy="10.4" r="3" /><path d="m7.8 8.2 5-5M11 5l1.6 1.6M9.4 6.6 11 8.2" /></>,
|
||||
user: <><circle cx="8" cy="5.4" r="2.9" /><path d="M2.7 13.8a5.5 5.5 0 0 1 10.6 0" /></>,
|
||||
users: <><circle cx="6.5" cy="5.4" r="2.7" /><path d="M1.8 13.6a4.8 4.8 0 0 1 9.4 0M11 3a2.7 2.7 0 0 1 0 5.2M12.4 10.1a4.8 4.8 0 0 1 1.9 3.5" /></>,
|
||||
shield: <path d="M8 1.8 13 3.6v4.2c0 3.2-2.2 5.4-5 6.4-2.8-1-5-3.2-5-6.4V3.6Z" />,
|
||||
x: <path d="m4 4 8 8M12 4l-8 8" />,
|
||||
check: <path d="m3.2 8.4 3.2 3.2 6.4-6.4" />,
|
||||
alert: <><path d="M8 2 14.3 13.3H1.7Z" /><path d="M8 6.4v3M8 11.4v.1" /></>,
|
||||
send: <path d="M14.3 1.8 1.8 7.1l4.9 1.8 1.8 4.9Z M14.3 1.8 6.7 8.9" />,
|
||||
sparkles: <><path d="m11.3 1.8.9 2.3 2.3.9-2.3.9-.9 2.3-.9-2.3-2.3-.9 2.3-.9Z" /><path d="M5 6.5 5.7 8.3 7.5 9 5.7 9.7 5 11.5 4.3 9.7 2.5 9 4.3 8.3ZM8.4 10.8l.5 1.3 1.3.5-1.3.5-.5 1.3-.5-1.3L6.6 12.6l1.3-.5Z" /></>,
|
||||
clock: <><circle cx="8" cy="8" r="6" /><path d="M8 4.7V8l2.3 1.4" /></>,
|
||||
list: <path d="M5.5 4h8M5.5 8h8M5.5 12h8M2.6 4v.1M2.6 8v.1M2.6 12v.1" />,
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 16, ...rest }: { name: IconName; size?: number } & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={size} height={size} viewBox="0 0 16 16" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true" {...rest}
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type IconButtonProps = {
|
||||
name: IconName; size?: number; label: string
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'>
|
||||
|
||||
/** 通用图标按钮:尺寸/圆角/悬停统一走 .icon-btn 样式。 */
|
||||
export function IconButton({ name, size = 16, label, className = '', ...rest }: IconButtonProps) {
|
||||
return (
|
||||
<button className={`icon-btn ${className}`} type="button" aria-label={label} title={label} {...rest}>
|
||||
<Icon name={name} size={size} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 品牌标(侧栏/登录页共用):星芒 + 渐变底。 */
|
||||
export function BrandMark({ size = 22 }: { size?: number }) {
|
||||
return (
|
||||
<span className="brand-mark" style={{ width: size, height: size, borderRadius: Math.round(size * 0.3) }}>
|
||||
<Icon name="sparkles" size={Math.round(size * 0.62)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 工作区条目历史 emoji 字符串 → 线性图标。 */
|
||||
export function workspaceIcon(char: string | undefined): IconName {
|
||||
switch (char) {
|
||||
case '📦': return 'package'
|
||||
case '🤖': return 'bot'
|
||||
case '🧩': return 'puzzle'
|
||||
case '🗂': return 'grid'
|
||||
default: return 'grid'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 消息部件顺序保真(渲染顺序 = 落库顺序):后端 llm.go 按时间交错落库 parts
|
||||
// (text → tool_call → tool_result → artifact_ref → 下一轮 text…),渲染端不得
|
||||
// 按 process 标志二分重排——只允许把"连续的"过程部件就地聚成一块折叠。
|
||||
import type { Part } from './contracts'
|
||||
|
||||
export type PartBlock =
|
||||
| { kind: 'process'; parts: Part[] } // 连续过程段(thinking/tool_call/tool_result…)
|
||||
| { kind: 'content'; part: Part } // 原位内容部件(text/artifact_ref/card/hitl…)
|
||||
|
||||
/**
|
||||
* 按原始顺序把 parts 切成「过程段 / 内容」交替的块序列。
|
||||
* 例:[text, tool_call, tool_result, text, artifact_ref]
|
||||
* → [content(text), process(tool_call,tool_result), content(text), content(artifact_ref)]
|
||||
* 连续的过程部件合并为一个折叠单元,不改变相对顺序。
|
||||
*/
|
||||
export function groupParts(parts: Part[]): PartBlock[] {
|
||||
const out: PartBlock[] = []
|
||||
for (const p of parts) {
|
||||
if (p.process) {
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.kind === 'process') last.parts.push(p)
|
||||
else out.push({ kind: 'process', parts: [p] })
|
||||
} else {
|
||||
out.push({ kind: 'content', part: p })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 文件大小人类可读(工件面板/DocViewer 共用)。 */
|
||||
export function humanSize(n: number | undefined): string {
|
||||
if (!n || n <= 0) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
const s = v >= 100 || i === 0 ? String(Math.round(v)) : v.toFixed(1).replace(/\.0$/, '')
|
||||
return `${s} ${units[i]}`
|
||||
}
|
||||
|
||||
/** 工件文件图标名(@ui Icon 名):按扩展名优先,回落 kind。 */
|
||||
export function artifactIcon(name: string, kind?: string): 'file' | 'image' | 'package' {
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(name) || kind === 'image') return 'image'
|
||||
if (/\.(zip|tar|gz|7z|rar)$/i.test(name)) return 'package'
|
||||
return 'file'
|
||||
}
|
||||
+50
-8
@@ -4,7 +4,7 @@ import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard, Part,
|
||||
} from './contracts'
|
||||
|
||||
export interface ContextObs {
|
||||
@@ -21,6 +21,8 @@ export interface LiveState {
|
||||
toolCalls: { name: string; status?: string; summary?: string }[]
|
||||
agentNotes: Record<string, string>
|
||||
startedAt: number
|
||||
/** 到达序部件(渲染顺序 = 事件顺序;与后端落库的交错顺序同构)。 */
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
@@ -40,6 +42,7 @@ export interface UIState {
|
||||
lastProjectId: string | null
|
||||
adminUsersOpen: boolean
|
||||
changePwOpen: boolean
|
||||
projectSettingsId: string | null
|
||||
}
|
||||
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
|
||||
@@ -86,6 +89,7 @@ const DEFAULT_UI: UIState = {
|
||||
lastProjectId: null,
|
||||
adminUsersOpen: false,
|
||||
changePwOpen: false,
|
||||
projectSettingsId: null,
|
||||
}
|
||||
|
||||
function loadUI(): UIState {
|
||||
@@ -209,6 +213,13 @@ export const store = {
|
||||
store.refreshTasks()
|
||||
},
|
||||
|
||||
// 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。
|
||||
async setCrossFlags(convId: string, flags: { allowCrossRead?: boolean; crossPrivate?: boolean }) {
|
||||
const tasks = state.tasks.map(t => t.id === convId ? { ...t, ...flags } : t)
|
||||
set({ tasks })
|
||||
try { await api.setConversationFlags(convId, flags) } catch (e) { store.toast(`跨会话设置失败:${e}`); store.refreshTasks() }
|
||||
},
|
||||
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] })
|
||||
@@ -253,25 +264,47 @@ export const store = {
|
||||
switch (e.type) {
|
||||
case 'message.delta': {
|
||||
const p = e.payload as { index: number; text: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.text += p.text }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.text += p.text
|
||||
// 到达序:文本增量并入尾部的流式 text part,否则新开一段。
|
||||
const last = l.parts[l.parts.length - 1]
|
||||
if (last && last.type === 'text' && !last.process) last.text = (last.text ?? '') + p.text
|
||||
else l.parts.push({ type: 'text', text: p.text })
|
||||
}) })
|
||||
break
|
||||
}
|
||||
case 'thinking.delta': {
|
||||
const p = e.payload as { index: number; text: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.thinking += p.text }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.thinking += p.text
|
||||
const last = l.parts[l.parts.length - 1]
|
||||
if (last && last.type === 'thinking' && last.process) last.text = (last.text ?? '') + p.text
|
||||
else l.parts.push({ type: 'thinking', text: p.text, process: true })
|
||||
}) })
|
||||
break
|
||||
}
|
||||
case 'tool.call': {
|
||||
const p = e.payload as { callId: string; name: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.toolCalls.push({ name: p.name }) }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.toolCalls.push({ name: p.name })
|
||||
l.parts.push({ type: 'tool_call', process: true, toolCall: { id: p.callId, name: p.name } })
|
||||
}) })
|
||||
break
|
||||
}
|
||||
case 'tool.result': {
|
||||
const p = e.payload as { callId: string; status: string; summary?: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
let name = ''
|
||||
for (let i = l.toolCalls.length - 1; i >= 0; i--) {
|
||||
if (l.toolCalls[i].status === undefined) { l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }; break }
|
||||
if (l.toolCalls[i].status === undefined) {
|
||||
l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }
|
||||
name = l.toolCalls[i].name
|
||||
break
|
||||
}
|
||||
}
|
||||
// 结果紧跟对应 tool_call 之后展示(顺序执行下 push 即到位)。
|
||||
l.parts.push({ type: 'tool_result', process: true, toolCall: { id: p.callId, name },
|
||||
toolResult: { callId: p.callId, status: p.status === 'error' ? 'error' : 'ok', summary: p.summary } })
|
||||
}) })
|
||||
break
|
||||
}
|
||||
@@ -333,6 +366,12 @@ export const store = {
|
||||
break
|
||||
}
|
||||
case 'artifact.created': {
|
||||
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
|
||||
if (p?.artifact) {
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.parts.push({ type: 'artifact_ref', artifact: { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind } })
|
||||
}) })
|
||||
}
|
||||
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
|
||||
break
|
||||
}
|
||||
@@ -367,7 +406,7 @@ export const store = {
|
||||
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
|
||||
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
|
||||
if (!state.live || state.live.runId !== out.run.id) {
|
||||
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } })
|
||||
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now(), parts: [] } })
|
||||
}
|
||||
set({ messages: [...state.messages, out.message] })
|
||||
store.connectIfNeeded()
|
||||
@@ -494,6 +533,7 @@ export const store = {
|
||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
|
||||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||||
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
|
||||
toast(msg: string) {
|
||||
set({ toast: msg })
|
||||
setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
|
||||
@@ -501,8 +541,10 @@ export const store = {
|
||||
}
|
||||
|
||||
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() }
|
||||
const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes } }
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now(), parts: [] as Part[] }
|
||||
const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes }, parts: [...base.parts] }
|
||||
// parts 内部对象的后续可变更新(delta 并入尾部)需要新引用——浅拷贝后再克隆被改的尾部。
|
||||
if (next.parts.length > 0) next.parts[next.parts.length - 1] = { ...next.parts[next.parts.length - 1] }
|
||||
mutate(next)
|
||||
return next
|
||||
}
|
||||
|
||||
+49
-12
@@ -101,8 +101,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
|
||||
.part-text a { color: var(--accent); }
|
||||
.part-error { color: var(--err); background: var(--err-soft); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
|
||||
.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 5px 10px; cursor: pointer; font-size: 13px; transition: background .12s; }
|
||||
.artifact-chip:hover { background: var(--accent-soft-2); }
|
||||
.artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: 13px; margin: 2px 0; }
|
||||
.artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
|
||||
.artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artifact-chip:hover { border-color: var(--accent); }
|
||||
.artifact-chip .ac-act { display: inline-flex; align-items: center; padding: 5px 8px; color: var(--text-dim); border-left: 1px solid var(--border-soft); background: none; cursor: pointer; }
|
||||
.artifact-chip .ac-act:hover { color: var(--accent); background: var(--bg-hover); }
|
||||
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
|
||||
.part-group { margin: 2px 0; }
|
||||
|
||||
/* 过程折叠(run 终态后) */
|
||||
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
|
||||
@@ -155,10 +161,25 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.step.in_progress .step-icon { color: var(--accent); }
|
||||
.step.done .step-icon { color: var(--ok); }
|
||||
.step.skipped { color: var(--text-faint); text-decoration: line-through; }
|
||||
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
/* ---- 工件面板(工作区文件管理)---- */
|
||||
.artifacts-panel { display: flex; flex-direction: column; gap: 4px; }
|
||||
.ap-toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: 12.5px; }
|
||||
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: 12px; cursor: pointer; }
|
||||
.kind-pill:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.kind-pill.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.ap-upload { cursor: pointer; }
|
||||
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
|
||||
.artifact-row:hover { background: var(--bg-hover); }
|
||||
.artifact-row .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.artifact-row .kind { font-size: 11px; color: var(--text-faint); }
|
||||
.artifact-row .ar-icon { display: flex; color: var(--text-dim); cursor: pointer; flex: none; }
|
||||
.artifact-row .ar-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; cursor: pointer; }
|
||||
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.artifact-row .meta { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artifact-row .ar-acts { display: none; align-items: center; gap: 2px; flex: none; }
|
||||
.artifact-row:hover .ar-acts { display: inline-flex; }
|
||||
.artifact-row .ar-acts .icon-btn { width: 26px; height: 26px; }
|
||||
.artifact-row .ar-acts .icon-btn.danger { color: var(--err); }
|
||||
.empty-note { color: var(--text-faint); font-size: 12.5px; padding: 14px 6px; }
|
||||
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; }
|
||||
.agent-row:hover { background: var(--bg-hover); }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
@@ -179,17 +200,27 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; margin-bottom: 8px; background: var(--bg-panel); }
|
||||
.subagent-node .head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
|
||||
/* ---- 设置(schema 驱动)---- */
|
||||
.settings-group { margin-bottom: 18px; }
|
||||
.settings-group h4 { margin: 0 0 8px; font-size: 11.5px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .07em; font-weight: 600; }
|
||||
.setting-row { display: grid; grid-template-columns: 200px 1fr auto; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border-soft); font-size: 13px; }
|
||||
.setting-row label { color: var(--text-dim); }
|
||||
.setting-row input, .setting-row select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; }
|
||||
.setting-row input:focus, .setting-row select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
/* ---- 设置(schema 驱动;行组件来自 @ui SettingRow,这里只补控件样式)---- */
|
||||
.ui-setting-control { display: flex; align-items: center; justify-content: flex-end; }
|
||||
.ui-setting-control input, .ui-setting-control select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; width: 100%; max-width: 340px; }
|
||||
.ui-setting-control input:focus, .ui-setting-control select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
.ui-setting-control input[type="checkbox"] { width: auto; }
|
||||
.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
|
||||
.effect-hot { color: var(--ok); border-color: var(--ok); }
|
||||
.effect-new-run { color: var(--warn); border-color: var(--warn); }
|
||||
|
||||
/* ---- 文档查看器(单层 OverlayHost 内)---- */
|
||||
.docv { display: flex; flex-direction: column; min-height: 0; }
|
||||
.docv-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; border-bottom: 1px solid var(--border-soft); margin-bottom: 10px; }
|
||||
.docv-toolbar .spacer { flex: 1; }
|
||||
.docv-meta { font-size: 11.5px; color: var(--text-faint); text-transform: capitalize; }
|
||||
.docv-dirty { color: var(--warn); font-size: 12px; }
|
||||
.docv-dl { text-decoration: none; }
|
||||
.docv-body { flex: 1; min-height: 0; overflow: auto; }
|
||||
.docv-pdf { width: 100%; height: 100%; min-height: 60vh; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg); }
|
||||
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: 12.5px; resize: vertical; }
|
||||
.docv-pre { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
|
||||
|
||||
/* ---- 杂项 ---- */
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: 13px; }
|
||||
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
@@ -360,3 +391,9 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.icon-only button { background: none; border: none; color: var(--text-faint); cursor: pointer; padding: 0; width: 34px; height: 34px; border-radius: var(--radius); display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn); color: #fff; font-size: 9.5px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
|
||||
|
||||
/* ---- UI 能力平台消费(消息头像列 / 设置外壳 / 管理弹层头像) ---- */
|
||||
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||
.role-mark.role-assistant { background: none; border: none; padding: 0; }
|
||||
.msg.user .msg-role { align-items: center; }
|
||||
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 消息部件顺序保真纯函数测试(渲染顺序 = 落库顺序)。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { groupParts, humanSize, artifactIcon } from '../src/messageparts'
|
||||
import type { Part } from '../src/contracts'
|
||||
|
||||
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||
return partial as Part
|
||||
}
|
||||
|
||||
describe('groupParts(顺序保真)', () => {
|
||||
it('interleaves tool blocks at their original positions', () => {
|
||||
// 后端 llm.go 的真实交错序:text → tool → tool_result → artifact_ref → text
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先找资料' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
||||
p({ type: 'text', text: '文档已生成,请查收' }),
|
||||
]
|
||||
const blocks = groupParts(parts)
|
||||
expect(blocks.map(b => b.kind)).toEqual(['content', 'process', 'content', 'content'])
|
||||
expect((blocks[0] as { part: Part }).part.text).toBe('先找资料')
|
||||
expect((blocks[1] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
|
||||
expect((blocks[2] as { part: Part }).part.type).toBe('artifact_ref')
|
||||
expect((blocks[3] as { part: Part }).part.text).toBe('文档已生成,请查收')
|
||||
})
|
||||
|
||||
it('merges only consecutive process parts into one block', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'thinking', process: true, text: '思考' }),
|
||||
p({ type: 'text', text: '第一段' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'a' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
|
||||
p({ type: 'text', text: '第二段' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'b' } }),
|
||||
]
|
||||
const blocks = groupParts(parts)
|
||||
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'process', 'content', 'process'])
|
||||
expect((blocks[2] as { parts: Part[] }).parts).toHaveLength(2)
|
||||
expect((blocks[4] as { parts: Part[] }).parts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles all-process and all-content messages', () => {
|
||||
expect(groupParts([p({ type: 'thinking', process: true, text: 'x' }), p({ type: 'tool_call', process: true, toolCall: { id: '', name: '' } })]))
|
||||
.toEqual([{ kind: 'process', parts: [expect.anything(), expect.anything()] }])
|
||||
expect(groupParts([p({ type: 'text', text: 'hi' })]).map(b => b.kind)).toEqual(['content'])
|
||||
expect(groupParts([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanSize / artifactIcon', () => {
|
||||
it('formats sizes compactly', () => {
|
||||
expect(humanSize(undefined)).toBe('')
|
||||
expect(humanSize(0)).toBe('')
|
||||
expect(humanSize(512)).toBe('512 B')
|
||||
expect(humanSize(2048)).toBe('2 KB')
|
||||
expect(humanSize(1536 * 1024)).toBe('1.5 MB')
|
||||
})
|
||||
it('picks icons by extension then kind', () => {
|
||||
expect(artifactIcon('会议纪要.docx')).toBe('file')
|
||||
expect(artifactIcon('截图.png')).toBe('image')
|
||||
expect(artifactIcon('blob', 'image')).toBe('image')
|
||||
expect(artifactIcon('打包.zip')).toBe('package')
|
||||
})
|
||||
})
|
||||
+4
-1
@@ -16,7 +16,10 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@ui": ["../ui/src/index.ts"],
|
||||
"@ui/*": ["../ui/src/*"]
|
||||
"@ui/*": ["../ui/src/*"],
|
||||
"react": ["./node_modules/@types/react"],
|
||||
"react-dom": ["./node_modules/@types/react-dom"],
|
||||
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/icons.tsx","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user