From 0a38e14b3e43b5e3f5231dd39aae55cfc31ba08e Mon Sep 17 00:00:00 2001 From: Titan Date: Thu, 19 Mar 2026 19:14:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E7=AD=89=E5=BE=85=20thread=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=AF=E8=AF=BB=E4=BB=A5=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E8=BF=87=E6=97=A9=E5=B1=95=E7=A4=BAchat=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memo.md | 90 --------------------------------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 memo.md diff --git a/memo.md b/memo.md deleted file mode 100644 index 79becbe6..00000000 --- a/memo.md +++ /dev/null @@ -1,90 +0,0 @@ -# 当前改动总结(2026-03-09) - -## TODO备忘:AIO sandbox端口分配并发竞态 -- 问题:sandbox容器启动时端口分配(get_free_port + docker run)非原子操作,存在并发竞态。 - - 多个会话并发检测到端口空闲,可能同时尝试分配同一端口,导致后一个容器启动失败(端口已被占用)。 -- 建议: - - 增加端口分配锁(如文件锁、Redis等),保证端口分配与容器启动原子性。 - - 或在容器启动失败后自动重试分配新端口。 - - 适用于高并发场景,低并发下概率极低。 -# 当前改动总结(2026-03-09) - -## 7) 近期补充变更(2026-03-13) -- langgraph 会话持久化: - - 支持 langgraph API 层会话落盘,重启后历史线程/消息可恢复。 - - 通过 .langgraph_api 挂载和主进程 exec 启动,保证 SIGTERM 优雅关闭和持久化。 -- skill 扫描范围扩展: - - skill 扫描目录新增 /mnt/user-data/uploads 路径,支持用户上传 skill-yaml、skill 文件自动纳入扫描。 - - 兼容 skill-package.yaml、skill.zip 等多种格式,自动生成 skill 目录。 -- 外部创建 langgraph 会话: - - 支持通过 API/外部服务创建 langgraph 会话,thread_id 可由外部指定。 - - 前端/第三方系统可直接初始化会话并绑定 skill_id,实现多入口集成。 -# 当前改动总结(2026-03-09) - -## 6) Skill YAML 自动导入与远程初始化(2026-03-13) -- 目标:支持上传 skill-yaml.yaml,一键导入为 skill 目录,并支持 skill_id/languageType 参数自动远程初始化。 -- 前端: - - 新增 materializeSkillYaml/ bootstrapRemoteSkill API,支持 skill-yaml 文件解析与远程内容拉取。 - - 在 chats/[thread_id]/page.tsx 页面加载时自动触发 skill 初始化(有 skill_id 参数时),并用 useEffect/Ref 保证只初始化一次。 - - 提交时增加空消息 guard,避免页面初始化时误触发 submit。 - - 上传文件卡片支持 YAML 文件解析(materializeSkillYaml),但按钮默认注释,后续可按需开放。 - - 初始化期间禁用输入框,UI 显示“正在初始化 Skill 文件...”或失败提示。 -- 后端: - - gateway/config.py 增加 skill_content_api_url 配置,支持环境变量覆盖。 - - routers/skills.py 新增 /api/skills/materialize-yaml 与 /api/skills/bootstrap-remote 两个 POST 接口,分别支持本地 YAML 解析与远程内容拉取+目录生成。 - - skill_yaml_importer.py 增强解析器,支持 package.structure、path/name/children、root sentinel、别名等多种 YAML schema,兼容复杂 skill-package.yaml。 - - 解析异常时返回详细错误,前端可捕获并提示。 -- 流程说明: - 1. 用户上传 skill-yaml.yaml,可在文件卡片触发“导入为 Skill 目录”API(materializeSkillYaml)。(前端已注释掉相关代码) - 2. 页面有 skill_id/languageType 参数时,自动调用 bootstrapRemoteSkill,拉取远程 YAML 并生成目录。 - 3. skill 初始化总在 skill 扫描前完成,且只触发一次,支持新/旧线程。 - 4. 提交时空消息 guard,避免页面加载时误触发 submit。 -- 文件: - - frontend/src/app/workspace/chats/[thread_id]/page.tsx - - frontend/src/core/skills/api.ts - - frontend/src/components/workspace/messages/message-list-item.tsx - - frontend/src/core/threads/hooks.ts - - backend/src/gateway/config.py - - backend/src/gateway/routers/skills.py - - backend/src/gateway/skill_yaml_importer.py -- 效果:skill-yaml 自动导入、远程 skill 初始化、页面加载触发、解析器兼容多 schema,前后端 API 完整闭环。 -# 当前改动总结(2026-03-09) - - -## 1) AIO sandbox 网络访问修复(已完成) -- 问题:容器内访问 `localhost` 实际指向容器自身,无法访问宿主机上的 sandbox 端口。 -- 调整:将 sandbox 访问地址改为 `host.docker.internal`。 -- 文件:`backend/src/community/aio_sandbox/local_backend.py` -- 影响:`create()` / `discover()` 走宿主机映射端口可达。 - -## 2) AIO sandbox Docker 权限与连通性修复(已完成) -- 问题:`gateway` / `langgraph` 容器无法直接访问 Docker daemon,无法管理 AIO sandbox 容器。 -- 调整:为两个服务挂载 Docker socket,并设置 `DOCKER_HOST`。 -- 文件:`docker/docker-compose-dev.yaml` -- 关键配置: - - volume: `/var/run/docker.sock:/var/run/docker.sock:ro` - - env: `DOCKER_HOST=unix:///var/run/docker.sock` - -## 3) 会话持久化最终生效方案(已验证) -- 目标:`make docker-stop && make docker-start` 后 `/workspace/chats` 保留历史线程。 -- 最终生效改动(都在 `langgraph` 服务): - 1. 启动命令改为 `exec uv run langgraph dev ... --no-reload ...` - 2. 挂载持久化目录:`../backend/.langgraph_api:/app/backend/.langgraph_api` -- 文件:`docker/docker-compose-dev.yaml` - -### 原理说明 -- `.langgraph_api` 挂载:把 inmem runtime 的落盘文件映射到宿主机,容器重建后仍保留。 -- `exec`:让 LangGraph 主进程直接接收 `SIGTERM`,触发优雅关闭与 `PersistentDict` 写盘。 -- `--no-reload`:避免热重载多进程导致的停机时序问题,保证落盘稳定。 - -## 5) Docker下目录权限修复(已完成) -- 目标:确保 sandbox 容器/Pod 挂载的 `/mnt/user-data` 及其子目录可被非 root 用户正常读写,避免上传/写入失败。 -- 文件: - - `backend/src/community/aio_sandbox/aio_sandbox_provider.py` - - `backend/src/community/aio_sandbox/local_backend.py` - - `docker/provisioner/app.py` -- 关键措施: - 1. 启动 sandbox 时自动创建 thread 挂载目录并 `chmod 777`,解析为 host 路径,保证权限生效。 - 2. 容器启动后用 `docker exec` 执行 `mkdir -p` 和 `chmod 777`,多次重试,确保 `/mnt/user-data/uploads/workspace/outputs` 可写。 - 3. K8s Pod spec 新增 init container,以 root 权限初始化 `/mnt/user-data` 并 `chmod -R 777`,安全上下文限制特权。 -- 效果:sandbox 挂载目录无论本地还是 K8s,均可被非 root 用户正常写入,上传/输出/工作区权限问题彻底解决。 \ No newline at end of file From f67aa274344673fd753b159c4429c2fdf8a47979 Mon Sep 17 00:00:00 2001 From: Titan Date: Thu, 19 Mar 2026 19:15:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E7=AD=89=E5=BE=85=20thread=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=AF=E8=AF=BB=E4=BB=A5=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E8=BF=87=E6=97=A9=E5=B1=95=E7=A4=BAchat=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 22 ++++---- .../app/workspace/chats/[thread_id]/page.tsx | 52 +++++++++++-------- frontend/src/core/threads/hooks.ts | 28 ++++++++++ 3 files changed, 71 insertions(+), 31 deletions(-) diff --git a/Makefile b/Makefile index a7a09705..b5fd1806 100644 --- a/Makefile +++ b/Makefile @@ -263,11 +263,11 @@ docker-logs-gateway: # ========================================== # Docker Publish Command # ========================================== -# Usage: make docker-publish VER=v220.20251202 SVC=frontend -# Example: make docker-publish VER=v220.20251202 SVC=frontend +# Usage: make docker-publish VER=[version] SVC=[service name] [PUSH=1] +# Example: make docker-publish VER=v2.0.20251202 SVC=frontend PUSH=0 docker-publish: @if [ -z "$(VER)" ]; then \ - echo "✗ VER is required (e.g. v220.20251202)"; \ + echo "✗ VER is required (e.g. v2.0.20251202)"; \ exit 1; \ fi @if [ -z "$(SVC)" ]; then \ @@ -293,9 +293,13 @@ docker-publish: echo "✗ Docker build failed"; \ exit 1; \ fi; \ - docker push $$IMAGE; \ - if [ $$? -ne 0 ]; then \ - echo "✗ Docker push failed"; \ - exit 1; \ - fi; \ - echo "✓ Docker image $$IMAGE built and pushed successfully" \ No newline at end of file + if [ "$(PUSH)" = "0" ]; then \ + echo "✓ Docker image $$IMAGE built successfully (not pushed)"; \ + else \ + docker push $$IMAGE; \ + if [ $$? -ne 0 ]; then \ + echo "✗ Docker push failed"; \ + exit 1; \ + fi; \ + echo "✓ Docker image $$IMAGE built and pushed successfully"; \ + fi \ No newline at end of file diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 7b9f9c4b..354aa80b 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -32,6 +32,7 @@ import { DevTodoList } from "@/components/workspace/dev-todo-list"; import { IframeTestPanel } from "@/components/workspace/iframe-test-panel"; import { InputBox } from "@/components/workspace/input-box"; import { MessageList } from "@/components/workspace/messages"; +import { MessageListSkeleton } from "@/components/workspace/messages/skeleton"; import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; @@ -165,6 +166,10 @@ export default function ChatPage() { const [hasSubmitted, setHasSubmitted] = useState(false); const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted; + const suppressNewThreadSubmitUi = + isNewThread && createNewSession && hasSubmitted; + const suppressConversationUi = + suppressExistingThreadPrefetchUi || suppressNewThreadSubmitUi; useEffect(() => { const pageTitle = isNewThread @@ -172,7 +177,7 @@ export default function ChatPage() { : thread.values?.title && thread.values.title !== "Untitled" ? thread.values.title : t.pages.untitled; - if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) { + if (thread.isThreadLoading && !suppressConversationUi) { document.title = `Loading... - ${t.pages.appName}`; } else { document.title = `${pageTitle} - ${t.pages.appName}`; @@ -184,19 +189,21 @@ export default function ChatPage() { t.pages.appName, thread.values.title, thread.isThreadLoading, - suppressExistingThreadPrefetchUi, + suppressConversationUi, ]); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); useEffect(() => { - setArtifacts(thread.values.artifacts); - if ( - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && - autoSelectFirstArtifact - ) { - if (thread?.values?.artifacts?.length > 0) { - setAutoSelectFirstArtifact(false); - selectArtifact(thread.values.artifacts[0]!); + if (!suppressConversationUi) { + setArtifacts(thread.values.artifacts); + if ( + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && + autoSelectFirstArtifact + ) { + if (thread?.values?.artifacts?.length > 0) { + setAutoSelectFirstArtifact(false); + selectArtifact(thread.values.artifacts[0]!); + } } } }, [ @@ -337,20 +344,21 @@ export default function ChatPage() { )} >
- + ) : ( + + } + paddingBottom={todoListCollapsed ? 160 : 280} + /> + )}
diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index d2542104..1df09deb 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -18,6 +18,29 @@ import type { AgentThreadState, } from "./types"; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForThreadStateToBeReadable( + apiClient: ReturnType, + threadId: string, + timeoutMs = 3000, +) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + const state = await apiClient.threads.getState(threadId); + if ((state.values.messages?.length ?? 0) > 0) { + return; + } + } catch { + // Ignore transient 404 / not-ready errors while the new thread is being persisted. + } + + await sleep(100); + } +} + export function useThreadStream({ threadId, isNewThread, @@ -188,6 +211,11 @@ export function useSubmitThread({ }, }, ); + + if (createNewSession && isNewThread && threadId) { + await waitForThreadStateToBeReadable(apiClient, threadId); + } + void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); afterSubmit?.(); },