diff --git a/.github/workflows/project-workspace-ci.yml b/.github/workflows/project-workspace-ci.yml new file mode 100644 index 00000000..ecc8ca2f --- /dev/null +++ b/.github/workflows/project-workspace-ci.yml @@ -0,0 +1,135 @@ +name: Project Workspace CI + +on: + push: + branches: + - codex/research-project-workspace-design + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres + DIRECT_URL: postgres://postgres:postgres@localhost:5432/postgres + BETTER_AUTH_SECRET: project-workspace-ci-secret-at-least-32-characters + MINIMAX_API_KEY: project-workspace-build-placeholder + MINIMAX_BASE_URL: https://example.invalid/v1 + LLM_MODEL_ID: project-workspace-build-model + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Targeted ESLint + run: >- + pnpm exec eslint + app/thread-chat/chat/chat-view.tsx + app/thread-chat/core/projections.ts + app/thread-chat/core/store.ts + app/thread-chat/core/types.ts + app/thread-chat/gate-3-harness/mock-v1-runtime.ts + app/thread-chat/net/client.ts + app/thread-chat/net/commands/conversation-commands.ts + app/thread-chat/net/project-file-upload.ts + app/thread-chat/orchestration/artifacts/artifact-drawer.tsx + app/thread-chat/orchestration/artifacts/project-panel.tsx + app/thread-chat/orchestration/artifacts/store-bound-project-panel.tsx + app/thread-chat/orchestration/navigation/thread-chat-topbar.tsx + app/thread-chat/thread-chat-demo.tsx + constants/project-workspace.ts + evals/agent/executors/production-harness.ts + evals/agent/schema.ts + lib/chat/attachment-content-resolver.ts + lib/chat/attachment-context-policy.ts + lib/chat/project-contract.ts + lib/chat/resolve-attachments.ts + lib/db/schema.ts + lib/thread-chat/application/compile-model-context.ts + lib/thread-chat/application/project-mutations.ts + lib/thread-chat/application/queries.ts + lib/thread-chat/contracts/commands.ts + lib/thread-chat/contracts/dto.ts + lib/thread-chat/persistence/artifact-repository.ts + lib/thread-chat/persistence/mappers.ts + lib/thread-chat/persistence/project-file-repository.ts + lib/thread-chat/server/handlers.ts + lib/thread-chat/streaming/finalize.ts + lib/thread-chat/streaming/generation-plan.ts + lib/thread-chat/streaming/run-generation.ts + + - name: Verify generated migration is in sync + run: pnpm db:generate && git diff --exit-code -- drizzle + + - name: Legacy data migration compatibility + run: node --import tsx e2e/thread-chat/project-workspace-migration-compatibility.test.mjs + + - name: Pure project context policies + run: node --import tsx e2e/thread-chat/project-workspace-context.test.mjs + + - name: Project panel UI contract + run: node e2e/thread-chat/project-panel-ui-contract.test.mjs + + - name: Project panel workspace isolation + run: node --import tsx e2e/thread-chat/project-panel-workspace-state.test.mjs + + - name: Project workspace eval harness + run: node --import tsx e2e/observability/project-workspace-eval-harness.test.mjs + + - name: Prepare normalized test database + run: pnpm db:test:reset && pnpm db:test:migrate + + - name: Project workspace schema and repository acceptance + run: node --import tsx e2e/thread-chat/project-workspace-db.test.mjs + + - name: Project workspace API integration + run: node --import tsx e2e/thread-chat/project-workspace-api-db.test.mjs + + - name: Project contract generation boundary + run: node --import tsx e2e/thread-chat/project-contract-generation-boundary.test.mjs + + - name: Project workspace history stability + run: node --import tsx e2e/thread-chat/project-workspace-history-stability.test.mjs + + - name: Project artifact context isolation + run: node --import tsx e2e/thread-chat/project-artifact-context-isolation.test.mjs + + - name: Agent eval smoke + run: pnpm eval:agent + + - name: Agent eval CI + run: pnpm eval:agent:ci + + - name: Production build + run: pnpm build + + - name: Validate OpenSpec + run: pnpm openspec:validate diff --git a/app/api/thread-chat/v1/projects/[projectId]/files/[attachmentId]/route.ts b/app/api/thread-chat/v1/projects/[projectId]/files/[attachmentId]/route.ts new file mode 100644 index 00000000..38f2cdd6 --- /dev/null +++ b/app/api/thread-chat/v1/projects/[projectId]/files/[attachmentId]/route.ts @@ -0,0 +1,11 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleRemoveProjectFile } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +type Context = RouteContext<{ projectId: string; attachmentId: string }> + +export async function DELETE(request: Request, context: Context) { + const { projectId, attachmentId } = await context.params + return handleRemoveProjectFile(request, projectId, attachmentId) +} diff --git a/app/api/thread-chat/v1/projects/[projectId]/files/route.ts b/app/api/thread-chat/v1/projects/[projectId]/files/route.ts new file mode 100644 index 00000000..ba7cf019 --- /dev/null +++ b/app/api/thread-chat/v1/projects/[projectId]/files/route.ts @@ -0,0 +1,11 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleAddProjectFile } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +type Context = RouteContext<{ projectId: string }> + +export async function POST(request: Request, context: Context) { + const { projectId } = await context.params + return handleAddProjectFile(request, projectId) +} diff --git a/app/thread-chat/chat/chat-view.tsx b/app/thread-chat/chat/chat-view.tsx index 3888cec8..ec43dfce 100644 --- a/app/thread-chat/chat/chat-view.tsx +++ b/app/thread-chat/chat/chat-view.tsx @@ -92,23 +92,25 @@ export function ChatView({
{intro} {messages.map((msg) => ( - - - +
+ + + +
))}
diff --git a/app/thread-chat/chat/composer/thread-model-selector.tsx b/app/thread-chat/chat/composer/thread-model-selector.tsx index 50015736..96f3a2c3 100644 --- a/app/thread-chat/chat/composer/thread-model-selector.tsx +++ b/app/thread-chat/chat/composer/thread-model-selector.tsx @@ -12,7 +12,10 @@ import { } from "@/components/ui/tooltip" import { CHAT_MODEL_PROVIDER_LABELS, +<<<<<<< HEAD THREAD_CHAT_MODEL_GROUP_LABELS, +======= +>>>>>>> a30b2c9 (feat(chat): group model selector by provider) THREAD_CHAT_MODELS, } from "@/constants/model" import { Bot } from "lucide-react" @@ -64,12 +67,19 @@ const THREAD_CHAT_MODEL_OPTIONS: readonly ModelOption[] = ) .map(({ model }) => ({ id: model.id, +<<<<<<< HEAD name: model.name.replace( `${CHAT_MODEL_PROVIDER_LABELS[model.provider]} · `, "" ), providerId: model.provider, providerName: THREAD_CHAT_MODEL_GROUP_LABELS[model.provider], +======= + name: model.name, + description: model.description, + providerId: model.provider, + providerName: CHAT_MODEL_PROVIDER_LABELS[model.provider], +>>>>>>> a30b2c9 (feat(chat): group model selector by provider) })) export interface ThreadModelSelectorProps { @@ -140,7 +150,11 @@ export function ThreadModelSelector({ )} >>>>>> a30b2c9 (feat(chat): group model selector by provider) /> ) diff --git a/app/thread-chat/core/projections.ts b/app/thread-chat/core/projections.ts index 0d3d6e24..2f5e2fd5 100644 --- a/app/thread-chat/core/projections.ts +++ b/app/thread-chat/core/projections.ts @@ -53,10 +53,6 @@ function projectMessageState( } } -/** - * 现有工作台组件以 `main` 作为根列的展示标识;规范化模型的根 Thread 则使用 UUID。 - * 这个别名只存在于只读 UI facade,任何 v1 command/DTO 都继续使用真实 Thread ID。 - */ export function toConversationViewThreadId( state: NormalizedThreadChatState, threadId: string @@ -184,15 +180,11 @@ export function projectArtifactDTO( kind: artifact.kind, ...(artifact.language ? { lang: artifact.language } : {}), content: artifact.content, - sourceThreadId: toConversationViewThreadId( - state, - state.messagesById[artifact.sourceMessageId]?.threadId ?? "" - ), + sourceThreadId: toConversationViewThreadId(state, artifact.threadId), sourceMessageId: artifact.sourceMessageId, } } -/** Gate 3 兼容 facade:既有组件不再读取整树持久化,只消费规范化 selector 投影。 */ export function projectConversationTree( state: NormalizedThreadChatState ): ThreadTreeState { diff --git a/app/thread-chat/core/store.ts b/app/thread-chat/core/store.ts index a61aa9a9..e0277dbe 100644 --- a/app/thread-chat/core/store.ts +++ b/app/thread-chat/core/store.ts @@ -5,6 +5,7 @@ import type { MessageDTO, ProjectBootstrapDTO, ProjectDTO, + ProjectFileDTO, ThreadDTO, } from "@/lib/thread-chat/contracts/dto" import type { @@ -57,6 +58,10 @@ function entitiesFromBootstrap( const active = new Set(bootstrap.activeGenerationIds) return { project: bootstrap.project, + projectFilesById: Object.fromEntries( + bootstrap.files.map((file) => [file.attachmentId, file]) + ), + projectFileOrder: bootstrap.files.map((file) => file.attachmentId), threadsById: Object.fromEntries( bootstrap.threads.map((thread) => [thread.id, thread]) ), @@ -79,6 +84,7 @@ function entitiesFromBootstrap( function emptyEntities(): ConversationEntitySnapshot { return entitiesFromBootstrap({ project: null, + files: [], threads: [], messages: [], artifacts: [], @@ -91,6 +97,8 @@ function entitySnapshot( ): ConversationEntitySnapshot { return structuredClone({ project: state.project, + projectFilesById: state.projectFilesById, + projectFileOrder: state.projectFileOrder, threadsById: state.threadsById, messagesById: state.messagesById, messageIdsByThread: state.messageIdsByThread, @@ -157,6 +165,30 @@ export function createConversationStore(input?: { upsertProject(project: ProjectDTO) { set({ project }) }, + upsertProjectFile(file: ProjectFileDTO) { + set((state) => ({ + projectFilesById: { + ...state.projectFilesById, + [file.attachmentId]: file, + }, + projectFileOrder: state.projectFileOrder.includes(file.attachmentId) + ? state.projectFileOrder + : [file.attachmentId, ...state.projectFileOrder], + })) + }, + removeProjectFile(attachmentId: string) { + set((state) => { + if (!state.projectFilesById[attachmentId]) return state + const projectFilesById = { ...state.projectFilesById } + delete projectFilesById[attachmentId] + return { + projectFilesById, + projectFileOrder: state.projectFileOrder.filter( + (id) => id !== attachmentId + ), + } + }) + }, upsertThread(thread: ThreadDTO) { set((state) => ({ threadsById: { ...state.threadsById, [thread.id]: thread }, @@ -183,7 +215,7 @@ export function createConversationStore(input?: { artifactsById: { ...state.artifactsById, [artifact.id]: artifact }, artifactOrder: state.artifactOrder.includes(artifact.id) ? state.artifactOrder - : [...state.artifactOrder, artifact.id], + : [artifact.id, ...state.artifactOrder], })) }, applyStreamSnapshot(messageId, message, throughSeq) { @@ -340,6 +372,19 @@ export function createConversationStore(input?: { sameValue(current.project, patch.after.project) ? structuredClone(patch.before.project) : current.project, + projectFilesById: rollbackRecord( + current.projectFilesById, + patch.before.projectFilesById, + patch.after.projectFilesById + ), + projectFileOrder: + !sameValue( + patch.before.projectFileOrder, + patch.after.projectFileOrder + ) && + sameValue(current.projectFileOrder, patch.after.projectFileOrder) + ? structuredClone(patch.before.projectFileOrder) + : current.projectFileOrder, threadsById: rollbackRecord( current.threadsById, patch.before.threadsById, diff --git a/app/thread-chat/core/types.ts b/app/thread-chat/core/types.ts index 4146e171..b5bba2bb 100644 --- a/app/thread-chat/core/types.ts +++ b/app/thread-chat/core/types.ts @@ -11,17 +11,21 @@ import type { MessageDTO, ProjectBootstrapDTO, ProjectDTO, + ProjectFileDTO, ThreadDTO, } from "@/lib/thread-chat/contracts/dto" import type { ThreadChatUIMessage } from "@/lib/thread-chat/contracts/ui-message" -/** 现有组件消费的兼容投影;uiParts 保留完整 AI SDK v7 协议。 */ +/** 现有组件消费的兼容结构;uiParts 保留完整 AI SDK v7 协议。 */ export interface ConversationViewMessage extends LegacyMessage { uiParts?: ThreadChatUIMessage["parts"] } export type ConversationStreamPhase = - "connecting" | "live" | "background" | "terminal" + | "connecting" + | "live" + | "background" + | "terminal" export interface ConversationStreamState { phase: ConversationStreamPhase @@ -38,6 +42,7 @@ export interface WorkspaceCanvasSnapshot { export interface WorkspacePanelSizes { columns?: number[] artifactDrawer?: number + projectPanel?: number } export interface WorkspaceUiState { @@ -56,6 +61,8 @@ export interface WorkspaceUiState { export interface ConversationEntitySnapshot { project: ProjectDTO | null + projectFilesById: Record + projectFileOrder: string[] threadsById: Record messagesById: Record messageIdsByThread: Record @@ -78,6 +85,8 @@ export interface NormalizedThreadChatState extends ConversationEntityState { workspace: WorkspaceUiState hydrateProject(bootstrap: ProjectBootstrapDTO): void upsertProject(project: ProjectDTO): void + upsertProjectFile(file: ProjectFileDTO): void + removeProjectFile(attachmentId: string): void upsertThread(thread: ThreadDTO): void upsertMessage(message: MessageDTO): void upsertArtifact(artifact: ArtifactDTO): void diff --git a/app/thread-chat/gate-3-harness/mock-v1-runtime.ts b/app/thread-chat/gate-3-harness/mock-v1-runtime.ts index fa49be5b..99d2bb49 100644 --- a/app/thread-chat/gate-3-harness/mock-v1-runtime.ts +++ b/app/thread-chat/gate-3-harness/mock-v1-runtime.ts @@ -55,6 +55,9 @@ function initialBootstrap( rootThreadId: ROOT_THREAD_ID, autoTitle: "规范化会话验收", customTitle: null, + target: null, + instructions: null, + contractVersion: 0, archivedAt: null, createdAt: stamp, updatedAt: stamp, @@ -277,7 +280,11 @@ function initialBootstrap( const artifact: ArtifactDTO = { id: INITIAL_ARTIFACT_ID, projectId, + threadId: ROOT_THREAD_ID, sourceMessageId: ROOT_ASSISTANT_ID, + sourceThreadTitle: "规范化会话验收", + sourceThreadFootnote: null, + sourceMessageStatus: "completed", kind: "markdown", title: "断流恢复验收清单", content: @@ -289,6 +296,7 @@ function initialBootstrap( } return { project, + files: [], threads: [root, child, nested], messages, artifacts: [artifact], @@ -321,6 +329,7 @@ export function createGate3MockRuntime( const bootstrap = (): ProjectBootstrapDTO => ({ project: clone(project), + files: [], threads: [...threads.values()].map(clone), messages: [...messages.values()].map(clone), artifacts: [...artifacts.values()].map(clone), @@ -426,7 +435,11 @@ export function createGate3MockRuntime( artifacts.set(artifactId, { id: artifactId, projectId, + threadId: current.threadId, sourceMessageId: messageId, + sourceThreadTitle: threads.get(current.threadId)?.customTitle ?? threads.get(current.threadId)?.autoTitle ?? null, + sourceThreadFootnote: threads.get(current.threadId)?.footnote ?? null, + sourceMessageStatus: "completed", kind: "markdown", title: "Gate 3 生成报告", content: @@ -521,6 +534,9 @@ export function createGate3MockRuntime( rootThreadId: input.rootThreadId, autoTitle: null, customTitle: null, + target: null, + instructions: null, + contractVersion: 0, archivedAt: null, createdAt: stamp, updatedAt: stamp, @@ -746,6 +762,29 @@ export function createGate3MockRuntime( project = { ...project, customTitle: input.customTitle, updatedAt: now() } return commandResponse(clone(project)) }, + async updateProjectContract(_targetProjectId, input) { + if (!project) throw new Error("PROJECT_NOT_FOUND") + if (project.contractVersion !== input.expectedContractVersion) + throw new Error("PROJECT_CONTRACT_VERSION_CONFLICT") + project = { + ...project, + target: input.target.trim() || null, + instructions: input.instructions.trim() || null, + contractVersion: project.contractVersion + 1, + updatedAt: now(), + } + return commandResponse(clone(project)) + }, + async addProjectFile() { + throw new Error("PROJECT_FILE_UPLOAD_NOT_AVAILABLE_IN_GATE3_HARNESS") + }, + async removeProjectFile(_targetProjectId, attachmentId) { + return commandResponse({ + projectId, + attachmentId, + removed: true as const, + }) + }, async setProjectArchived(_targetProjectId, input) { if (!project) throw new Error("PROJECT_NOT_FOUND") project = { diff --git a/app/thread-chat/net/client.ts b/app/thread-chat/net/client.ts index 0595f3b8..b9a50a36 100644 --- a/app/thread-chat/net/client.ts +++ b/app/thread-chat/net/client.ts @@ -1,7 +1,9 @@ import type { + AddProjectFileCommand, DeleteProjectCommand, EditLatestTurnCommand, ForkThreadCommand, + RemoveProjectFileCommand, RenameProjectCommand, RetryMessageCommand, SendMessageCommand, @@ -9,6 +11,7 @@ import type { SetProjectArchivedCommand, StartProjectCommand, StopMessageCommand, + UpdateProjectContractCommand, UpdateThreadCommand, } from "@/lib/thread-chat/contracts/commands" import type { @@ -17,6 +20,7 @@ import type { MessageDTO, ProjectBootstrapDTO, ProjectDTO, + ProjectFileDTO, ThreadTitleDTO, ThreadDTO, } from "@/lib/thread-chat/contracts/dto" @@ -57,6 +61,12 @@ export interface DeleteAcceptedDTO { deleted: true } +export interface RemoveProjectFileAcceptedDTO { + projectId: string + attachmentId: string + removed: true +} + function apiUrl(baseUrl: string, path: string): string { return `${baseUrl.replace(/\/$/, "")}${path}` } @@ -88,10 +98,13 @@ async function requestJson( const body = await decodeJson(response) if (!response.ok) { const error = (body as { error?: ApiErrorDTO }).error - throw new ThreadChatApiError(response.status, error ?? { - code: "GENERATION_FAILED", - message: "请求失败,请稍后重试", - }) + throw new ThreadChatApiError( + response.status, + error ?? { + code: "GENERATION_FAILED", + message: "请求失败,请稍后重试", + } + ) } return body as T } @@ -219,6 +232,39 @@ export function createThreadChatClient(options: ThreadChatClientOptions = {}) { input ) }, + updateProjectContract( + projectId: string, + input: UpdateProjectContractCommand + ) { + return command( + fetcher, + url(`/api/thread-chat/v1/projects/${projectId}`), + "PATCH", + input + ) + }, + addProjectFile(projectId: string, input: AddProjectFileCommand) { + return command( + fetcher, + url(`/api/thread-chat/v1/projects/${projectId}/files`), + "POST", + input + ) + }, + removeProjectFile( + projectId: string, + attachmentId: string, + input: RemoveProjectFileCommand + ) { + return command( + fetcher, + url( + `/api/thread-chat/v1/projects/${projectId}/files/${attachmentId}` + ), + "DELETE", + input + ) + }, setProjectArchived(projectId: string, input: SetProjectArchivedCommand) { return command( fetcher, diff --git a/app/thread-chat/net/commands/conversation-commands.ts b/app/thread-chat/net/commands/conversation-commands.ts index 0fddbc7c..e546d2e9 100644 --- a/app/thread-chat/net/commands/conversation-commands.ts +++ b/app/thread-chat/net/commands/conversation-commands.ts @@ -1,9 +1,12 @@ import type { + AddProjectFileCommand, EditLatestTurnCommand, ForkThreadCommand, + RemoveProjectFileCommand, RetryMessageCommand, SendMessageCommand, StartProjectCommand, + UpdateProjectContractCommand, } from "@/lib/thread-chat/contracts/commands" import type { MessageDTO, @@ -131,6 +134,11 @@ function supersede( return message ? { ...message, supersededAt: at, updatedAt: at } : undefined } +function normalized(value: string): string | null { + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + export function createConversationCommands( options: ConversationCommandOptions ) { @@ -153,6 +161,17 @@ export function createConversationCommands( throw lastError } + async function refreshProjectArtifacts(projectId: string) { + try { + const bootstrap = await client.getProject(projectId) + if (bootstrap.project) store.getState().upsertProject(bootstrap.project) + for (const artifact of bootstrap.artifacts) + store.getState().upsertArtifact(artifact) + } catch { + // Artifact 资源区刷新是非阻塞增强;历史消息仍保留工具结果。 + } + } + function follow( accepted: Parameters[0]["accepted"], afterFinish?: (threadId: string) => void | Promise @@ -162,9 +181,10 @@ export function createConversationCommands( store, client, accepted, - onFinishMessage: afterFinish - ? (message) => afterFinish(message.threadId) - : undefined, + onFinishMessage: async (message) => { + if (afterFinish) await afterFinish(message.threadId) + await refreshProjectArtifacts(message.projectId) + }, fetch: options.fetch, pollDelays: options.pollDelays, wait: options.wait, @@ -212,6 +232,9 @@ export function createConversationCommands( rootThreadId: command.rootThreadId, autoTitle: null, customTitle: null, + target: null, + instructions: null, + contractVersion: 0, archivedAt: null, createdAt: now, updatedAt: now, @@ -252,6 +275,8 @@ export function createConversationCommands( }) store.getState().beginOptimisticCommand(command.commandId, () => ({ project, + projectFilesById: {}, + projectFileOrder: [], threadsById: { [thread.id]: thread }, messagesById: { [user.id]: user, [assistant.id]: assistant }, messageIdsByThread: { [thread.id]: [user.id, assistant.id] }, @@ -588,6 +613,73 @@ export function createConversationCommands( return { command, response } } + async function updateProjectContract(input: { + projectId: string + target: string + instructions: string + expectedContractVersion?: number + }) { + const current = store.getState().project + if (!current || current.id !== input.projectId) + throw new Error("Project 尚未加载") + const command: UpdateProjectContractCommand = Object.freeze({ + commandId: createId(), + expectedContractVersion: + input.expectedContractVersion ?? current.contractVersion, + target: input.target, + instructions: input.instructions, + }) + const optimistic: ProjectDTO = { + ...current, + target: normalized(input.target), + instructions: normalized(input.instructions), + contractVersion: current.contractVersion + 1, + updatedAt: new Date().toISOString(), + } + store.getState().beginOptimisticCommand(command.commandId, () => ({ + project: optimistic, + })) + try { + const response = await execute(() => + client.updateProjectContract(input.projectId, command) + ) + store.getState().commitOptimisticCommand(command.commandId) + store.getState().upsertProject(response.data) + return { command, response } + } catch (error) { + store.getState().rollbackOptimisticCommand(command.commandId) + throw error + } + } + + async function addProjectFile(attachmentId: string) { + const project = store.getState().project + if (!project) throw new Error("Project 尚未加载") + const command: AddProjectFileCommand = Object.freeze({ + commandId: createId(), + attachmentId, + }) + const response = await execute(() => + client.addProjectFile(project.id, command) + ) + store.getState().upsertProjectFile(response.data) + return { command, response } + } + + async function removeProjectFile(attachmentId: string) { + const project = store.getState().project + if (!project) throw new Error("Project 尚未加载") + const command: RemoveProjectFileCommand = Object.freeze({ + commandId: createId(), + attachmentId, + }) + const response = await execute(() => + client.removeProjectFile(project.id, attachmentId, command) + ) + store.getState().removeProjectFile(attachmentId) + return { command, response } + } + async function setProjectArchived(projectId: string, archived: boolean) { const command = Object.freeze({ commandId: createId(), archived }) const response = await execute(() => @@ -618,6 +710,9 @@ export function createConversationCommands( setFeedback, updateThread, renameProject, + updateProjectContract, + addProjectFile, + removeProjectFile, setProjectArchived, deleteProject, dispose() { diff --git a/app/thread-chat/net/project-file-upload.ts b/app/thread-chat/net/project-file-upload.ts new file mode 100644 index 00000000..1354369d --- /dev/null +++ b/app/thread-chat/net/project-file-upload.ts @@ -0,0 +1,70 @@ +"use client" + +import { ATTACHMENT_POLICIES } from "@/constants/attachment" + +async function readError(response: Response, fallback: string) { + const body = (await response.json().catch(() => null)) as + | { error?: string } + | null + return body?.error ?? fallback +} + +export interface ProjectFileUploadCallbacks { + onAttachmentCreated?(attachmentId: string): Promise | void +} + +/** + * Reuse the existing Attachment + R2 + ingest pipeline for Project Files. + * Membership is established as soon as the Attachment row exists, so the + * Project workspace can truthfully expose the uploading lifecycle. + */ +export async function uploadProjectFile( + file: File, + callbacks: ProjectFileUploadCallbacks = {} +): Promise { + const policy = ATTACHMENT_POLICIES[file.type] + if (!policy) throw new Error(`不支持的文件类型:${file.type || "未知"}`) + if (file.size > policy.maxBytes) { + throw new Error( + `文件超过大小上限(${Math.floor(policy.maxBytes / (1024 * 1024))}MB)` + ) + } + + const createResponse = await fetch("/api/attachments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filename: file.name, + contentType: file.type, + size: file.size, + }), + }) + if (!createResponse.ok) { + throw new Error(await readError(createResponse, "创建附件失败")) + } + + const { id, uploadUrl } = (await createResponse.json()) as { + id: string + uploadUrl: string + } + + await callbacks.onAttachmentCreated?.(id) + + const uploadResponse = await fetch(uploadUrl, { + method: "PUT", + headers: { "Content-Type": file.type }, + body: file, + }) + if (!uploadResponse.ok) { + throw new Error(`上传失败(HTTP ${uploadResponse.status})`) + } + + const ingestResponse = await fetch(`/api/attachments/${id}/ingest`, { + method: "POST", + }) + if (!ingestResponse.ok) { + throw new Error(await readError(ingestResponse, "附件处理失败")) + } + + return id +} diff --git a/app/thread-chat/orchestration/artifacts/artifact-drawer.tsx b/app/thread-chat/orchestration/artifacts/artifact-drawer.tsx index 614e7045..0db22051 100644 --- a/app/thread-chat/orchestration/artifacts/artifact-drawer.tsx +++ b/app/thread-chat/orchestration/artifacts/artifact-drawer.tsx @@ -1,31 +1,32 @@ "use client" -/** - * orchestration/artifact-drawer —— Artifact 右侧抽屉「舞台」(全局唯一)。 - * 标签页管理全部 artifact(深度色圆点标来源会话),Markdown 走统一富文本渲染, - * 底部「定位来源会话」走壳层的统一打开意图。 - */ -import React, { useEffect, useId, useRef } from "react" -import { FileText, LocateFixed, X } from "lucide-react" -import type { Artifact, ThreadTreeState } from "../../core/types" -import { MarkdownBody } from "../../chat/message/markdown-body" -import { dotColorOf } from "../../theme" -import { - activePathArtifacts, - artifactSourceProvenance, -} from "../../core/selectors" +import { useMemo } from "react" +import type { ArtifactDTO, ProjectDTO } from "@/lib/thread-chat/contracts/dto" +import type { MessageStatus, ThreadTreeState } from "../../core/types" +import { ProjectPanel } from "./project-panel" export interface ArtifactDrawerProps { state: ThreadTreeState open: boolean - /** 当前激活的 artifact id(null 时回退到第一个) */ activeId: string | null - onClose: () => void - onSelect: (id: string) => void - /** 定位来源会话(壳层用 openBranchUI 打开) */ - onLocate: (threadId: string, sourceMessageId: string) => void + onClose(): void + onSelect(id: string): void + onLocate(threadId: string, sourceMessageId: string): void +} + +const HARNESS_TIMESTAMP = "1970-01-01T00:00:00.000Z" + +function sourceStatus(status: MessageStatus | undefined): ArtifactDTO["sourceMessageStatus"] { + if (status === "stopped") return "stopped" + if (status === "error") return "failed" + if (status === "pending" || status === "streaming") return "generating" + return "completed" } +/** + * Gate 3 旧 harness 的薄适配层。生产入口只使用 StoreBoundProjectPanel;这里仅把 + * 旧 ThreadTreeState 转成统一 ProjectPanel 所需 DTO,避免保留第二套 Drawer UI。 + */ export function ArtifactDrawer({ state, open, @@ -34,135 +35,70 @@ export function ArtifactDrawer({ onSelect, onLocate, }: ArtifactDrawerProps) { - const titleId = useId() - const closeButtonRef = useRef(null) - const returnFocusRef = useRef(null) - const wasOpenRef = useRef(false) - - useEffect(() => { - if (open) { - if (!wasOpenRef.current) { - returnFocusRef.current = - document.activeElement instanceof HTMLElement - ? document.activeElement - : null - } - wasOpenRef.current = true - const frame = requestAnimationFrame(() => closeButtonRef.current?.focus()) - return () => cancelAnimationFrame(frame) - } - if (wasOpenRef.current) { - wasOpenRef.current = false - returnFocusRef.current?.focus() - returnFocusRef.current = null - } - }, [open]) - - const activeArtifacts = activePathArtifacts(state) - const visibleArtifacts = activeId - ? [ - ...activeArtifacts, - ...(!activeArtifacts.some((artifact) => artifact.id === activeId) && - state.artifacts[activeId] - ? [state.artifacts[activeId]] - : []), - ] - : activeArtifacts - const a: Artifact | null = - (activeId && state.artifacts[activeId]) || visibleArtifacts[0] || null - const src = a ? state.threads[a.sourceThreadId] : null - const provenance = a ? artifactSourceProvenance(state, a) : null + const root = state.threads.main ?? Object.values(state.threads).find((thread) => thread.parentId === null) + const project = useMemo( + () => + root + ? { + id: "gate-3-harness-project", + rootThreadId: root.id, + autoTitle: root.title || null, + customTitle: null, + target: null, + instructions: null, + contractVersion: 0, + archivedAt: null, + createdAt: HARNESS_TIMESTAMP, + updatedAt: HARNESS_TIMESTAMP, + } + : null, + [root] + ) + const artifacts = useMemo( + () => + state.artifactOrder.flatMap((artifactId) => { + const artifact = state.artifacts[artifactId] + if (!artifact) return [] + const thread = state.threads[artifact.sourceThreadId] + const sourceMessage = thread?.messages.find( + (message) => message.id === artifact.sourceMessageId + ) + return [ + { + id: artifact.id, + projectId: project?.id ?? "gate-3-harness-project", + threadId: artifact.sourceThreadId, + sourceMessageId: artifact.sourceMessageId, + sourceThreadTitle: thread?.title ?? null, + sourceThreadFootnote: thread?.footnote ?? null, + sourceMessageStatus: sourceStatus(sourceMessage?.status), + kind: artifact.kind, + title: artifact.title, + content: artifact.content, + language: artifact.lang ?? null, + metadata: {}, + createdAt: HARNESS_TIMESTAMP, + updatedAt: HARNESS_TIMESTAMP, + }, + ] + }), + [project?.id, state.artifactOrder, state.artifacts, state.threads] + ) return ( -
-
- -

Markdown

- -
- {visibleArtifacts.length > 0 && ( -
- {visibleArtifacts.map((art) => { - const aid = art.id - const sb = state.threads[art.sourceThreadId] - return ( - - ) - })} -
- )} -
- {!a && ( -
- 还没有 Markdown——在主线或分支里生成后会出现在这里。 -
- )} - {a && a.kind === "code" &&
{a.content}
} - {a && a.kind === "note" && ( -
- {a.content.split("\n\n").map((p, i) => ( -

{p}

- ))} -
- )} - {a && a.kind === "markdown" && ( -
- -
- )} -
- {a && src && ( -
- - - 来源会话:{src.title} - {src.footnote !== null ? ` · 脚注 ${src.footnote}` : ""} - {provenance && !provenance.isOnActivePath ? " · 来自历史回复" : ""} - - -
- )} -
+ Promise.resolve()} + onSaveContract={() => Promise.resolve()} + onAddProjectFile={() => Promise.resolve()} + onRemoveProjectFile={() => Promise.resolve()} + /> ) } diff --git a/app/thread-chat/orchestration/artifacts/project-panel.tsx b/app/thread-chat/orchestration/artifacts/project-panel.tsx new file mode 100644 index 00000000..5ae7293a --- /dev/null +++ b/app/thread-chat/orchestration/artifacts/project-panel.tsx @@ -0,0 +1,590 @@ +"use client" + +import React, { useEffect, useId, useMemo, useRef, useState } from "react" +import { + ExternalLink, + FileText, + FolderKanban, + LocateFixed, + Paperclip, + Pencil, + Search, + Trash2, + Upload, + X, +} from "lucide-react" +import { ATTACHMENT_ACCEPT } from "@/constants/attachment" +import { + PROJECT_INSTRUCTIONS_MAX_CHARS, + PROJECT_TARGET_MAX_CHARS, + PROJECT_WORKSPACE_COPY, +} from "@/constants/project-workspace" +import type { + ArtifactDTO, + ProjectDTO, + ProjectFileDTO, +} from "@/lib/thread-chat/contracts/dto" +import { MarkdownBody } from "../../chat/message/markdown-body" +import { uploadProjectFile } from "../../net/project-file-upload" + +export interface ProjectPanelProps { + project: ProjectDTO | null + files: ProjectFileDTO[] + artifacts: ArtifactDTO[] + open: boolean + activeId: string | null + onClose(): void + onSelect(id: string): void + onLocate(threadId: string, sourceMessageId: string): void + onRefresh(): Promise + onSaveContract(target: string, instructions: string): Promise + onAddProjectFile(attachmentId: string): Promise + onRemoveProjectFile(attachmentId: string): Promise +} + +type ProjectPanelSection = "overview" | "files" | "artifacts" + +function formatBytes(size: number) { + if (size < 1024) return `${size} B` + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)) +} + +function sourceStatusLabel(status: ArtifactDTO["sourceMessageStatus"]) { + if (status === "completed") return "已完成" + if (status === "stopped") return "已停止" + if (status === "failed") return "失败" + return "生成中" +} + +function artifactKindLabel(kind: ArtifactDTO["kind"]) { + if (kind === "markdown") return "Markdown" + if (kind === "code") return "Code" + return "Note" +} + +function fileStatusLabel(file: ProjectFileDTO) { + if (file.status === "ready") return "可用" + if (file.status === "failed") return "失败" + return "处理中" +} + +export function ProjectPanel({ + project, + files, + artifacts, + open, + activeId, + onClose, + onSelect, + onLocate, + onRefresh, + onSaveContract, + onAddProjectFile, + onRemoveProjectFile, +}: ProjectPanelProps) { + const titleId = useId() + const fileInputRef = useRef(null) + const closeButtonRef = useRef(null) + const returnFocusRef = useRef(null) + const wasOpenRef = useRef(false) + const [section, setSection] = useState("overview") + const [editing, setEditing] = useState(false) + const [targetDraft, setTargetDraft] = useState("") + const [instructionsDraft, setInstructionsDraft] = useState("") + const [saving, setSaving] = useState(false) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + const [artifactQuery, setArtifactQuery] = useState("") + const archived = Boolean(project?.archivedAt) + const loading = open && !project + const displayedSection: ProjectPanelSection = activeId ? "artifacts" : section + + useEffect(() => { + if (!open) return + let cancelled = false + void onRefresh() + .then(() => { + if (!cancelled) setError(null) + }) + .catch((cause) => { + if (!cancelled) { + setError(cause instanceof Error ? cause.message : "Project 加载失败") + } + }) + return () => { + cancelled = true + } + }, [onRefresh, open]) + + useEffect(() => { + if (open) { + if (!wasOpenRef.current) { + returnFocusRef.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null + } + wasOpenRef.current = true + const frame = requestAnimationFrame(() => closeButtonRef.current?.focus()) + return () => cancelAnimationFrame(frame) + } + if (wasOpenRef.current) { + wasOpenRef.current = false + returnFocusRef.current?.focus() + returnFocusRef.current = null + } + }, [open]) + + const selectedArtifact = useMemo( + () => artifacts.find((artifact) => artifact.id === activeId) ?? null, + [activeId, artifacts] + ) + const sortedArtifacts = useMemo( + () => + [...artifacts] + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .filter((artifact) => { + const query = artifactQuery.trim().toLowerCase() + if (!query) return true + return [artifact.title, artifact.kind, artifact.sourceThreadTitle ?? ""] + .join(" ") + .toLowerCase() + .includes(query) + }), + [artifactQuery, artifacts] + ) + const sortedFiles = useMemo( + () => [...files].sort((left, right) => right.addedAt.localeCompare(left.addedAt)), + [files] + ) + + const selectSection = (next: ProjectPanelSection) => { + if (next !== "artifacts" && activeId) onSelect("") + setSection(next) + } + + const beginEdit = () => { + if (!project) return + setTargetDraft(project.target ?? "") + setInstructionsDraft(project.instructions ?? "") + setError(null) + setEditing(true) + } + + const cancelEdit = () => { + setTargetDraft(project?.target ?? "") + setInstructionsDraft(project?.instructions ?? "") + setError(null) + setEditing(false) + } + + const saveContract = async () => { + if (!project || archived) return + setSaving(true) + setError(null) + try { + await onSaveContract(targetDraft, instructionsDraft) + setEditing(false) + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : PROJECT_WORKSPACE_COPY.contractConflict + ) + } finally { + setSaving(false) + } + } + + const upload = async (file: File) => { + if (!project || archived) return + setUploading(true) + setError(null) + try { + await uploadProjectFile(file, { + onAttachmentCreated: onAddProjectFile, + }) + await onRefresh() + } catch (cause) { + setError(cause instanceof Error ? cause.message : "文件上传失败") + await onRefresh().catch(() => {}) + } finally { + setUploading(false) + if (fileInputRef.current) fileInputRef.current.value = "" + } + } + + const remove = async (file: ProjectFileDTO) => { + if (!project || archived) return + const confirmed = window.confirm( + `从 Project 中移除「${file.filename}」?历史消息中的附件不会被删除。` + ) + if (!confirmed) return + setError(null) + try { + await onRemoveProjectFile(file.attachmentId) + } catch (cause) { + setError(cause instanceof Error ? cause.message : "移除文件失败") + } + } + + const locateArtifact = (artifact: ArtifactDTO) => { + const viewThreadId = + project?.rootThreadId === artifact.threadId ? "main" : artifact.threadId + onLocate(viewThreadId, artifact.sourceMessageId) + } + + return ( +
+
+ +

+ Project + {project && ( + v{project.contractVersion} + )} +

+ {archived && 只读} + +
+ +
+ + + +
+ + {error &&
{error}
} + {archived && ( +
+ {PROJECT_WORKSPACE_COPY.archivedReadOnly} +
+ )} + +
+ {loading ?
Project 加载中…
: null} + + {displayedSection === "overview" && !loading && ( +
+
+
+
PROJECT CONTRACT
+

目标与长期指令

+

保存后只影响之后启动的生成,不改写历史消息、Artifact 或 Fork Context。

+
+ {!archived && !editing && project && ( + + )} +
+ +