Compare commits
8 Commits
6130f12790
...
4318b8392a
| Author | SHA1 | Date |
|---|---|---|
|
|
4318b8392a | |
|
|
4950350494 | |
|
|
590001c130 | |
|
|
cb758af645 | |
|
|
5bfdba9cdb | |
|
|
96ea81d677 | |
|
|
cea46914fe | |
|
|
c41475b552 |
43
Makefile
43
Makefile
|
|
@ -255,8 +255,47 @@ docker-stop:
|
|||
docker-logs:
|
||||
@./scripts/docker.sh logs
|
||||
|
||||
# View Docker development logs
|
||||
docker-logs-frontend:
|
||||
@./scripts/docker.sh logs --frontend
|
||||
docker-logs-gateway:
|
||||
@./scripts/docker.sh logs --gateway
|
||||
@./scripts/docker.sh logs --gateway
|
||||
|
||||
# ==========================================
|
||||
# Docker Publish Command
|
||||
# ==========================================
|
||||
# Usage: make docker-publish VER=v220.20251202 SVC=frontend
|
||||
# Example: make docker-publish VER=v220.20251202 SVC=frontend
|
||||
docker-publish:
|
||||
@if [ -z "$(VER)" ]; then \
|
||||
echo "✗ VER is required (e.g. v220.20251202)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ -z "$(SVC)" ]; then \
|
||||
echo "✗ SVC is required (frontend, gateway, langgraph)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "=========================================="
|
||||
@echo " Building Docker image for $(SVC)"
|
||||
@echo "=========================================="
|
||||
@IMAGE=registry.xueai.art/deerflow/deerflow-$(SVC):$(VER); \
|
||||
DOCKERFILE=$$(case "$(SVC)" in \
|
||||
frontend) echo "frontend/Dockerfile.prod";; \
|
||||
gateway) echo "backend/Dockerfile";; \
|
||||
langgraph) echo "backend/Dockerfile";; \
|
||||
*) echo "";; \
|
||||
esac); \
|
||||
if [ -z "$$DOCKERFILE" ]; then \
|
||||
echo "✗ Unknown SVC: $(SVC)"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
docker build -f $$DOCKERFILE -t $$IMAGE .; \
|
||||
if [ $$? -ne 0 ]; then \
|
||||
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"
|
||||
|
|
@ -8,20 +8,7 @@ RUN apt-get update && apt-get install -y \
|
|||
docker.io \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv
|
||||
# Use IP address for proxy (cai.local may not resolve in Docker container)
|
||||
ENV http_proxy=http://192.168.1.250:7897 https_proxy=http://192.168.1.250:7897
|
||||
# Exclude localhost and container network from proxy
|
||||
ENV no_proxy=localhost,127.0.0.1,0.0.0.0,frontend,gateway,langgraph,nginx,.local
|
||||
# RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# Try to install uv via official installer, fallback to pip if fails
|
||||
RUN (curl -LsSf https://astral.sh/uv/install.sh | sh || pip install uv) && \
|
||||
echo "uv installed at:" && \
|
||||
which uv || echo "uv not found in PATH" && \
|
||||
ls -la /root/.local/bin/ || echo "/root/.local/bin/ not found" && \
|
||||
echo "uv version:" && \
|
||||
uv --version || echo "uv command not working"
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
RUN pip install uv -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
|
@ -32,10 +19,7 @@ COPY backend ./backend
|
|||
# Install dependencies with cache mount
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
sh -c "cd backend && uv sync"
|
||||
|
||||
# Keep proxy for uv sync (Python package downloads may need proxy in China)
|
||||
ENV http_proxy= https_proxy=
|
||||
|
||||
|
||||
# Expose ports (gateway: 8001, langgraph: 2024)
|
||||
EXPOSE 8001 2024
|
||||
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ services:
|
|||
- deer-flow-dev
|
||||
restart: unless-stopped
|
||||
|
||||
# Frontend - Next.js Development Server
|
||||
# Frontend - Next.js Development Server
|
||||
frontend:
|
||||
# image: deer-flow-dev-frontend:latest
|
||||
# image: deer-flow-dev-frontend:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: frontend/Dockerfile
|
||||
|
|
@ -103,9 +103,9 @@ services:
|
|||
- deer-flow-dev
|
||||
restart: unless-stopped
|
||||
|
||||
# Backend - Gateway API
|
||||
# Backend - Gateway API
|
||||
gateway:
|
||||
# image: deer-flow-dev-gateway:latest
|
||||
# image: deer-flow-dev-gateway:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: backend/Dockerfile
|
||||
|
|
@ -140,7 +140,7 @@ services:
|
|||
|
||||
# Backend - LangGraph Server
|
||||
langgraph:
|
||||
# image: deer-flow-dev-langgraph:latest
|
||||
# image: deer-flow-dev-langgraph:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: backend/Dockerfile
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
# DeerFlow Production Environment
|
||||
# Usage: docker-compose -f docker-compose-prod.yaml up -d
|
||||
#
|
||||
# Services:
|
||||
# - nginx: Reverse proxy (port 2026)
|
||||
# - frontend: Frontend Next.js (production build)
|
||||
# - gateway: Backend Gateway API
|
||||
# - langgraph: LangGraph server
|
||||
#
|
||||
|
||||
# All services use pre-built images from registry.xueai.art/deerflow
|
||||
|
||||
name: deerflow2
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: deer-flow-nginx
|
||||
ports:
|
||||
- "2026:2026"
|
||||
volumes:
|
||||
- ./nginx/nginx.local.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- gateway
|
||||
- langgraph
|
||||
networks:
|
||||
- deer-flow-dev
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: registry.xueai.art/deerflow/deerflow-frontend:${VERSION}
|
||||
container_name: deer-flow-frontend
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
networks:
|
||||
- deer-flow-dev
|
||||
restart: unless-stopped
|
||||
|
||||
gateway:
|
||||
image: registry.xueai.art/deerflow/deerflow-gateway:${VERSION}
|
||||
container_name: deer-flow-gateway
|
||||
command: sh -c "cd backend && uv run uvicorn src.gateway.app:app --host 0.0.0.0 --port 8001 > /app/logs/gateway.log 2>&1"
|
||||
volumes:
|
||||
- ../config.yaml:/app/config.yaml
|
||||
- ../skills:/app/skills
|
||||
- ../logs:/app/logs
|
||||
- ../backend/.deer-flow:/app/backend/.deer-flow
|
||||
# Mount uv cache for faster dependency installation
|
||||
- ~/.cache/uv:/root/.cache/uv
|
||||
# Mount Docker socket for aio sandbox
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- CI=true
|
||||
- DOCKER_HOST=unix:///var/run/docker.sock
|
||||
env_file:
|
||||
- ../.env
|
||||
extra_hosts:
|
||||
# For Linux: map host.docker.internal to host gateway
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- deer-flow-dev
|
||||
restart: unless-stopped
|
||||
|
||||
langgraph:
|
||||
image: registry.xueai.art/deerflow/deerflow-langgraph:${VERSION}
|
||||
container_name: deer-flow-langgraph
|
||||
command: sh -c "cd backend && exec uv run langgraph dev --no-browser --no-reload --allow-blocking --host 0.0.0.0 --port 2024 > /app/logs/langgraph.log 2>&1"
|
||||
volumes:
|
||||
# Persist LangGraph inmem runtime data (threads/checkpoints/store)
|
||||
- ../backend/.langgraph_api:/app/backend/.langgraph_api
|
||||
- ../config.yaml:/app/config.yaml
|
||||
- ../skills:/app/skills
|
||||
- ../logs:/app/logs
|
||||
- ../backend/.deer-flow:/app/backend/.deer-flow
|
||||
# Mount uv cache for faster dependency installation
|
||||
- ~/.cache/uv:/root/.cache/uv
|
||||
# Mount Docker socket for aio sandbox
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- CI=true
|
||||
- DOCKER_HOST=unix:///var/run/docker.sock
|
||||
env_file:
|
||||
- ../.env
|
||||
extra_hosts:
|
||||
# For Linux: map host.docker.internal to host gateway
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- deer-flow-dev
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
deer-flow-dev:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 192.168.200.0/24
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# --------------- 构建阶段 ---------------
|
||||
FROM node:22-alpine AS builder
|
||||
ENV NODE_ENV=production
|
||||
ARG PNPM_STORE_PATH=/root/.local/share/pnpm/store
|
||||
ENV BETTER_AUTH_SECRET=any-random-string-123456
|
||||
|
||||
RUN corepack enable && corepack install -g pnpm@10.26.2
|
||||
RUN pnpm config set store-dir ${PNPM_STORE_PATH}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/ .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "start"]
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
# Iframe Skill 选择功能实现计划
|
||||
|
||||
## Context
|
||||
实现 iframe 与宿主页之间的 skill 选择通信功能:
|
||||
1. 点击预选 Skill 的 Suggestion 按钮时,向宿主页发送 `{ type: 'selectSkill', skill_id }`,宿主页返回 `{ skill_id, title }`
|
||||
2. 点击 IframeSkillDialogButton 时,向宿主页发送 `{ type: 'openSkillDialog', openSkillDialog: true }`,宿主页返回用户选择的 `{ skill_id, title }`
|
||||
3. 在 IframeSkillDialogButton 右侧显示可删除的 Tag 标签(显示 title)
|
||||
|
||||
目前 skill 为单选,但设计需考虑后期多选扩展。
|
||||
|
||||
## 现有代码分析
|
||||
|
||||
### 关键文件
|
||||
- `src/components/workspace/input-box.tsx` - 包含 `IframeSkillDialogButton` (L942-958) 和 `SuggestionList` (L854-921)
|
||||
- `src/components/ui/badge.tsx` - Badge 组件,可用于创建可删除 Tag
|
||||
- `src/hooks/use-mobile.ts` - 现有 hook 示例
|
||||
- `src/core/i18n/locales/types.ts` (L85-89) - suggestions 类型定义
|
||||
- `src/core/i18n/locales/zh-CN.ts` (L101-127) - 中文 suggestion 数据
|
||||
|
||||
### IframeSkillDialogButton 现状
|
||||
```tsx
|
||||
// L942-958: 目前只是一个按钮,点击时打开文件对话框
|
||||
function IframeSkillDialogButton({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const attachments = usePromptInputAttachments();
|
||||
return (
|
||||
<Tooltip content={t.inputBox.selectSkill}>
|
||||
<PromptInputButton onClick={() => attachments.openFileDialog()}>
|
||||
{/* SVG icon */}
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
```
|
||||
位置:在 input-box.tsx L496 处使用:`<IframeSkillDialogButton className="px-2!"/>`
|
||||
|
||||
### SuggestionList 现状
|
||||
```tsx
|
||||
// L854-921: 渲染预定义的建议按钮列表
|
||||
{t.inputBox.suggestions.map((suggestion) => (
|
||||
<Suggestion
|
||||
key={suggestion.suggestion}
|
||||
icon={suggestion.icon}
|
||||
suggestion={suggestion.suggestion}
|
||||
onClick={() => handleSuggestionClick(suggestion.prompt)}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### 1. 新建 Hook: `src/hooks/use-iframe-skill.ts`
|
||||
创建 iframe skill 通信 hook:
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// 消息类型常量
|
||||
const MESSAGE_TYPES = {
|
||||
SELECT_SKILL: 'selectSkill',
|
||||
OPEN_SKILL_DIALOG: 'openSkillDialog',
|
||||
} as const;
|
||||
|
||||
// Skill 数据类型
|
||||
interface SkillData {
|
||||
skill_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Hook 返回类型
|
||||
interface UseIframeSkillReturn {
|
||||
selectedSkill: SkillData | null;
|
||||
sendSelectSkill: (skill_id: string) => void;
|
||||
openSkillDialog: () => void;
|
||||
clearSkill: () => void;
|
||||
}
|
||||
|
||||
// 从 URL query 参数解析 skill 数据
|
||||
function parseSkillFromQuery(): SkillData | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const skill_id = params.get('skill_id');
|
||||
const title = params.get('title');
|
||||
if (skill_id && title) {
|
||||
return { skill_id, title };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useIframeSkill(): UseIframeSkillReturn {
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(() => parseSkillFromQuery());
|
||||
|
||||
// 发送选择预定义 skill
|
||||
const sendSelectSkill = useCallback((skill_id: string) => {
|
||||
window.parent.postMessage({ type: MESSAGE_TYPES.SELECT_SKILL, skill_id }, '*');
|
||||
}, []);
|
||||
|
||||
// 打开 skill 选择对话框
|
||||
const openSkillDialog = useCallback(() => {
|
||||
window.parent.postMessage({ type: MESSAGE_TYPES.OPEN_SKILL_DIALOG, openSkillDialog: true }, '*');
|
||||
}, []);
|
||||
|
||||
// 清除选中
|
||||
const clearSkill = useCallback(() => {
|
||||
setSelectedSkill(null);
|
||||
}, []);
|
||||
|
||||
// 监听 URL 变化(宿主页通过修改 query 参数返回数据)
|
||||
useEffect(() => {
|
||||
const handleUrlChange = () => {
|
||||
const skill = parseSkillFromQuery();
|
||||
if (skill) {
|
||||
setSelectedSkill(skill);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 popstate 事件(URL 变化时触发)
|
||||
window.addEventListener('popstate', handleUrlChange);
|
||||
|
||||
// 使用 MutationObserver 监听 URL 变化(某些情况下 popstate 不触发)
|
||||
const originalPushState = history.pushState;
|
||||
history.pushState = function(...args) {
|
||||
originalPushState.apply(history, args);
|
||||
handleUrlChange();
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handleUrlChange);
|
||||
history.pushState = originalPushState;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 修改类型定义: `src/core/i18n/locales/types.ts` (L85-89)
|
||||
扩展 suggestions 类型,添加可选的 `skill_id` 字段:
|
||||
|
||||
```typescript
|
||||
// 原代码
|
||||
suggestions: {
|
||||
suggestion: string;
|
||||
prompt: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
|
||||
// 修改为
|
||||
suggestions: {
|
||||
suggestion: string;
|
||||
prompt: string;
|
||||
icon: LucideIcon;
|
||||
skill_id?: string; // 新增:可选的 skill_id,用于 iframe 通信
|
||||
}[];
|
||||
```
|
||||
|
||||
### 3. 修改 i18n 数据: `src/core/i18n/locales/zh-CN.ts` (L101-127)
|
||||
为需要发送 skill_id 的 suggestion 添加字段:
|
||||
|
||||
```typescript
|
||||
suggestions: [
|
||||
{
|
||||
suggestion: "论文写作",
|
||||
prompt: "撰写一篇关于[主题]的学术论文...",
|
||||
icon: PenLineIcon,
|
||||
skill_id: "paper-writing", // 新增
|
||||
},
|
||||
// ... 其他建议类似添加
|
||||
],
|
||||
```
|
||||
|
||||
### 4. 修改组件: `src/components/workspace/input-box.tsx`
|
||||
|
||||
#### 4.1 导入 hook (文件顶部)
|
||||
```typescript
|
||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { XIcon } from "lucide-react";
|
||||
```
|
||||
|
||||
#### 4.2 修改 IframeSkillDialogButton (L942-958)
|
||||
```typescript
|
||||
function IframeSkillDialogButton({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const { selectedSkill, openSkillDialog, clearSkill } = useIframeSkill();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip content={t.inputBox.selectSkill}>
|
||||
<PromptInputButton
|
||||
className={cn("group px-2! hover:!bg-[#EAE2F5]", className)}
|
||||
onClick={openSkillDialog} // 改为调用 openSkillDialog
|
||||
>
|
||||
{/* SVG icon 保持不变 */}
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
{/* 显示选中的 skill Tag */}
|
||||
{selectedSkill && (
|
||||
<Badge variant="secondary" className="gap-1 pr-1">
|
||||
{selectedSkill.title}
|
||||
<button
|
||||
onClick={clearSkill}
|
||||
className="ml-1 rounded-full hover:bg-muted-foreground/20"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3 修改 SuggestionList (L854-921)
|
||||
```typescript
|
||||
function SuggestionList() {
|
||||
const { t } = useI18n();
|
||||
const { textInput } = usePromptInputController();
|
||||
const { sendSelectSkill } = useIframeSkill();
|
||||
|
||||
const handleSuggestionClick = useCallback(
|
||||
(suggestion: { prompt: string; skill_id?: string }) => {
|
||||
// 如果有 skill_id,发送给宿主页
|
||||
if (suggestion.skill_id) {
|
||||
sendSelectSkill(suggestion.skill_id);
|
||||
return; // 可选:是否继续填充 prompt
|
||||
}
|
||||
// 原有逻辑
|
||||
if (!suggestion.prompt) return;
|
||||
textInput.setInput(suggestion.prompt);
|
||||
// ... 其余代码不变
|
||||
},
|
||||
[textInput, sendSelectSkill],
|
||||
);
|
||||
|
||||
return (
|
||||
<Suggestions className="min-h-16 w-fit items-start">
|
||||
{t.inputBox.suggestions.map((suggestion) => (
|
||||
<Suggestion
|
||||
key={suggestion.suggestion}
|
||||
icon={suggestion.icon}
|
||||
suggestion={suggestion.suggestion}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
/>
|
||||
))}
|
||||
{/* DropdownMenu 部分不变 */}
|
||||
</Suggestions>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Message Protocol
|
||||
|
||||
### 发送到宿主页 (iframe → parent)
|
||||
使用 `window.parent.postMessage()`:
|
||||
```typescript
|
||||
// 选择预定义 skill
|
||||
{ type: 'selectSkill', skill_id: string }
|
||||
|
||||
// 打开 skill 选择对话框
|
||||
{ type: 'openSkillDialog', openSkillDialog: true }
|
||||
```
|
||||
|
||||
### 宿主页返回 (parent → iframe)
|
||||
宿主页通过修改 iframe 的 URL query 参数返回数据:
|
||||
```
|
||||
?skill_id=xxx&title=xxx
|
||||
```
|
||||
|
||||
iframe 监听 URL 变化获取数据。
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **创建 useIframeSkill hook**
|
||||
- 新建文件 `src/hooks/use-iframe-skill.ts`
|
||||
- 实现 postMessage 发送和 message 事件监听
|
||||
|
||||
2. **扩展 i18n 类型**
|
||||
- 修改 `src/core/i18n/locales/types.ts`
|
||||
- 添加 skill_id 可选字段
|
||||
|
||||
3. **修改 SuggestionList**
|
||||
- 导入 useIframeSkill hook
|
||||
- 修改 handleSuggestionClick,检测 skill_id 时发送消息
|
||||
|
||||
4. **修改 IframeSkillDialogButton**
|
||||
- 导入 useIframeSkill hook 和 Badge 组件
|
||||
- 点击调用 openSkillDialog
|
||||
- 显示选中的 skill Tag,支持删除
|
||||
|
||||
5. **添加 i18n 数据**
|
||||
- 为 zh-CN.ts 和 en-US.ts 的 suggestions 添加 skill_id
|
||||
|
||||
## Verification
|
||||
1. 在 iframe 环境中测试:
|
||||
- 点击带 skill_id 的 Suggestion,确认消息发送(可在宿主页控制台查看)
|
||||
- 模拟宿主页返回 `{ skill_id: 'test', title: '测试技能' }`,确认 Tag 显示正确
|
||||
- 点击 IframeSkillDialogButton,确认 openSkillDialog 消息发送
|
||||
- 点击 Tag 的 X 按钮,确认删除功能正常
|
||||
|
||||
## Notes
|
||||
- 目前 skill 为单选,selectedSkill 类型为 `SkillData | null`
|
||||
- 后期扩展多选时,改为 `SkillData[]` 数组类型
|
||||
- iframe → 宿主页:使用 `postMessage`
|
||||
- 宿主页 → iframe:通过修改 URL query 参数 `?skill_id=xxx&title=xxx`
|
||||
- hook 通过监听 URL 变化(popstate + history.pushState 劫持)获取宿主页返回的数据
|
||||
|
|
@ -68,7 +68,6 @@ export default function ChatPage() {
|
|||
}
|
||||
},
|
||||
});
|
||||
console.log(thread.values.todos);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
|
|
@ -141,7 +140,7 @@ export default function ChatPage() {
|
|||
)}
|
||||
>
|
||||
<InputBox
|
||||
className={cn("bg-[#FBFAFC] w-full -translate-y-4 ")}
|
||||
className={cn("bg-[#FBFAFC] w-full -translate-y-4 rounded-[20px] ")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ export const ArtifactContent = ({
|
|||
...props
|
||||
}: ArtifactContentProps) => (
|
||||
<div
|
||||
className={cn("min-h-0 flex-1 overflow-auto p-4", className)}
|
||||
className={cn("min-h-0 flex-1 overflow-auto p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -247,6 +247,7 @@ export function ArtifactFileDetail({
|
|||
src={urlOfArtifact({ filepath, threadId, isMock })}
|
||||
/>
|
||||
)}
|
||||
{/* <div style={{ height: `${paddingBottom}px` }} /> */}
|
||||
</ArtifactContent>
|
||||
</Artifact>
|
||||
);
|
||||
|
|
@ -261,13 +262,14 @@ export function ArtifactFilePreview({
|
|||
}) {
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div className="size-full px-4">
|
||||
<div className={cn("size-full px-4")}>
|
||||
<Streamdown
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{ a: CitationLink }}
|
||||
>
|
||||
{content ?? ""}
|
||||
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfettiButton } from "@/components/ui/confetti-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -82,6 +83,7 @@ import {
|
|||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
|
|
@ -453,10 +455,13 @@ export function InputBox({
|
|||
// height和padding都为0来隐藏
|
||||
!effectiveIsFocused && "invisible h-[0px] p-[0px] opacity-0 translate-y-2 pointer-events-none"
|
||||
)}>
|
||||
{/* ========== 左侧工具栏 ========== */}
|
||||
<PromptInputTools>
|
||||
<AddAttachmentsButton className="px-2!" />
|
||||
<PromptInputActionMenu>
|
||||
{/* <ModeHoverGuide
|
||||
{/* 附件上传按钮 */}
|
||||
<AddAttachmentsButton />
|
||||
{/* [已禁用] 模式选择器触发器 (flash/thinking/pro/ultra) */}
|
||||
{/*<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
context.mode === "flash" ||
|
||||
context.mode === "thinking" ||
|
||||
|
|
@ -493,8 +498,10 @@ export function InputBox({
|
|||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
</ModeHoverGuide> */}
|
||||
<IframeSkillDialogButton className="px-2!"/>
|
||||
<PromptInputActionMenuContent className="w-80">
|
||||
{/* Skill 选择按钮 (iframe 与宿主页通信) */}
|
||||
<IframeSkillDialogButton className="px-2!"/>
|
||||
{/* [已禁用] 模式选择下拉菜单内容 */}
|
||||
{/* <PromptInputActionMenuContent className="w-80">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||
{t.inputBox.mode}
|
||||
|
|
@ -626,6 +633,7 @@ export function InputBox({
|
|||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
{/* [已禁用] 推理强度选择器 (minimal/low/medium/high) */}
|
||||
{supportReasoningEffort && context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className="gap-1! px-2!">
|
||||
|
|
@ -737,8 +745,10 @@ export function InputBox({
|
|||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
{/* ========== 右侧工具栏 ========== */}
|
||||
<PromptInputTools>
|
||||
<ModelSelector
|
||||
{/* [已禁用] 模型选择器 */}
|
||||
{/* <ModelSelector
|
||||
open={modelDialogOpen}
|
||||
onOpenChange={setModelDialogOpen}
|
||||
>
|
||||
|
|
@ -768,13 +778,14 @@ export function InputBox({
|
|||
))}
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
</ModelSelector> */}
|
||||
{/* 占位符 */}
|
||||
<div className="w-[150px]"></div>
|
||||
</PromptInputTools>
|
||||
</PromptInputFooter>
|
||||
{/* 移动出来 */}
|
||||
<PromptInputSubmit
|
||||
className="absolute right-3 bottom-3 z-[20]"
|
||||
className="absolute right-3 bottom-3 z-[20] border-0"
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
status={status}
|
||||
|
|
@ -850,21 +861,29 @@ export function InputBox({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 快速选择skillbutton
|
||||
function SuggestionList() {
|
||||
const { t } = useI18n();
|
||||
const { textInput } = usePromptInputController();
|
||||
const { sendSelectSkill } = useIframeSkill();
|
||||
|
||||
const handleSuggestionClick = useCallback(
|
||||
(prompt: string | undefined) => {
|
||||
if (!prompt) return;
|
||||
textInput.setInput(prompt);
|
||||
(suggestion: { prompt: string; skill_id?: string }) => {
|
||||
// 如果有 skill_id,发送给宿主页
|
||||
if (suggestion.skill_id) {
|
||||
sendSelectSkill(suggestion.skill_id);
|
||||
return;
|
||||
}
|
||||
// 原有逻辑
|
||||
if (!suggestion.prompt) return;
|
||||
textInput.setInput(suggestion.prompt);
|
||||
setTimeout(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>(
|
||||
"textarea[name='message']",
|
||||
);
|
||||
if (textarea) {
|
||||
const selStart = prompt.indexOf("[");
|
||||
const selEnd = prompt.indexOf("]");
|
||||
const selStart = suggestion.prompt.indexOf("[");
|
||||
const selEnd = suggestion.prompt.indexOf("]");
|
||||
if (selStart !== -1 && selEnd !== -1) {
|
||||
textarea.setSelectionRange(selStart, selEnd + 1);
|
||||
textarea.focus();
|
||||
|
|
@ -872,7 +891,7 @@ function SuggestionList() {
|
|||
}
|
||||
}, 500);
|
||||
},
|
||||
[textInput],
|
||||
[textInput, sendSelectSkill],
|
||||
);
|
||||
return (
|
||||
<Suggestions className="min-h-16 w-fit items-start">
|
||||
|
|
@ -889,7 +908,7 @@ function SuggestionList() {
|
|||
key={suggestion.suggestion}
|
||||
icon={suggestion.icon}
|
||||
suggestion={suggestion.suggestion}
|
||||
onClick={() => handleSuggestionClick(suggestion.prompt)}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
|
|
@ -905,7 +924,7 @@ function SuggestionList() {
|
|||
!("type" in suggestion) && (
|
||||
<DropdownMenuItem
|
||||
key={suggestion.suggestion}
|
||||
onClick={() => handleSuggestionClick(suggestion.prompt)}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
>
|
||||
{suggestion.icon && <suggestion.icon className="size-4" />}
|
||||
{suggestion.suggestion}
|
||||
|
|
@ -919,14 +938,14 @@ function SuggestionList() {
|
|||
</Suggestions>
|
||||
);
|
||||
}
|
||||
|
||||
// 上传附件
|
||||
function AddAttachmentsButton({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const attachments = usePromptInputAttachments();
|
||||
return (
|
||||
<Tooltip content={t.inputBox.addAttachments}>
|
||||
<PromptInputButton
|
||||
className={cn("group px-2! hover:!bg-[#EAE2F5]", className)}
|
||||
className={cn("group px-2! hover:bg-[#EAE2F5] ", className)}
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
>
|
||||
<svg width="18" height="15" viewBox="0 0 18 15" fill="none" xmlns="http://www.w3.org/2000/svg" className="transition-[stroke] duration-200 [&>path]:transition-[fill,stroke] [&>path]:duration-200 [&>path:first-child]:group-hover:fill-[#8E47F0] [&>path:last-child]:group-hover:stroke-[#8E47F0]">
|
||||
|
|
@ -938,21 +957,34 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
|||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// 启动iframeSkillDialog
|
||||
function IframeSkillDialogButton({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const attachments = usePromptInputAttachments();
|
||||
return (
|
||||
<Tooltip content={t.inputBox.selectSkill}>
|
||||
<PromptInputButton
|
||||
className={cn("group px-2! hover:!bg-[#EAE2F5]", className)}
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
>
|
||||
const { selectedSkill, openSkillDialog, clearSkill } = useIframeSkill();
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="size-4 transition-[stroke] duration-200 [&>path]:transition-[stroke] [&>path]:duration-200 [&>path]:group-hover:stroke-[#8E47F0]" viewBox="0 0 12 16" fill="none">
|
||||
<path d="M3.7998 0.5H9.19922C9.24033 0.5 9.26852 0.518136 9.28516 0.541992C9.30124 0.565318 9.30411 0.588767 9.29395 0.613281H9.29297L7.43066 5.07422L7.1416 5.76758H11.3994C11.4295 5.76765 11.4474 5.77552 11.459 5.7832C11.4724 5.79207 11.4846 5.80503 11.4922 5.82129C11.4997 5.83745 11.5013 5.85253 11.5 5.86328C11.4989 5.87156 11.4953 5.88556 11.4785 5.9043L2.87891 15.4629V15.4639C2.85396 15.4914 2.83406 15.4971 2.82031 15.499C2.80144 15.5016 2.77553 15.4981 2.74902 15.4844C2.72225 15.4705 2.70837 15.453 2.70312 15.4424C2.70056 15.4372 2.69457 15.4253 2.70312 15.3936V15.3926L4.30273 9.49512L4.47461 8.86426H0.600586C0.559682 8.86424 0.531324 8.84587 0.514648 8.82227C0.498608 8.79944 0.496551 8.777 0.505859 8.75293L3.70508 0.558594C3.71075 0.544183 3.72173 0.529788 3.73828 0.518555C3.74688 0.51277 3.75704 0.508037 3.76758 0.504883L3.7998 0.5Z" stroke="#150033"/>
|
||||
</svg>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip content={t.inputBox.selectSkill}>
|
||||
<PromptInputButton
|
||||
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
|
||||
onClick={openSkillDialog}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="size-4 transition-[stroke] duration-200 [&>path]:transition-[stroke] [&>path]:duration-200 [&>path]:group-hover:stroke-[#8E47F0]" viewBox="0 0 12 16" fill="none">
|
||||
<path d="M3.7998 0.5H9.19922C9.24033 0.5 9.26852 0.518136 9.28516 0.541992C9.30124 0.565318 9.30411 0.588767 9.29395 0.613281H9.29297L7.43066 5.07422L7.1416 5.76758H11.3994C11.4295 5.76765 11.4474 5.77552 11.459 5.7832C11.4724 5.79207 11.4846 5.80503 11.4922 5.82129C11.4997 5.83745 11.5013 5.85253 11.5 5.86328C11.4989 5.87156 11.4953 5.88556 11.4785 5.9043L2.87891 15.4629V15.4639C2.85396 15.4914 2.83406 15.4971 2.82031 15.499C2.80144 15.5016 2.77553 15.4981 2.74902 15.4844C2.72225 15.4705 2.70837 15.453 2.70312 15.4424C2.70056 15.4372 2.69457 15.4253 2.70312 15.3936V15.3926L4.30273 9.49512L4.47461 8.86426H0.600586C0.559682 8.86424 0.531324 8.84587 0.514648 8.82227C0.498608 8.79944 0.496551 8.777 0.505859 8.75293L3.70508 0.558594C3.71075 0.544183 3.72173 0.529788 3.73828 0.518555C3.74688 0.51277 3.75704 0.508037 3.76758 0.504883L3.7998 0.5Z" stroke="#150033"/>
|
||||
</svg>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
{selectedSkill && (
|
||||
<Badge variant="secondary" className="gap-1 px-[15px] py-[4px] text-[#8E47F0] bg-[#EAE2F5]">
|
||||
{selectedSkill.title}
|
||||
<button
|
||||
onClick={clearSkill}
|
||||
className="ml-1 rounded-full hover:bg-muted-foreground/20"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,26 +107,31 @@ export const enUS: Translations = {
|
|||
suggestion: "Paper Writing",
|
||||
prompt: "Write an academic paper about [topic], including abstract, introduction, body and references.",
|
||||
icon: PenLineIcon,
|
||||
skill_id: "paper-writing",
|
||||
},
|
||||
{
|
||||
suggestion: "Report Generation",
|
||||
prompt: "Analyze [topic] in depth and generate a well-structured research report.",
|
||||
icon: MicroscopeIcon,
|
||||
skill_id: "report-generation",
|
||||
},
|
||||
{
|
||||
suggestion: "Copywriting",
|
||||
prompt: "Create a complete planning proposal and promotional copy for [project/event].",
|
||||
icon: ShapesIcon,
|
||||
skill_id: "planning-copywriting",
|
||||
},
|
||||
{
|
||||
suggestion: "PPT Generation",
|
||||
prompt: "Generate a PPT presentation outline and content about [topic].",
|
||||
icon: GraduationCapIcon,
|
||||
skill_id: "ppt-generation",
|
||||
},
|
||||
{
|
||||
suggestion: "Document Processing",
|
||||
prompt: "Process [document] with reading, summarizing, translating or format conversion.",
|
||||
icon: CompassIcon,
|
||||
skill_id: "document-processing",
|
||||
},
|
||||
],
|
||||
suggestionsCreate: [
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export interface Translations {
|
|||
suggestion: string;
|
||||
prompt: string;
|
||||
icon: LucideIcon;
|
||||
skill_id?: string;
|
||||
}[];
|
||||
suggestionsCreate: (
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -103,26 +103,31 @@ export const zhCN: Translations = {
|
|||
suggestion: "论文写作",
|
||||
prompt: "撰写一篇关于[主题]的学术论文,包含摘要、引言、正文和参考文献。",
|
||||
icon: PenLineIcon,
|
||||
skill_id: "1",
|
||||
},
|
||||
{
|
||||
suggestion: "报告生成",
|
||||
prompt: "深入分析[主题],生成一份结构清晰的调研报告。",
|
||||
icon: MicroscopeIcon,
|
||||
skill_id: "2",
|
||||
},
|
||||
{
|
||||
suggestion: "策划文案",
|
||||
prompt: "为[项目/活动]撰写一份完整的策划方案和宣传文案。",
|
||||
icon: ShapesIcon,
|
||||
skill_id: "3",
|
||||
},
|
||||
{
|
||||
suggestion: "PPT生成",
|
||||
prompt: "生成一个关于[主题]的PPT演示文稿大纲和内容。",
|
||||
icon: GraduationCapIcon,
|
||||
skill_id: "4",
|
||||
},
|
||||
{
|
||||
suggestion: "文档处理",
|
||||
prompt: "对[文档]进行阅读、总结、翻译或格式转换等处理。",
|
||||
icon: CompassIcon,
|
||||
skill_id: "5",
|
||||
},
|
||||
],
|
||||
suggestionsCreate: [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
// 消息类型常量
|
||||
const MESSAGE_TYPES = {
|
||||
SELECT_SKILL: 'selectSkill',
|
||||
OPEN_SKILL_DIALOG: 'openSkillDialog',
|
||||
} as const;
|
||||
|
||||
// Skill 数据类型
|
||||
interface SkillData {
|
||||
skill_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Hook 返回类型
|
||||
interface UseIframeSkillReturn {
|
||||
selectedSkill: SkillData | null;
|
||||
sendSelectSkill: (skill_id: string) => void;
|
||||
openSkillDialog: () => void;
|
||||
clearSkill: () => void;
|
||||
}
|
||||
|
||||
export function useIframeSkill(): UseIframeSkillReturn {
|
||||
const searchParams = useSearchParams();
|
||||
const skillIdFromQuery = searchParams.get('skill_id');
|
||||
const titleFromQuery = searchParams.get('title');
|
||||
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
|
||||
|
||||
// 监听 query 参数变化
|
||||
useEffect(() => {
|
||||
console.log('[useIframeSkill] Query params changed - skill_id:', skillIdFromQuery, 'title:', titleFromQuery);
|
||||
if (skillIdFromQuery && titleFromQuery) {
|
||||
const skill = { skill_id: skillIdFromQuery, title: titleFromQuery };
|
||||
console.log('[useIframeSkill] Setting skill from URL:', skill);
|
||||
setSelectedSkill(skill);
|
||||
}
|
||||
}, [skillIdFromQuery, titleFromQuery]);
|
||||
|
||||
// 发送选择预定义 skill
|
||||
const sendSelectSkill = useCallback((skill_id: string) => {
|
||||
const message = { type: MESSAGE_TYPES.SELECT_SKILL, skill_id };
|
||||
console.log('[useIframeSkill] sendSelectSkill:', message);
|
||||
window.parent.postMessage(message, '*');
|
||||
}, []);
|
||||
|
||||
// 打开 skill 选择对话框
|
||||
const openSkillDialog = useCallback(() => {
|
||||
const message = { type: MESSAGE_TYPES.OPEN_SKILL_DIALOG, openSkillDialog: true };
|
||||
console.log('[useIframeSkill] openSkillDialog:', message);
|
||||
window.parent.postMessage(message, '*');
|
||||
}, []);
|
||||
|
||||
// 清除选中
|
||||
const clearSkill = useCallback(() => {
|
||||
setSelectedSkill(null);
|
||||
}, []);
|
||||
|
||||
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
|
||||
}
|
||||
105
memo.md
105
memo.md
|
|
@ -1,27 +1,90 @@
|
|||
# 当前改动总结(2026-03-09)
|
||||
|
||||
1. 网络连接问题
|
||||
local_backend.py中使用localhost访问sandbox容器
|
||||
但在Docker容器内部,localhost指向容器自身,而不是主机
|
||||
需要改为host.docker.internal
|
||||
1. 修改网络配置
|
||||
文件: backend/src/community/aio_sandbox/local_backend.py
|
||||
## TODO备忘:AIO sandbox端口分配并发竞态
|
||||
- 问题:sandbox容器启动时端口分配(get_free_port + docker run)非原子操作,存在并发竞态。
|
||||
- 多个会话并发检测到端口空闲,可能同时尝试分配同一端口,导致后一个容器启动失败(端口已被占用)。
|
||||
- 建议:
|
||||
- 增加端口分配锁(如文件锁、Redis等),保证端口分配与容器启动原子性。
|
||||
- 或在容器启动失败后自动重试分配新端口。
|
||||
- 适用于高并发场景,低并发下概率极低。
|
||||
# 当前改动总结(2026-03-09)
|
||||
|
||||
第116行: 添加sandbox_host = "host.docker.internal"
|
||||
第119行: 将sandbox_url=f"http://localhost:{port}"改为sandbox_url=f"http://{sandbox_host}:{port}"
|
||||
第166-167行: 同样修改了discover方法中的URL构建
|
||||
## 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)
|
||||
|
||||
|
||||
2. Docker socket挂载问题
|
||||
gateway和langgraph容器需要访问Docker守护进程来启动sandbox容器
|
||||
但容器没有挂载Docker socket
|
||||
2. 添加Docker socket挂载
|
||||
文件: docker/docker-compose-dev.yaml
|
||||
## 1) AIO sandbox 网络访问修复(已完成)
|
||||
- 问题:容器内访问 `localhost` 实际指向容器自身,无法访问宿主机上的 sandbox 端口。
|
||||
- 调整:将 sandbox 访问地址改为 `host.docker.internal`。
|
||||
- 文件:`backend/src/community/aio_sandbox/local_backend.py`
|
||||
- 影响:`create()` / `discover()` 走宿主机映射端口可达。
|
||||
|
||||
为gateway和langgraph服务添加:
|
||||
volumes:
|
||||
# Mount Docker socket for aio sandbox
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
## 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`
|
||||
|
||||
environment:
|
||||
# Docker environment for aio sandbox
|
||||
- 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 用户正常写入,上传/输出/工作区权限问题彻底解决。
|
||||
Loading…
Reference in New Issue