Compare commits
3
Commits
4882e0ae25
...
a82a1e18de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82a1e18de | ||
|
|
279b2d02a2 | ||
|
|
6e611c5de0 |
+6
-4
@@ -186,6 +186,8 @@ async function json<T>(res: Response): Promise<T> {
|
|||||||
return res.json() as Promise<T>
|
return res.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConversationView = Conversation & { /** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */ scratch?: boolean }
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []),
|
permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []),
|
||||||
listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => {
|
listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => {
|
||||||
@@ -220,13 +222,13 @@ export const api = {
|
|||||||
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
|
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
|
||||||
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)),
|
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)),
|
||||||
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||||
createScratchSession: (title?: string) => auth.fetch(`${BASE}/api/scratch-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(title ? { title } : {}) }).then(r => json<{ project: Project; conversation: Conversation }>(r)),
|
createScratchSession: (title?: string) => auth.fetch(`${BASE}/api/scratch-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(title ? { title } : {}) }).then(r => json<{ project: Project; conversation: ConversationView }>(r)),
|
||||||
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
||||||
|
|
||||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<ConversationView[]>(r)),
|
||||||
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||||||
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
|
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<ConversationView[]>(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}`) }),
|
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}`) }),
|
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)),
|
||||||
@@ -292,7 +294,7 @@ export const api = {
|
|||||||
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
||||||
|
|
||||||
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
|
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
|
||||||
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
|
json<{ conversation: ConversationView; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
|
||||||
|
|
||||||
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
|
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
|
||||||
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
|
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import { auth } from '../auth'
|
import { auth } from '../auth'
|
||||||
import { api } from '../api'
|
import { api, type ConversationView } from '../api'
|
||||||
import { Icon } from '@ui'
|
import { Icon } from '@ui'
|
||||||
import type { Conversation } from '../contracts'
|
|
||||||
import { relativeTime, filterTasks } from '../tasklist'
|
import { relativeTime, filterTasks } from '../tasklist'
|
||||||
import {
|
import {
|
||||||
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
|
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
|
||||||
@@ -96,7 +95,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
|
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
|
||||||
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
|
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
|
||||||
|
|
||||||
const rowActions = (t: Conversation) => (
|
const rowActions = (t: ConversationView) => (
|
||||||
<span className="tsb-row-acts">
|
<span className="tsb-row-acts">
|
||||||
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
|
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
|
||||||
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||||||
@@ -118,7 +117,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
onConversationOpen?.()
|
onConversationOpen?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskRow = (t: Conversation) => (
|
const taskRow = (t: ConversationView) => (
|
||||||
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||||||
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
||||||
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
|
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ export interface Conversation {
|
|||||||
allowCrossRead?: boolean; crossPrivate?: boolean;
|
allowCrossRead?: boolean; crossPrivate?: boolean;
|
||||||
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
|
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
|
||||||
sessionNote?: string;
|
sessionNote?: string;
|
||||||
/** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */
|
|
||||||
scratch?: boolean;
|
|
||||||
createdAt: string; updatedAt: string;
|
createdAt: string; updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -4,10 +4,11 @@ import {
|
|||||||
PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS,
|
PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS,
|
||||||
createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus,
|
createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus,
|
||||||
} from './store'
|
} from './store'
|
||||||
import type { Conversation, Project } from './contracts'
|
import type { ConversationView } from './api'
|
||||||
|
import type { Project } from './contracts'
|
||||||
|
|
||||||
const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' })
|
const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' })
|
||||||
const task = (id: string, projectId: string): Conversation => ({ id, projectId, title: id, createdAt: '', updatedAt: '' })
|
const task = (id: string, projectId: string): ConversationView => ({ id, projectId, title: id, createdAt: '', updatedAt: '' })
|
||||||
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
let resolve!: (value: T) => void
|
let resolve!: (value: T) => void
|
||||||
@@ -39,7 +40,7 @@ function fakeEnv() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let projects: Project[]
|
let projects: Project[]
|
||||||
let tasks: Conversation[]
|
let tasks: ConversationView[]
|
||||||
let requests: { path: string; init?: RequestInit }[]
|
let requests: { path: string; init?: RequestInit }[]
|
||||||
let route: (path: string, init?: RequestInit) => Promise<Response>
|
let route: (path: string, init?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -1,7 +1,7 @@
|
|||||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||||
import { useSyncExternalStore } from 'react'
|
import { useSyncExternalStore } from 'react'
|
||||||
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
|
import { type AdminZoneSection, type ConversationView, type LinkedCapabilities, api, connectEvents } from './api'
|
||||||
import { auth } from './auth'
|
import { auth } from './auth'
|
||||||
import { noProjectError, projectActionError, projectWorkError } from './projectmeta'
|
import { noProjectError, projectActionError, projectWorkError } from './projectmeta'
|
||||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||||
@@ -26,7 +26,6 @@ import type {
|
|||||||
TaskSourceRef,
|
TaskSourceRef,
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
Artifact,
|
Artifact,
|
||||||
Conversation,
|
|
||||||
HITLCard,
|
HITLCard,
|
||||||
Message,
|
Message,
|
||||||
PlanSnapshot,
|
PlanSnapshot,
|
||||||
@@ -116,7 +115,7 @@ interface AppState {
|
|||||||
currentProjectId: string | null
|
currentProjectId: string | null
|
||||||
/** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */
|
/** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */
|
||||||
scratchMode: boolean
|
scratchMode: boolean
|
||||||
tasks: Conversation[]
|
tasks: ConversationView[]
|
||||||
me: Me | null
|
me: Me | null
|
||||||
currentConvId: string | null
|
currentConvId: string | null
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
@@ -379,9 +378,9 @@ async function bindProject(projectId: string, remember: boolean) {
|
|||||||
await store.refreshArtifacts(projectId)
|
await store.refreshArtifacts(projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function latestScratchTask(): Conversation | undefined {
|
function latestScratchTask(): ConversationView | undefined {
|
||||||
return state.tasks.filter(t => t.scratch && !t.archived)
|
return state.tasks.filter(t => t.scratch && !t.archived)
|
||||||
.reduce<Conversation | undefined>((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined)
|
.reduce<ConversationView | undefined>((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { auth } from '../src/auth'
|
import { auth } from '../src/auth'
|
||||||
import { canCreateProject, store } from '../src/store'
|
import { canCreateProject, store } from '../src/store'
|
||||||
import type { Conversation, Project } from '../src/contracts'
|
import type { ConversationView } from '../src/api'
|
||||||
|
import type { Project } from '../src/contracts'
|
||||||
|
|
||||||
const project = (id: string): Project => ({ id, name: id, createdAt: '', updatedAt: '' })
|
const project = (id: string): Project => ({ id, name: id, createdAt: '', updatedAt: '' })
|
||||||
const task = (id: string, projectId: string, scratch = false, updatedAt = ''): Conversation =>
|
const task = (id: string, projectId: string, scratch = false, updatedAt = ''): ConversationView =>
|
||||||
({ id, projectId, title: id, createdAt: '', updatedAt, ...(scratch ? { scratch: true } : {}) })
|
({ id, projectId, title: id, createdAt: '', updatedAt, ...(scratch ? { scratch: true } : {}) })
|
||||||
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
||||||
const detail = (conversation: Conversation) => response({ conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
const detail = (conversation: ConversationView) => response({ conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
||||||
|
|
||||||
let projects: Project[]
|
let projects: Project[]
|
||||||
let tasks: Conversation[]
|
let tasks: ConversationView[]
|
||||||
let me: Record<string, unknown>
|
let me: Record<string, unknown>
|
||||||
let requests: { path: string; init?: RequestInit }[]
|
let requests: { path: string; init?: RequestInit }[]
|
||||||
let scratchCount: number
|
let scratchCount: number
|
||||||
|
|||||||
Reference in New Issue
Block a user