Compare commits

...

8 Commits

Author SHA1 Message Date
肖应宇 4318b8392a fix(artifact): 调整 artifact 文件预览底部间距
- 注释掉 ArtifactContent 中的 paddingBottom 占位 div
- 为 markdown 预览容器添加 cn 工具函数支持
2026-03-17 10:27:14 +08:00
肖应宇 4950350494 Merge branch 'main' of https://git.xueai.art/skills/deerflow2 into feat/kexue-ui-v0.1 2026-03-17 09:23:07 +08:00
Titan 590001c130 build: 去除frotend docker的多阶段构建 2026-03-16 18:46:22 +08:00
Titan cb758af645 build/ci: prod发布重写 2026-03-16 17:22:18 +08:00
Titan 5bfdba9cdb chore: 发布相关脚本改动 2026-03-15 20:52:15 +08:00
Titan 96ea81d677 chore: 发布相关脚本改动 2026-03-15 20:50:09 +08:00
Titan cea46914fe chore: 忽略memo 2026-03-15 20:39:19 +08:00
Titan c41475b552 chore: 发布相关脚本改动 2026-03-15 20:38:11 +08:00
15 changed files with 693 additions and 82 deletions

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

18
frontend/Dockerfile.prod Normal file
View File

@ -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"]

View File

@ -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 劫持)获取宿主页返回的数据

View File

@ -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}

View File

@ -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}
/>
);

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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: [

View File

@ -86,6 +86,7 @@ export interface Translations {
suggestion: string;
prompt: string;
icon: LucideIcon;
skill_id?: string;
}[];
suggestionsCreate: (
| {

View File

@ -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: [

View File

@ -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
View File

@ -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 目录”APImaterializeSkillYaml前端已注释掉相关代码
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 用户正常写入,上传/输出/工作区权限问题彻底解决。