Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2878c04ea | ||
|
|
39dc8c456c | ||
|
|
482ba885e1 | ||
|
|
09310cb753 | ||
|
|
fcf40ff75f | ||
|
|
dc18d6c747 | ||
|
|
68f6c8638b | ||
|
|
df4ca1e470 | ||
|
|
8108655e61 | ||
|
|
6b4920b509 | ||
|
|
a26d7c5aee |
+6
-1
@@ -52,8 +52,13 @@ export function App() {
|
|||||||
const r = await auth.fetch('/api/health')
|
const r = await auth.fetch('/api/health')
|
||||||
if (r.status === 401) { setAuthState('login'); return }
|
if (r.status === 401) { setAuthState('login'); return }
|
||||||
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
||||||
|
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
|
||||||
|
// key 持有者清 key 回登录页,改走密码登录完成改密。
|
||||||
const me = await api.me().catch(() => null)
|
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')
|
setAuthState('ok')
|
||||||
} catch {
|
} catch {
|
||||||
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
|
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
|
||||||
|
|||||||
+6
-2
@@ -30,7 +30,11 @@ 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}`) }),
|
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)),
|
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)),
|
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)),
|
||||||
|
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)----
|
// ---- 认证与会话(管理员发放制,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 => {
|
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()}`)
|
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||||
@@ -160,7 +164,7 @@ const streamEventListeners: Record<StreamEventType, true> = {
|
|||||||
'card.created': true,
|
'card.created': true,
|
||||||
'card.updated': true,
|
'card.updated': true,
|
||||||
'card.status': 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 } {
|
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export const auth = {
|
|||||||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||||||
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
||||||
clear() { storage.removeItem(KEY) },
|
clear() { storage.removeItem(KEY) },
|
||||||
|
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
||||||
|
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
||||||
|
beginSession() { this.clear() },
|
||||||
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
||||||
fetch(input: string, init?: RequestInit): Promise<Response> {
|
fetch(input: string, init?: RequestInit): Promise<Response> {
|
||||||
const key = this.apiKey()
|
const key = this.apiKey()
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
|||||||
setErr(''); setBusy(true)
|
setErr(''); setBusy(true)
|
||||||
try {
|
try {
|
||||||
const me = await api.login(owner.trim(), password)
|
const me = await api.login(owner.trim(), password)
|
||||||
|
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
|
||||||
|
auth.beginSession()
|
||||||
if (me.mustChangePassword) {
|
if (me.mustChangePassword) {
|
||||||
// 初始/重置密码:先完成强制改密再放行。
|
// 初始/重置密码:先完成强制改密再放行。
|
||||||
onMustChange(me.owner)
|
onMustChange(me.owner)
|
||||||
@@ -57,7 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
|||||||
auth.setKey(key)
|
auth.setKey(key)
|
||||||
try {
|
try {
|
||||||
const r = await auth.fetch('/api/health')
|
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))
|
throw new Error(String(r.status))
|
||||||
} catch {
|
} catch {
|
||||||
auth.clear()
|
auth.clear()
|
||||||
@@ -118,7 +130,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
|||||||
await api.changePassword(current, next)
|
await api.changePassword(current, next)
|
||||||
onDone()
|
onDone()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(humanLoginError(String(e)).replace('401: ', ''))
|
setErr(humanChangePasswordError(String(e)))
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,10 +157,23 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 错误文案:把 HTTP 状态码翻成人话。 */
|
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||||
export function humanLoginError(raw: string): string {
|
export function humanLoginError(raw: string): string {
|
||||||
if (raw.includes('401')) return '用户名或密码不正确'
|
if (raw.includes('401')) return '用户名或密码不正确'
|
||||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||||
return raw.replace(/^\d{3}:\s*/, '')
|
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 的分隔条控制。
|
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
|
||||||
import { useEffect, useState } from 'react'
|
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 { api } from '../api'
|
||||||
import type { Artifact } from '../contracts'
|
import type { Artifact } from '../contracts'
|
||||||
import { humanSize } from '../messageparts'
|
import { humanSize } from '../messageparts'
|
||||||
@@ -15,6 +15,10 @@ export function PreviewPane() {
|
|||||||
const artifacts = useStore(s => s.artifacts)
|
const artifacts = useStore(s => s.artifacts)
|
||||||
const expanded = useStore(s => s.ui.previewExpanded)
|
const expanded = useStore(s => s.ui.previewExpanded)
|
||||||
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
|
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 art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||||
const textKind = !!art && isTextKind(art)
|
const textKind = !!art && isTextKind(art)
|
||||||
const [content, setContent] = useState<string | null>(null)
|
const [content, setContent] = useState<string | null>(null)
|
||||||
@@ -35,6 +39,32 @@ export function PreviewPane() {
|
|||||||
}, [art?.id])
|
}, [art?.id])
|
||||||
|
|
||||||
if (!active) return null
|
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} 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)
|
||||||
|
} }} />
|
||||||
|
: <p className="surface-view-empty">页面快照不可用(可能已被移除或无权限)。</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="preview-pane">
|
<div className="preview-pane">
|
||||||
<TabStrip
|
<TabStrip
|
||||||
|
|||||||
+45
-2
@@ -99,7 +99,8 @@ export type StreamEventType =
|
|||||||
| 'agent.spawn' | 'agent.progress' | 'agent.result'
|
| 'agent.spawn' | 'agent.progress' | 'agent.result'
|
||||||
| 'artifact.created' | 'artifact.updated'
|
| 'artifact.created' | 'artifact.updated'
|
||||||
| 'run.completed' | 'run.failed' | 'error'
|
| '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> {
|
export interface StreamEvent<P = unknown> {
|
||||||
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
|
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
|
||||||
@@ -185,7 +186,7 @@ export type UINodeType =
|
|||||||
|
|
||||||
export interface UINode {
|
export interface UINode {
|
||||||
type: UINodeType;
|
type: UINodeType;
|
||||||
/** Surface 层节点级标注(core ui.go U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
|
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
|
||||||
id?: string;
|
id?: string;
|
||||||
source?: string[];
|
source?: string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -355,3 +356,45 @@ export interface UploadInfo {
|
|||||||
artifactId: ID; filename: string; mime?: string; size: number;
|
artifactId: ID; filename: string; mime?: string; size: number;
|
||||||
parsed?: ParsedDocument;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+85
-4
@@ -16,8 +16,19 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
|||||||
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
||||||
}
|
}
|
||||||
import type {
|
import type {
|
||||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
ActivityCard,
|
||||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
AgentProfile,
|
||||||
|
Artifact,
|
||||||
|
Conversation,
|
||||||
|
HITLCard,
|
||||||
|
Message,
|
||||||
|
PlanSnapshot,
|
||||||
|
PluginPanel,
|
||||||
|
Project,
|
||||||
|
Run,
|
||||||
|
StreamEvent,
|
||||||
|
SurfaceSnapshot,
|
||||||
|
TodoList
|
||||||
} from './contracts'
|
} from './contracts'
|
||||||
|
|
||||||
export interface ContextObs {
|
export interface ContextObs {
|
||||||
@@ -86,6 +97,8 @@ interface AppState {
|
|||||||
plan: PlanSnapshot | null
|
plan: PlanSnapshot | null
|
||||||
todos: TodoList | null
|
todos: TodoList | null
|
||||||
artifacts: Artifact[]
|
artifacts: Artifact[]
|
||||||
|
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
||||||
|
surfaces: SurfaceSnapshot[]
|
||||||
cards: ActivityCard[]
|
cards: ActivityCard[]
|
||||||
projectCards: ActivityCard[]
|
projectCards: ActivityCard[]
|
||||||
inboxScope: 'conv' | 'project'
|
inboxScope: 'conv' | 'project'
|
||||||
@@ -149,11 +162,13 @@ function loadUI(): UIState {
|
|||||||
return { ...DEFAULT_UI }
|
return { ...DEFAULT_UI }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const surfaceActionsInflight: Record<string, boolean> = {}
|
||||||
|
|
||||||
let state: AppState = {
|
let state: AppState = {
|
||||||
contextObs: {},
|
contextObs: {},
|
||||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: 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: [],
|
serverWorkspaces: [],
|
||||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||||
outbox: [], outboxDraining: false,
|
outbox: [], outboxDraining: false,
|
||||||
@@ -359,7 +374,7 @@ export const store = {
|
|||||||
|
|
||||||
async openConversation(convId: string) {
|
async openConversation(convId: string) {
|
||||||
if (sse) { sse.close(); sse = null }
|
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)
|
persistConvId(convId)
|
||||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||||
@@ -369,6 +384,8 @@ export const store = {
|
|||||||
previewActive: { ...state.previewActive, [convId]: active },
|
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()
|
const resumeSeq = await store.reconcile()
|
||||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||||
},
|
},
|
||||||
@@ -490,6 +507,33 @@ export const store = {
|
|||||||
set({ todos: p.list })
|
set({ todos: p.list })
|
||||||
break
|
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.created':
|
||||||
case 'card.updated': {
|
case 'card.updated': {
|
||||||
const p = e.payload as { card: ActivityCard }
|
const p = e.payload as { card: ActivityCard }
|
||||||
@@ -846,6 +890,43 @@ export const store = {
|
|||||||
})
|
})
|
||||||
syncFilesEntry(true)
|
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)
|
||||||
|
},
|
||||||
|
// 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 }) },
|
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||||
activatePreview(id: string) {
|
activatePreview(id: string) {
|
||||||
const conv = previewTabsKey(state.currentConvId)
|
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,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.apiKey()).toBe('')
|
||||||
expect(auth.withKey('/x')).toBe('/x')
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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