Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc18d6c747 | ||
|
|
68f6c8638b | ||
|
|
df4ca1e470 | ||
|
|
8108655e61 | ||
|
|
6b4920b509 | ||
|
|
a26d7c5aee | ||
|
|
69b1487fb7 | ||
|
|
32e327a831 |
+6
-1
@@ -52,8 +52,13 @@ export function App() {
|
||||
const r = await auth.fetch('/api/health')
|
||||
if (r.status === 401) { setAuthState('login'); return }
|
||||
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
||||
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
|
||||
// key 持有者清 key 回登录页,改走密码登录完成改密。
|
||||
const me = await api.me().catch(() => null)
|
||||
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
|
||||
if (me?.mustChangePassword) {
|
||||
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
|
||||
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
|
||||
}
|
||||
setAuthState('ok')
|
||||
} catch {
|
||||
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
|
||||
|
||||
+4
-2
@@ -30,7 +30,9 @@ export const api = {
|
||||
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)),
|
||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).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)----
|
||||
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 => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
@@ -160,7 +162,7 @@ const streamEventListeners: Record<StreamEventType, true> = {
|
||||
'card.created': true,
|
||||
'card.updated': true,
|
||||
'card.status': true,
|
||||
'card.resolved': true,
|
||||
'card.resolved': true, 'surface.updated': true,
|
||||
}
|
||||
|
||||
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
|
||||
|
||||
@@ -57,7 +57,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
auth.setKey(key)
|
||||
try {
|
||||
const r = await auth.fetch('/api/health')
|
||||
if (r.ok) { onAuthed(); return }
|
||||
if (r.ok) {
|
||||
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
|
||||
// 不放行进死路改密门,引导改走账号密码登录。
|
||||
const me = await api.me().catch(() => null)
|
||||
if (me?.mustChangePassword) {
|
||||
auth.clear()
|
||||
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
|
||||
return
|
||||
}
|
||||
onAuthed(); return
|
||||
}
|
||||
throw new Error(String(r.status))
|
||||
} catch {
|
||||
auth.clear()
|
||||
@@ -118,7 +128,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
await api.changePassword(current, next)
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setErr(humanLoginError(String(e)).replace('401: ', ''))
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -145,10 +155,23 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
)
|
||||
}
|
||||
|
||||
/** 错误文案:把 HTTP 状态码翻成人话。 */
|
||||
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||
export function humanLoginError(raw: string): string {
|
||||
if (raw.includes('401')) return '用户名或密码不正确'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
|
||||
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Btn, FilePreview, Icon, TabStrip } from '@ui'
|
||||
import { Btn, FilePreview, Icon, SurfaceView, TabStrip } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { humanSize } from '../messageparts'
|
||||
@@ -15,6 +15,10 @@ export function PreviewPane() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const expanded = useStore(s => s.ui.previewExpanded)
|
||||
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const surfaceSnap = active?.id.startsWith('surface:')
|
||||
? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id)
|
||||
: undefined
|
||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||
const textKind = !!art && isTextKind(art)
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
@@ -35,6 +39,32 @@ export function PreviewPane() {
|
||||
}, [art?.id])
|
||||
|
||||
if (!active) return null
|
||||
if (active.id.startsWith('surface:')) {
|
||||
return (
|
||||
<div className="preview-pane">
|
||||
<TabStrip
|
||||
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
|
||||
activeId={active.id}
|
||||
onActivate={id => store.activatePreview(id)}
|
||||
onClose={id => store.closePreview(id)}
|
||||
/>
|
||||
<div className="preview-pane-bar">
|
||||
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
|
||||
<Icon name="list" size={14} />
|
||||
</button>
|
||||
<span className="preview-pane-name" title={active.name}>{active.name}</span>
|
||||
</div>
|
||||
<div className="preview-pane-body">
|
||||
{surfaceSnap
|
||||
? <SurfaceView snapshot={surfaceSnap} docBridge={{ onOpenDoc: docId => {
|
||||
const art = artifacts.find(a => a.id === docId)
|
||||
if (art) store.requestPreview(art)
|
||||
} }} />
|
||||
: <p className="surface-view-empty">页面快照不可用(可能已被移除或无权限)。</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="preview-pane">
|
||||
<TabStrip
|
||||
|
||||
+47
-1
@@ -99,7 +99,8 @@ export type StreamEventType =
|
||||
| 'agent.spawn' | 'agent.progress' | 'agent.result'
|
||||
| 'artifact.created' | 'artifact.updated'
|
||||
| 'run.completed' | 'run.failed' | 'error'
|
||||
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
|
||||
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved'
|
||||
| 'surface.updated';
|
||||
|
||||
export interface StreamEvent<P = unknown> {
|
||||
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
|
||||
@@ -185,6 +186,9 @@ export type UINodeType =
|
||||
|
||||
export interface UINode {
|
||||
type: UINodeType;
|
||||
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
|
||||
id?: string;
|
||||
source?: string[];
|
||||
title?: string;
|
||||
items?: string[];
|
||||
kv?: [string, string][];
|
||||
@@ -352,3 +356,45 @@ export interface UploadInfo {
|
||||
artifactId: ID; filename: string; mime?: string; size: number;
|
||||
parsed?: ParsedDocument;
|
||||
}
|
||||
|
||||
// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)----
|
||||
|
||||
export interface SurfaceCompat {
|
||||
schemaVersion: number;
|
||||
catalogVersion: string;
|
||||
pluginVersion?: string;
|
||||
}
|
||||
|
||||
export interface SurfaceIdentity {
|
||||
surfaceId: ID;
|
||||
owner: string;
|
||||
projectId?: ID;
|
||||
conversationId?: ID;
|
||||
sourceRunId?: ID;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface SurfaceSpec {
|
||||
title: string;
|
||||
nodes: UINode[];
|
||||
}
|
||||
|
||||
export interface SurfaceSnapshot {
|
||||
identity: SurfaceIdentity;
|
||||
compat: SurfaceCompat;
|
||||
revision: number;
|
||||
baseRevision?: number;
|
||||
contextVersion?: string;
|
||||
activationEpoch: number;
|
||||
spec: SurfaceSpec;
|
||||
semanticText?: string;
|
||||
}
|
||||
|
||||
export interface SurfaceMeta {
|
||||
surfaceId: ID;
|
||||
title: string;
|
||||
pluginId: string;
|
||||
revision: number;
|
||||
conversationId?: ID;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
+57
-4
@@ -16,8 +16,19 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
||||
}
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
||||
ActivityCard,
|
||||
AgentProfile,
|
||||
Artifact,
|
||||
Conversation,
|
||||
HITLCard,
|
||||
Message,
|
||||
PlanSnapshot,
|
||||
PluginPanel,
|
||||
Project,
|
||||
Run,
|
||||
StreamEvent,
|
||||
SurfaceSnapshot,
|
||||
TodoList
|
||||
} from './contracts'
|
||||
|
||||
export interface ContextObs {
|
||||
@@ -86,6 +97,8 @@ interface AppState {
|
||||
plan: PlanSnapshot | null
|
||||
todos: TodoList | null
|
||||
artifacts: Artifact[]
|
||||
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
||||
surfaces: SurfaceSnapshot[]
|
||||
cards: ActivityCard[]
|
||||
projectCards: ActivityCard[]
|
||||
inboxScope: 'conv' | 'project'
|
||||
@@ -153,7 +166,7 @@ let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
outbox: [], outboxDraining: false,
|
||||
@@ -359,7 +372,7 @@ export const store = {
|
||||
|
||||
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: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
persistConvId(convId)
|
||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||
@@ -369,6 +382,8 @@ export const store = {
|
||||
previewActive: { ...state.previewActive, [convId]: active },
|
||||
})
|
||||
}
|
||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||
api.surfaceList(convId).then(list => { if (state.currentConvId === convId) set({ surfaces: list ?? [] }) }).catch(() => {})
|
||||
const resumeSeq = await store.reconcile()
|
||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||
},
|
||||
@@ -490,6 +505,33 @@ export const store = {
|
||||
set({ todos: p.list })
|
||||
break
|
||||
}
|
||||
case 'surface.updated': {
|
||||
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
|
||||
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
|
||||
const p = e.payload as SurfaceSnapshot
|
||||
if (!p?.identity?.surfaceId) break
|
||||
const id = String(p.identity.surfaceId)
|
||||
const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id)
|
||||
const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id)
|
||||
if (!existing || p.revision > existing.revision) {
|
||||
surfaces.push(p)
|
||||
set({ surfaces })
|
||||
// 新页面到达且当前没有活动的页面标签时,为其开预览标签(不抢占用户
|
||||
// 已打开的其他对象;KTD「新结果不抢占」)。
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const activeId = state.previewActive[conv] ?? null
|
||||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||
if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) {
|
||||
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'card.created':
|
||||
case 'card.updated': {
|
||||
const p = e.payload as { card: ActivityCard }
|
||||
@@ -846,6 +888,17 @@ export const store = {
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
},
|
||||
openSurface(surfaceId: string) {
|
||||
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
|
||||
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
},
|
||||
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||
activatePreview(id: string) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { store } from './store'
|
||||
import type { StreamEvent, SurfaceSnapshot } from './contracts'
|
||||
|
||||
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||||
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
|
||||
|
||||
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
|
||||
return {
|
||||
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
|
||||
payload: {
|
||||
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
|
||||
revision, activationEpoch: 1,
|
||||
spec: { title, nodes: [{ type: 'markdown', id: 'n1', markdown: 'x' }] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('surface.updated projection', () => {
|
||||
it('新事件入库;旧修订与重复事件不覆盖', () => {
|
||||
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
|
||||
store.applyEvent(surfEvent(2, 's1', 2, '修订二'))
|
||||
store.applyEvent(surfEvent(3, 's1', 1, '迟到的旧修订'))
|
||||
store.applyEvent(surfEvent(4, 's1', 2, '重复事件'))
|
||||
const list = store.getState().surfaces
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0].revision).toBe(2)
|
||||
expect(list[0].spec.title).toBe('修订二')
|
||||
})
|
||||
|
||||
it('多页面并存,互不覆盖', () => {
|
||||
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
|
||||
const list = store.getState().surfaces
|
||||
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
|
||||
expect(list.filter(x => String(x.identity.surfaceId) === 's2')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
|
||||
|
||||
describe('humanLoginError(登录)', () => {
|
||||
it('401 统一为用户名或密码不正确', () => {
|
||||
expect(humanLoginError('401: invalid owner or password')).toBe('用户名或密码不正确')
|
||||
})
|
||||
it('429/412 各有文案,其余剥状态码前缀', () => {
|
||||
expect(humanLoginError('429: too many failed attempts; retry later')).toBe('失败次数过多,请稍后再试')
|
||||
expect(humanLoginError('412: auth disabled (embedded single-tenant mode)')).toBe('本服务未开启登录(嵌入式单租户档)')
|
||||
expect(humanLoginError('400: bad')).toBe('bad')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError(改密门)', () => {
|
||||
it('当前密码错误不再伪装成登录失败', () => {
|
||||
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
|
||||
})
|
||||
it('无会话(API key 凭证)给出可行动指引', () => {
|
||||
expect(humanChangePasswordError('401: session login required (API keys do not own passwords)')).toBe('请先用账号密码登录再改密(API 密钥不能修改密码)')
|
||||
})
|
||||
it('会话过期与失效 key 各有文案', () => {
|
||||
expect(humanChangePasswordError('401: session expired')).toBe('会话已过期,请重新登录')
|
||||
expect(humanChangePasswordError('401: unauthorized: invalid or revoked API key')).toBe('本地残留的 API 密钥已失效,请退出登录后重试')
|
||||
})
|
||||
it('未知 401 回落通用文案,其余错误剥前缀透传', () => {
|
||||
expect(humanChangePasswordError('401: whatever')).toBe('认证失败,请重新登录')
|
||||
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user