Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2878c04ea | ||
|
|
39dc8c456c | ||
|
|
482ba885e1 | ||
|
|
09310cb753 | ||
|
|
fcf40ff75f |
@@ -31,6 +31,8 @@ export const api = {
|
||||
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)),
|
||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
|
||||
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
|
||||
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
|
||||
@@ -22,6 +22,9 @@ export const auth = {
|
||||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||||
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
||||
clear() { storage.removeItem(KEY) },
|
||||
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
||||
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
||||
beginSession() { this.clear() },
|
||||
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
||||
fetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
const key = this.apiKey()
|
||||
|
||||
@@ -41,6 +41,8 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
const me = await api.login(owner.trim(), password)
|
||||
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
|
||||
auth.beginSession()
|
||||
if (me.mustChangePassword) {
|
||||
// 初始/重置密码:先完成强制改密再放行。
|
||||
onMustChange(me.owner)
|
||||
|
||||
@@ -56,7 +56,7 @@ export function PreviewPane() {
|
||||
</div>
|
||||
<div className="preview-pane-body">
|
||||
{surfaceSnap
|
||||
? <SurfaceView snapshot={surfaceSnap} docBridge={{ onOpenDoc: docId => {
|
||||
? <SurfaceView snapshot={surfaceSnap} onAction={(actionId, payload) => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), actionId, payload as Record<string, unknown>)} docBridge={{ onOpenDoc: docId => {
|
||||
const art = artifacts.find(a => a.id === docId)
|
||||
if (art) store.requestPreview(art)
|
||||
} }} />
|
||||
|
||||
@@ -162,6 +162,8 @@ function loadUI(): UIState {
|
||||
return { ...DEFAULT_UI }
|
||||
}
|
||||
|
||||
const surfaceActionsInflight: Record<string, boolean> = {}
|
||||
|
||||
let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
@@ -899,6 +901,32 @@ export const store = {
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
},
|
||||
// U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻
|
||||
// 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。
|
||||
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
|
||||
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
|
||||
if (inflight) return
|
||||
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
|
||||
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
|
||||
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
|
||||
surfaceActionsInflight[surfaceId + ':' + actionId] = true
|
||||
try {
|
||||
const res = await api.submitSurfaceAction(surfaceId, {
|
||||
actionId, revision: snap.revision, requestId, data,
|
||||
})
|
||||
if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行')
|
||||
else if (res?.state === 'committed') store.toast('已提交')
|
||||
else store.toast('动作状态:' + (res?.state ?? 'unknown'))
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message ?? e)
|
||||
if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试')
|
||||
else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用')
|
||||
else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝')
|
||||
else store.toast('动作失败:' + msg)
|
||||
} finally {
|
||||
delete surfaceActionsInflight[surfaceId + ':' + actionId]
|
||||
}
|
||||
},
|
||||
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||
activatePreview(id: string) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
|
||||
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
|
||||
|
||||
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
|
||||
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now())
|
||||
return {
|
||||
actionId,
|
||||
revision: snapshot.revision,
|
||||
requestId,
|
||||
data: drafts,
|
||||
}
|
||||
}
|
||||
|
||||
describe('surface action request', () => {
|
||||
it('携带当前修订与显式草稿;requestId 唯一', () => {
|
||||
const snap = { revision: 3 }
|
||||
const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
|
||||
const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
|
||||
expect(a.revision).toBe(3)
|
||||
expect(a.actionId).toBe('generative-ui.submit')
|
||||
expect(a.data).toEqual({ f1: { years: '5' } })
|
||||
expect(a.requestId).not.toBe(b.requestId)
|
||||
})
|
||||
})
|
||||
@@ -40,4 +40,15 @@ describe('auth 凭证模块', () => {
|
||||
expect(auth.apiKey()).toBe('')
|
||||
expect(auth.withKey('/x')).toBe('/x')
|
||||
})
|
||||
|
||||
it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => {
|
||||
auth.setKey('ask_stale')
|
||||
auth.beginSession()
|
||||
expect(auth.apiKey()).toBe('')
|
||||
const spy = vi.fn().mockResolvedValue(new Response('{}'))
|
||||
vi.stubGlobal('fetch', spy)
|
||||
await auth.fetch('/api/me')
|
||||
const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined]
|
||||
expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user