Add a file rewind confirm on user messages #42
@@ -28,6 +28,8 @@ export const api = {
|
||||
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
|
||||
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}`) }),
|
||||
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||
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 => {
|
||||
|
||||
@@ -540,6 +540,7 @@ export function Conversation() {
|
||||
<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>
|
||||
{currentConvId && <RewindControl convId={currentConvId} messageId={m.id} />}
|
||||
{anchored.map(c => (
|
||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||
@@ -616,3 +617,63 @@ export function Conversation() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RewindControl({ convId, messageId }: { convId: string; messageId: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [files, setFiles] = useState<{ restored: string[]; skipped: string[] } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
async function preview() {
|
||||
setErr('')
|
||||
setOpen(true)
|
||||
setFiles(null)
|
||||
try {
|
||||
const plan = await api.checkpoints(convId, messageId)
|
||||
setFiles({ restored: plan.restored ?? [], skipped: plan.skipped ?? [] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
async function confirm() {
|
||||
setBusy(true)
|
||||
setErr('')
|
||||
try {
|
||||
await api.rewindFiles(convId, messageId)
|
||||
setOpen(false)
|
||||
await store.openTask(convId)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
const canRewind = !!files && files.restored.length > 0
|
||||
return (
|
||||
<div className="rewind-row">
|
||||
<button type="button" className="btn ghost" onClick={preview}>回滚文件</button>
|
||||
{open && (
|
||||
<div className="rewind-panel" role="dialog" aria-label="回滚文件">
|
||||
{!files && !err && <div>正在列出文件…</div>}
|
||||
{files && files.restored.length === 0 && files.skipped.length === 0 && <div>没有需要回滚的文件。</div>}
|
||||
{files && files.restored.length > 0 && (
|
||||
<>
|
||||
<div>将恢复这些文件:</div>
|
||||
<ul>{files.restored.map(p => <li key={p}>{p}</li>)}</ul>
|
||||
</>
|
||||
)}
|
||||
{files && files.skipped.length > 0 && (
|
||||
<>
|
||||
<div>这些文件在追踪之外被改过,会跳过:</div>
|
||||
<ul>{files.skipped.map(p => <li key={`skip-${p}`}>{p}</li>)}</ul>
|
||||
</>
|
||||
)}
|
||||
{err && <div className="rewind-error">{err}</div>}
|
||||
<div className="rewind-actions">
|
||||
<button type="button" className="btn" disabled={busy || !canRewind} onClick={confirm}>确认回滚</button>
|
||||
<button type="button" className="btn ghost" onClick={() => setOpen(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export type RunPhase = 'thinking' | 'acting' | 'responding' | 'finalizing' | 'do
|
||||
export interface Usage {
|
||||
promptTokens: number; completionTokens: number; totalTokens: number;
|
||||
durationMs: number; toolCalls: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
|
||||
@@ -553,3 +553,12 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
margin-left: 6px; min-width: 16px; padding: 0 5px; border-radius: var(--radius-full);
|
||||
background: var(--err); color: var(--text-on-accent); font-size: var(--fs-ui-xs);
|
||||
}
|
||||
.rewind-row { margin-top: 8px; }
|
||||
.rewind-panel {
|
||||
margin-top: 8px; padding: 8px 10px;
|
||||
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-ui-sm);
|
||||
}
|
||||
.rewind-panel ul { margin: 6px 0; padding-left: 1.2em; }
|
||||
.rewind-actions { display: flex; gap: 8px; }
|
||||
.rewind-error { color: var(--err); font-size: var(--fs-ui-sm); }
|
||||
|
||||
Reference in New Issue
Block a user