diff --git a/.env.example b/.env.example index 5b446953..866919cd 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,11 @@ UMAPIS_BASE_URL=https://www.umapis.com/v1 UMAPIS_API_KEY_CLAUDE= UMAPIS_API_KEY_GPT= +# === 私有模型中继(独立固定路由;服务端专用) === +# 可填写服务根地址或 /v1 API 根地址;应用会统一规范为 /v1。 +PRIVATE_RELAY_BASE_URL=https://model-relay.internal/v1 +PRIVATE_RELAY_API_KEY= + # === 火山方舟 Coding Plan(OpenAI-compatible) === # 必须使用 Coding Plan 专用的 /api/coding/v3;普通 /api/v3 会产生套餐外费用。 ARK_CODING_API_KEY= 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/attachment-composer-demo/page.tsx b/app/thread-chat/attachment-composer-demo/page.tsx new file mode 100644 index 00000000..2433225b --- /dev/null +++ b/app/thread-chat/attachment-composer-demo/page.tsx @@ -0,0 +1,46 @@ +"use client" + +import { useCallback, useState } from "react" + +import { AttachmentComposerDemo } from "../chat/composer/attachment-composer-demo" +import type { DemoAttachment } from "../chat/composer/attachment-composer-demo-model" + +export default function AttachmentComposerDemoPage() { + const [attachments, setAttachments] = useState([]) + + const handleChange = useCallback((nextAttachments: DemoAttachment[]) => { + setAttachments(nextAttachments) + console.log("attachments changed", nextAttachments) + }, []) + + return ( +
+
+
+

+ ThreadChat · Frontend Demo +

+

+ Attachment Composer +

+

+ 通过多选、拖拽或粘贴添加文件。粘贴的纯文本会转换为本地 .txt + 文件;所有操作仅保留在当前浏览器页面,不会上传或发送消息。 +

+
+ + + +

+ 当前附件:{attachments.length} +

+
+
+ ) +} diff --git a/app/thread-chat/branching/selection/selection-bubble.tsx b/app/thread-chat/branching/selection/selection-bubble.tsx index 7bea8836..489e0f01 100644 --- a/app/thread-chat/branching/selection/selection-bubble.tsx +++ b/app/thread-chat/branching/selection/selection-bubble.tsx @@ -22,7 +22,14 @@ * Esc 交由壳层关闭链关气泡;Enter 有 IME 守卫(isComposing / keyCode 229)。 */ -import React, { useEffect, useLayoutEffect, useRef, useState } from "react" +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react" +import "./selection-draft-guard.css" import { GitMerge } from "lucide-react" import type { ThreadTreeState } from "../../core/types" import { threadTitle } from "../../core/selectors" @@ -74,17 +81,41 @@ export function SelectionBubble({ maxExpanded, lastActiveOf, }: SelectionBubbleProps) { + /** 可选首问(受控 textarea):留空提交 = 现有预填流;非空提交 = 带问开分支 */ + const [question, setQuestion] = useState("") + const hasQuestion = question.trim().length > 0 + /** 有草稿时新划选被忽略的轻提示(悬挂在气泡外,不扰动面板高度/定位) */ + const [draftHint, setDraftHint] = useState(false) + /** 轻提示自动消失计时器(再次忽略新划选时重置) */ + const draftHintTimer = useRef | null>(null) + const showDraftHint = useCallback(() => { + setDraftHint(true) + if (draftHintTimer.current) clearTimeout(draftHintTimer.current) + draftHintTimer.current = setTimeout(() => setDraftHint(false), 2500) + }, []) + useEffect( + () => () => { + if (draftHintTimer.current) clearTimeout(draftHintTimer.current) + }, + [] + ) useAssistantTextSelection({ state, selection: sel, onSelectionChange: onSelChange, + hasDraft: hasQuestion, + onIgnoredSelection: showDraftHint, }) + /** Esc 确认弹窗(有草稿时 Esc 不直接关,先确认清空) */ + const [confirming, setConfirming] = useState(false) + /** 气泡左右抖动中(确认弹窗弹出时触发一次,animationend 归位) */ + const [shaking, setShaking] = useState(false) + /** 入场淡入窗口:tc-pop 播完即拆 .entering 类,让稳态 animation 为 none(见 selection.css) */ + const [entering, setEntering] = useState(true) /** 迷你列条点选的让位列(override);气泡隐藏 / 换一段划选时清空 */ const [override, setOverride] = useState(null) /** ⌘/Ctrl 是否按住(实时跟踪,目标与按钮文案随之切换) */ const [metaHeld, setMetaHeld] = useState(false) - /** 可选首问(受控 textarea):留空提交 = 现有预填流;非空提交 = 带问开分支 */ - const [question, setQuestion] = useState("") const taRef = useRef(null) /** 气泡内容层(面板本体):测其高度 H 喂定位模型与轮廓 path */ const contentRef = useRef(null) @@ -97,6 +128,10 @@ export function SelectionBubble({ setOverride(null) setMetaHeld(sel?.meta ?? false) setQuestion("") + setConfirming(false) + setShaking(false) + setEntering(Boolean(sel)) // 每次新划选都重放一次 tc-pop;关闭态保持 false + setDraftHint(false) setMeasuredH(0) // 换一段划选:高度作废,等重新测量再定位(先隐藏,避免旧位闪现) } @@ -114,14 +149,16 @@ export function SelectionBubble({ return () => ro.disconnect() }, [sel]) - /* 气泡弹出即聚焦输入框(preventScroll:气泡定位刚结算完,不能再引发滚动); - 顺手清掉上一次自增高留下的行内高度(textarea 跨划选不重挂载) */ + /* 气泡测量完成、真正 visible 后再聚焦输入框:首帧 measuredH=0 时根节点 + visibility:hidden,Chromium 会拒绝聚焦其后代;只依赖 sel 会错过后续 visible 帧。 + focusReady 只发生 false → true,不会在 textarea 自增高时反复抢焦点。 */ + const focusReady = Boolean(sel && measuredH > 0) useEffect(() => { const ta = taRef.current - if (!sel || !ta) return + if (!focusReady || !ta) return ta.style.height = "" ta.focus({ preventScroll: true }) - }, [sel]) + }, [sel, focusReady]) /* 气泡打开期间跟踪 ⌘/Ctrl 起落(keydown/keyup 都带 metaKey/ctrlKey 快照) */ useEffect(() => { @@ -138,6 +175,29 @@ export function SelectionBubble({ } }, [sel]) + /* 有草稿时拦截 Esc(capture:先于壳层 use-workspace-overlays 的关闭链): + · 确认弹窗已开 → 再按 Esc 只关确认弹窗(回到编辑,内容保留); + · 未开 → 弹出「清空并关闭」确认,同时气泡左右抖动提醒内容会丢; + · 无草稿 → 不拦截,Esc 照旧直接关气泡。IME 组合态的 Esc 不拦截。 */ + useEffect(() => { + if (!sel) return + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape" || e.isComposing) return + if (!question.trim()) return + e.preventDefault() + e.stopPropagation() + if (confirming) { + setConfirming(false) + return + } + setConfirming(true) + setShaking(true) + setEntering(false) + } + document.addEventListener("keydown", onKey, true) + return () => document.removeEventListener("keydown", onKey, true) + }, [sel, question, confirming]) + if (!sel) return null /* —— 落点:floating-popup 定位模型,只用上/下两向(尾巴竖直指向选区)—— @@ -192,7 +252,6 @@ export function SelectionBubble({ : null /* —— 按钮文案四态(优先级):列条 override > ⌘ 按住 > 有输入 > 默认 —— */ - const hasQuestion = question.trim().length > 0 // 按钮只表达「动作」(两态、长度稳定);「放置后果」下沉到列条下的提示行—— // 变长的列标题在提示行里可单行省略,按钮宽度不再被撑爆(用户定的通用方案) const btnLabel = hasQuestion ? "带着问题开分支" : "开启分支讨论" @@ -224,93 +283,168 @@ export function SelectionBubble({ onFork(sel, h, q || undefined) } + /** 确认清空:关气泡并丢弃草稿(唯一能丢弃草稿的路径) */ + const discardDraft = () => { + setConfirming(false) + setQuestion("") + window.getSelection()?.removeAllRanges() + onSelChange(null) + } + return ( -
- {/* 平滑曲线轮廓(背景层):面板 + 指向选区的尾巴,一条 path。尾巴朝上时 - 整层上移 ah 让顶点探出面板上沿;朝下时留在原位向下探出。 */} + <> -
-
在新分支中讨论这段
-
{sel.text}
-
-