Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b557fab2f2 | ||
|
|
37a1856ca7 | ||
|
|
0e98878992 | ||
|
|
4659d43e8e | ||
|
|
3e4d095425 | ||
|
|
dcf978fe92 | ||
|
|
481bdf789a | ||
|
|
15359e391e | ||
|
|
3336fdb5cd | ||
|
|
4a26c2bdfc | ||
|
|
689859881a | ||
|
|
4028ef3dad | ||
|
|
89297fb827 | ||
|
|
d608f491f1 | ||
|
|
7ec98f4835 | ||
|
|
aa79292f6e | ||
|
|
165ab9bc29 | ||
|
|
2871706c47 |
@@ -0,0 +1,72 @@
|
||||
name: Build Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build image on tag
|
||||
runs-on: host-shell
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: https://gitee.com/gitea-actions/setup-buildx-action@v3 # 推荐使用 Gitee/Gitea 国内镜像
|
||||
|
||||
- name: Login to Registry
|
||||
uses: https://gitee.com/gitea-actions/login-action@v3
|
||||
with:
|
||||
registry: registry.cn-shanghai.aliyuncs.com
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Show build context
|
||||
run: |
|
||||
echo "Ref: ${{ github.ref }}"
|
||||
echo "Tag: ${GITHUB_REF_NAME}"
|
||||
echo "Runner: $(uname -a)"
|
||||
|
||||
- name: Build Image
|
||||
run: |
|
||||
docker build -t opencode-custom:latest -t registry.cn-shanghai.aliyuncs.com/taochen/opencode-custom:latest .
|
||||
|
||||
- name: Verify image
|
||||
run: |
|
||||
docker images | grep opencode-custom
|
||||
docker run --rm opencode-custom:latest --version
|
||||
|
||||
- name: Push Image
|
||||
run: |
|
||||
docker push registry.cn-shanghai.aliyuncs.com/taochen/opencode-custom:latest
|
||||
|
||||
- name: Notify release via Bark
|
||||
if: always()
|
||||
run: |
|
||||
TITLE="${{ github.ref_name }}"
|
||||
STATUS="${{ job.status }}"
|
||||
if [ "${STATUS}" = "success" ]; then
|
||||
EMOJI="✅"
|
||||
else
|
||||
EMOJI="❌"
|
||||
fi
|
||||
BODY="${EMOJI} opencode-custom:latest image build ${STATUS}\n branch ${{ github.ref_name }}\n workflow ${{ github.workflow }}\n run ${{ github.run_id }}"
|
||||
PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"device_key": "6ZFVsxM95cuAjYoNLWSWaN",
|
||||
"title": "${TITLE} - image build ${STATUS}",
|
||||
"body": "${BODY}",
|
||||
"group": "Gitea",
|
||||
"isArchive": 1,
|
||||
"ttl": 3600
|
||||
}
|
||||
EOF
|
||||
)
|
||||
curl -s \
|
||||
-X POST http://101.43.40.124:81/push \
|
||||
-H "Content-Type: application/json; charset=utf-8" \
|
||||
-d "${PAYLOAD}"
|
||||
@@ -115,3 +115,7 @@ The two non-pipeline skills (`logic-planner` taxonomy details, `sql-review` chec
|
||||
- **Pipeline status changes** — never collapse gates without user confirmation. The pipeline's value comes from each stage being a separate, re-runnable hand-off; inlining them couples them.
|
||||
- **Triggers in `description:` frontmatter** — these are what Claude Code matches against user prompts. Be specific (input contract + concrete trigger phrases). Vague triggers cause skill over-invocation.
|
||||
- **Stale path references** — the bundled SKILL.md / scripts use `.claude/skills/...` paths from an earlier layout. When updating validator instructions, prefer the actual paths (`skills/...` on host, `/root/.config/opencode/skills/...` in image).
|
||||
|
||||
|
||||
|
||||
<!-- claude --resume a70186ab-eddf-442f-a7c9-6d3aa638f7f4 -->
|
||||
+14
-24
@@ -19,10 +19,10 @@ RUN mkdir -p /etc/apt/keyrings \
|
||||
&& apt-get update && apt-get install -y nodejs --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 uv 并利用 uv 下载 Python 3.12
|
||||
# 安装 uv 并利用 uv 下载 Python 3.11
|
||||
ENV UV_INSTALL_DIR=/opt/uv
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& /opt/uv/uv python install 3.12
|
||||
&& /opt/uv/uv python install 3.11
|
||||
|
||||
|
||||
# ==========================================
|
||||
@@ -53,37 +53,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. 从 builder 复制 uv 和由 uv 管理的 Python 3.12
|
||||
# 2. 从 builder 复制 uv 和由 uv 管理的 Python 3.11
|
||||
COPY --from=builder /opt/uv/uv /usr/local/bin/uv
|
||||
COPY --from=builder /root/.local/share/uv/python /root/.local/share/uv/python
|
||||
RUN uv --version
|
||||
|
||||
COPY ./fs-backend /app/fs-backend
|
||||
RUN cd /app/fs-backend && \
|
||||
uv venv && \
|
||||
uv pip install \
|
||||
--index-url https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
--requirements ./requirements.txt
|
||||
|
||||
|
||||
RUN uv venv /opt/venv --python 3.12
|
||||
RUN uv venv /opt/venv --python 3.11
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN uv pip install \
|
||||
--index-url https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
--requirements /tmp/requirements.txt
|
||||
|
||||
# 3. 在最终镜像中用正规的 npm 安装 opencode-ai,确保系统全局软链接生成完整
|
||||
RUN npm install -g opencode-ai && npm cache clean --force
|
||||
RUN opencode --version
|
||||
|
||||
COPY skills /root/.config/opencode/skills
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL https://mimo.xiaomi.com/install | bash
|
||||
|
||||
ENV PATH="/root/.mimocode/bin:${PATH}"
|
||||
RUN mkdir -p /root/workspace
|
||||
COPY skills /root/.config/mimocode/skills
|
||||
COPY csv2json /root/workspace/csv2json
|
||||
|
||||
|
||||
COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# 入口脚本作为 ENTRYPOINT,并将 opencode 作为默认执行指令/参数
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["opencode"]
|
||||
ENTRYPOINT ["mimo"]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 1. 在后台启动 fs-backend
|
||||
echo "Starting fs-backend in background..."
|
||||
cd /app/fs-backend
|
||||
# 使用创建好的虚拟环境或 uv 运行服务(以 uvicorn 为例,请根据你的实际入口修改 main:app)
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 &
|
||||
|
||||
# 2. 返回根目录或原始工作目录
|
||||
cd ~/workspace
|
||||
|
||||
# 3. 将控制权交给容器主进程(即 opencode 及传入的 CLI 参数)
|
||||
exec "$@"
|
||||
@@ -1,68 +0,0 @@
|
||||
# OpenCode Backend
|
||||
|
||||
基于 Python + FastAPI 的后端服务项目
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
OpenCode-backend/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # 应用主入口
|
||||
│ ├── core/ # 核心配置
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── config.py # 应用配置
|
||||
│ ├── routers/ # 路由模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── fs.py # 文件系统路由
|
||||
│ └── schemas/ # Pydantic 模式
|
||||
│ ├── __init__.py
|
||||
│ └── project.py # 项目相关模式
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
### 3. 访问 API 文档
|
||||
|
||||
启动后访问:http://localhost:8000/docs
|
||||
|
||||
## API 接口
|
||||
|
||||
### 创建项目
|
||||
|
||||
**接口:** `POST /fs/project/create`
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"project_name": "我的项目"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "项目 '我的项目' 创建成功",
|
||||
"project_path": "/root/workspace/我的项目"
|
||||
}
|
||||
```
|
||||
|
||||
**校验规则:**
|
||||
- 项目名称仅允许:中英文、数字、下划线 (_)、横杠 (-)
|
||||
- 禁止包含路径逃逸字符:`../`、`/`、`\`
|
||||
- 所有项目被锁定在 `/root/workspace` 目录内
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
包初始化文件
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.routers.fs import router as fs_router
|
||||
|
||||
__all__ = ["settings", "fs_router"]
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
核心模块初始化
|
||||
"""
|
||||
@@ -1,28 +0,0 @@
|
||||
"""
|
||||
应用配置模块
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# 工作空间基础路径
|
||||
BASE_WORKSPACE: str = "/root/workspace"
|
||||
|
||||
# 可通过环境变量覆盖:export BASE_WORKSPACE=/custom/path
|
||||
|
||||
# CORS 配置
|
||||
CORS_ORIGINS: List[str] = ["*"]
|
||||
# CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||
|
||||
# 项目名验证正则:仅允许中英文、数字、下划线、横杠
|
||||
PROJECT_NAME_PATTERN: str = r"^[\w一-龥\-]+$"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
FastAPI 后端服务主入口
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.routers import fs
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""创建并配置 FastAPI 应用实例"""
|
||||
app = FastAPI(
|
||||
title="OpenCode Backend API",
|
||||
description="OpenCode 后端服务接口",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# 配置 CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(fs.router, prefix="/fs", tags=["文件系统"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""健康检查接口"""
|
||||
return {"status": "ok", "message": "OpenCode Backend API is running"}
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
路由模块初始化
|
||||
"""
|
||||
@@ -1,472 +0,0 @@
|
||||
"""
|
||||
文件系统相关路由
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.fs import (
|
||||
FileContentResponse,
|
||||
FileContentUpdateRequest,
|
||||
FileContentUpdateResponse,
|
||||
FileCreateRequest,
|
||||
FileOperationResponse,
|
||||
FileTreeItem,
|
||||
)
|
||||
from app.schemas.project import ProjectCreateRequest, ProjectCreateResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _is_git_ignored(directory: str, absolute_path: str) -> bool:
|
||||
"""
|
||||
判断指定路径是否被 git 忽略。
|
||||
|
||||
依赖目标目录是否为 git 仓库:
|
||||
- 若是 git 仓库,使用 `git check-ignore` 判定;
|
||||
- 若不是 git 仓库或 git 不可用,默认返回 False。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", directory, "check-ignore", absolute_path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=5,
|
||||
)
|
||||
# git check-ignore 退出码:0 表示被忽略,1 表示未被忽略
|
||||
return result.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
# git 未安装或超时,按非忽略处理
|
||||
return False
|
||||
|
||||
|
||||
def _assert_path_allowed(real_directory: str, real_target: str) -> None:
|
||||
"""
|
||||
确保解析后的真实路径在允许范围内。
|
||||
|
||||
允许范围:
|
||||
- 项目目录 real_directory 内(常规文件访问);
|
||||
- BASE_WORKSPACE 工作空间内(允许 tables_meta 这类指向工作空间根的共享软链接)。
|
||||
|
||||
这样既阻止通过 `..` 或外部软链接逃逸出工作空间,又允许项目内合法的共享软链接。
|
||||
"""
|
||||
real_workspace = os.path.realpath(settings.BASE_WORKSPACE)
|
||||
within_dir = real_target == real_directory or real_target.startswith(real_directory + os.sep)
|
||||
within_workspace = real_target == real_workspace or real_target.startswith(real_workspace + os.sep)
|
||||
if not (within_dir or within_workspace):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="非法的查询路径,检测到路径逃逸尝试"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/file", response_model=List[FileTreeItem])
|
||||
async def list_file_tree(
|
||||
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
|
||||
path: str = Query(..., description="相对于 directory 的查询路径,. 表示根目录"),
|
||||
):
|
||||
"""
|
||||
查询指定目录下的文件树列表
|
||||
|
||||
- directory:项目顶层绝对目录,固定值(如 /root/workspace/test)
|
||||
- path:相对查询路径
|
||||
- 传 `.`:读取 directory 根目录下所有文件 / 文件夹
|
||||
- 传 `文件夹名/`(如 `ningxia/`):读取 directory/文件夹名 子目录内文件树
|
||||
"""
|
||||
# 规范化 directory:去除尾部斜杠
|
||||
directory = os.path.normpath(directory)
|
||||
|
||||
# 安全校验:directory 必须存在且为目录
|
||||
if not os.path.isdir(directory):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"目录不存在或不是文件夹:{directory}"
|
||||
)
|
||||
|
||||
# 规范化 path:仅允许 `.` 或以 `/` 结尾的相对子路径
|
||||
# 处理 path 为 `.` 的根目录情形
|
||||
if path == "." or path == "":
|
||||
target_dir = directory
|
||||
else:
|
||||
# 去除首尾空白,确保以 / 结尾(约定:文件夹末尾带 /)
|
||||
rel_path = path.strip()
|
||||
if not rel_path.endswith("/"):
|
||||
rel_path = rel_path + "/"
|
||||
# 去除结尾的 / 用于拼接
|
||||
rel_path_clean = rel_path.rstrip("/")
|
||||
# 禁止路径逃逸
|
||||
if ".." in rel_path_clean.split("/"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="非法的相对路径,禁止路径逃逸 (..)"
|
||||
)
|
||||
# 禁止绝对路径
|
||||
if os.path.isabs(rel_path_clean):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="相对路径 path 不能为绝对路径"
|
||||
)
|
||||
target_dir = os.path.join(directory, rel_path_clean)
|
||||
|
||||
# 解析真实物理路径,确保最终路径在允许范围内(防逃逸,允许工作空间内软链接)
|
||||
real_directory = os.path.realpath(directory)
|
||||
real_target = os.path.realpath(target_dir)
|
||||
_assert_path_allowed(real_directory, real_target)
|
||||
|
||||
if not os.path.isdir(real_target):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"查询的子目录不存在:{path}"
|
||||
)
|
||||
|
||||
# 计算 path 前缀(基于 directory 的相对路径,用于拼接每个条目的 path)
|
||||
# path 为 `.` 时前缀为空;否则前缀为 `文件夹名/`
|
||||
if path == "." or path == "":
|
||||
rel_prefix = ""
|
||||
else:
|
||||
rel_prefix = path.strip()
|
||||
if not rel_prefix.endswith("/"):
|
||||
rel_prefix = rel_prefix + "/"
|
||||
|
||||
items: List[FileTreeItem] = []
|
||||
try:
|
||||
entries = sorted(os.listdir(real_target))
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"读取目录失败:{str(e)}"
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
abs_path = os.path.join(real_target, entry)
|
||||
is_dir = os.path.isdir(abs_path)
|
||||
|
||||
# 基于 directory 的相对路径:文件夹末尾带 /,文件不带
|
||||
rel_path = rel_prefix + entry + ("/" if is_dir else "")
|
||||
|
||||
items.append(
|
||||
FileTreeItem(
|
||||
name=entry,
|
||||
path=rel_path,
|
||||
absolute=abs_path,
|
||||
type="directory" if is_dir else "file",
|
||||
ignored=_is_git_ignored(directory, abs_path),
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _resolve_file_path(directory: str, path: str):
|
||||
"""
|
||||
解析并校验「相对文件路径」对应的真实物理路径。
|
||||
|
||||
校验规则:
|
||||
- directory 必须存在且为目录
|
||||
- path 必须指向具体文件,不能为根目录(. / 空)
|
||||
- 禁止路径逃逸(..)与绝对路径
|
||||
- realpath 解析后必须仍位于 directory 内(防符号链接逃逸)
|
||||
|
||||
返回:(real_directory, real_target, rel_path)
|
||||
"""
|
||||
# 规范化 directory
|
||||
directory = os.path.normpath(directory)
|
||||
if not os.path.isdir(directory):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"目录不存在或不是文件夹:{directory}"
|
||||
)
|
||||
|
||||
rel_path = path.strip().rstrip("/")
|
||||
# 仅允许相对路径,禁止路径逃逸
|
||||
if rel_path in ("", "."):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="path 必须指向具体文件,不能为根目录"
|
||||
)
|
||||
if ".." in rel_path.split("/"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="非法的相对路径,禁止路径逃逸 (..)"
|
||||
)
|
||||
if os.path.isabs(rel_path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="相对路径 path 不能为绝对路径"
|
||||
)
|
||||
|
||||
target = os.path.join(directory, rel_path)
|
||||
|
||||
# 解析真实物理路径,确保最终路径在允许范围内(防逃逸,允许工作空间内软链接)
|
||||
real_directory = os.path.realpath(directory)
|
||||
real_target = os.path.realpath(target)
|
||||
_assert_path_allowed(real_directory, real_target)
|
||||
|
||||
return real_directory, real_target, rel_path
|
||||
|
||||
|
||||
@router.get("/file/content", response_model=FileContentResponse)
|
||||
async def get_file_content(
|
||||
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
|
||||
path: str = Query(..., description="相对于 directory 的文件路径,如 appfile/main.py"),
|
||||
):
|
||||
"""
|
||||
预览文件内容
|
||||
|
||||
- directory:项目顶层绝对目录
|
||||
- path:相对于 directory 的文件路径(不带尾部 /,指向具体文件)
|
||||
- 返回文本文件内容;二进制文件返回 type=binary 且 content 为空
|
||||
"""
|
||||
_real_directory, real_target, _rel_path = _resolve_file_path(directory, path)
|
||||
|
||||
if not os.path.exists(real_target):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"文件不存在:{path}"
|
||||
)
|
||||
if os.path.isdir(real_target):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"目标路径是文件夹,不是文件:{path}"
|
||||
)
|
||||
|
||||
# 限制预览文件大小,避免读取超大文件
|
||||
max_size = 5 * 1024 * 1024 # 5MB
|
||||
try:
|
||||
file_size = os.path.getsize(real_target)
|
||||
if file_size > max_size:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"文件过大,无法预览({file_size} 字节,上限 {max_size} 字节)"
|
||||
)
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"读取文件信息失败:{str(e)}"
|
||||
)
|
||||
|
||||
# 先按二进制读取前若干字节判断是否为文本
|
||||
try:
|
||||
with open(real_target, "rb") as f:
|
||||
chunk = f.read(1024)
|
||||
# 含 NUL 字节通常视为二进制
|
||||
if b"\x00" in chunk:
|
||||
return FileContentResponse(type="binary", content="")
|
||||
|
||||
# 尝试以 UTF-8 解码全文
|
||||
with open(real_target, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return FileContentResponse(type="text", content=content)
|
||||
except UnicodeDecodeError:
|
||||
# 非 UTF-8 文本,按二进制处理
|
||||
return FileContentResponse(type="binary", content="")
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"读取文件失败:{str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/file/content", response_model=FileContentUpdateResponse)
|
||||
async def update_file_content(
|
||||
request: FileContentUpdateRequest,
|
||||
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
|
||||
path: str = Query(..., description="相对于 directory 的文件路径,如 appfile/main.py"),
|
||||
):
|
||||
"""
|
||||
修改(覆盖写入)文件内容
|
||||
|
||||
- directory:项目顶层绝对目录
|
||||
- path:相对于 directory 的文件路径(不带尾部 /,指向具体文件)
|
||||
- 请求体 content:文件新内容(UTF-8 文本,整文件覆盖写入)
|
||||
"""
|
||||
_real_directory, real_target, rel_path = _resolve_file_path(directory, path)
|
||||
|
||||
if not os.path.exists(real_target):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"文件不存在:{path}"
|
||||
)
|
||||
if os.path.isdir(real_target):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"目标路径是文件夹,不是文件:{path}"
|
||||
)
|
||||
|
||||
try:
|
||||
# 整文件覆盖写入(UTF-8)
|
||||
with open(real_target, "w", encoding="utf-8") as f:
|
||||
f.write(request.content)
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"写入文件失败:{str(e)}"
|
||||
)
|
||||
|
||||
return FileContentUpdateResponse(
|
||||
success=True,
|
||||
message="文件已保存",
|
||||
path=rel_path,
|
||||
absolute=real_target,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/file", response_model=FileOperationResponse)
|
||||
async def create_file_entry(
|
||||
request: FileCreateRequest,
|
||||
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
|
||||
path: str = Query(..., description="相对于 directory 的新建项路径,如 appfile/new.py"),
|
||||
):
|
||||
"""
|
||||
新建文件 / 文件夹
|
||||
|
||||
- directory:项目顶层绝对目录
|
||||
- path:相对于 directory 的新建项路径(不带尾部 /)
|
||||
- 请求体 type:file / directory;type=file 时可用 content 指定初始内容
|
||||
- 目标已存在返回 409;新建文件时会自动创建所需父目录
|
||||
"""
|
||||
_real_directory, real_target, rel_path = _resolve_file_path(directory, path)
|
||||
|
||||
if os.path.exists(real_target):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"目标已存在:{path}"
|
||||
)
|
||||
|
||||
item_type = request.type
|
||||
try:
|
||||
if item_type == "directory":
|
||||
os.makedirs(real_target, exist_ok=False)
|
||||
else:
|
||||
# 文件:确保父目录存在后写入初始内容
|
||||
parent_dir = os.path.dirname(real_target)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
with open(real_target, "w", encoding="utf-8") as f:
|
||||
f.write(request.content or "")
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"新建失败:{str(e)}"
|
||||
)
|
||||
|
||||
return FileOperationResponse(
|
||||
success=True,
|
||||
message=f"{'文件夹' if item_type == 'directory' else '文件'}已创建",
|
||||
path=rel_path,
|
||||
absolute=real_target,
|
||||
type=item_type,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/file", response_model=FileOperationResponse)
|
||||
async def delete_file_entry(
|
||||
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
|
||||
path: str = Query(..., description="相对于 directory 的待删除项路径"),
|
||||
):
|
||||
"""
|
||||
删除文件 / 文件夹
|
||||
|
||||
- directory:项目顶层绝对目录
|
||||
- path:相对于 directory 的待删除项路径(文件夹可带尾部 /)
|
||||
- 文件夹递归删除;不存在返回 404
|
||||
- path 不能为根目录(.),不会删除 directory 本身
|
||||
"""
|
||||
_real_directory, real_target, rel_path = _resolve_file_path(directory, path)
|
||||
|
||||
if not os.path.exists(real_target):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"目标不存在:{path}"
|
||||
)
|
||||
|
||||
is_dir = os.path.isdir(real_target)
|
||||
try:
|
||||
if is_dir:
|
||||
shutil.rmtree(real_target)
|
||||
else:
|
||||
os.remove(real_target)
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"删除失败:{str(e)}"
|
||||
)
|
||||
|
||||
return FileOperationResponse(
|
||||
success=True,
|
||||
message=f"{'文件夹' if is_dir else '文件'}已删除",
|
||||
path=rel_path,
|
||||
absolute=real_target,
|
||||
type="directory" if is_dir else "file",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/project/create", response_model=ProjectCreateResponse)
|
||||
async def create_project(request: ProjectCreateRequest):
|
||||
"""
|
||||
创建新项目
|
||||
|
||||
- 校验项目名格式
|
||||
- 拼接完整路径
|
||||
- 确保路径在安全目录内
|
||||
- 创建项目目录
|
||||
- 软链接 BASE_WORKSPACE/tables_meta 到新项目下(共享表元数据)
|
||||
"""
|
||||
project_name = request.project_name
|
||||
|
||||
# 二次校验:禁止路径逃逸字符(防御性编程)
|
||||
if ".." in project_name or "/" in project_name or "\\" in project_name:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="项目名称不能包含路径逃逸字符 (../ 或 / 或 \\)"
|
||||
)
|
||||
|
||||
# 拼接完整路径
|
||||
project_path = os.path.join(settings.BASE_WORKSPACE, project_name)
|
||||
|
||||
# 安全校验:确保最终路径在 base_workspace 内
|
||||
real_workspace = os.path.realpath(settings.BASE_WORKSPACE)
|
||||
real_project_path = os.path.realpath(os.path.dirname(project_path))
|
||||
|
||||
# 检查路径是否逃逸
|
||||
if not real_project_path.startswith(real_workspace):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="非法的项目路径,检测到路径逃逸尝试"
|
||||
)
|
||||
|
||||
try:
|
||||
# 创建项目目录
|
||||
os.makedirs(project_path, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"创建项目目录失败:{str(e)}"
|
||||
)
|
||||
|
||||
# 软链接 BASE_WORKSPACE/tables_meta 到新项目下(共享表元数据)
|
||||
# 使用相对路径,便于工作空间整体迁移;已存在则不覆盖
|
||||
tables_meta_src = os.path.join(settings.BASE_WORKSPACE, "tables_meta")
|
||||
tables_meta_link = os.path.join(project_path, "tables_meta")
|
||||
symlink_note = ""
|
||||
try:
|
||||
if os.path.lexists(tables_meta_link):
|
||||
# 已存在(可能是历史软链接或实际文件),不覆盖
|
||||
pass
|
||||
elif not os.path.exists(tables_meta_src):
|
||||
symlink_note = "(未找到 tables_meta 源,已跳过软链接)"
|
||||
else:
|
||||
rel_src = os.path.relpath(tables_meta_src, project_path)
|
||||
os.symlink(rel_src, tables_meta_link)
|
||||
except OSError as e:
|
||||
symlink_note = f"(软链接 tables_meta 失败:{str(e)})"
|
||||
|
||||
return ProjectCreateResponse(
|
||||
success=True,
|
||||
message=f"项目 '{project_name}' 创建成功{symlink_note}",
|
||||
project_path=project_path
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
数据模式模块初始化
|
||||
"""
|
||||
@@ -1,61 +0,0 @@
|
||||
"""
|
||||
文件系统相关 Pydantic 模式定义
|
||||
"""
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FileTreeItem(BaseModel):
|
||||
"""文件树单个条目(文件 / 文件夹)"""
|
||||
|
||||
name: str = Field(..., description="文件 / 文件夹展示名称")
|
||||
path: str = Field(
|
||||
...,
|
||||
description="基于 directory 的相对路径;文件夹末尾带 /,文件不带",
|
||||
)
|
||||
absolute: str = Field(..., description="服务器完整绝对物理路径")
|
||||
type: str = Field(..., description="资源类型:directory / file")
|
||||
ignored: bool = Field(..., description="是否属于被忽略的文件(如 git 忽略文件)")
|
||||
|
||||
|
||||
class FileContentResponse(BaseModel):
|
||||
"""文件内容预览响应"""
|
||||
|
||||
type: str = Field(
|
||||
...,
|
||||
description="内容类型:text(文本,可预览)/ binary(二进制,不可直接预览)",
|
||||
)
|
||||
content: str = Field(..., description="文件文本内容;二进制文件为空字符串")
|
||||
|
||||
|
||||
class FileContentUpdateRequest(BaseModel):
|
||||
"""文件内容修改请求"""
|
||||
|
||||
content: str = Field(..., description="文件新内容(UTF-8 文本)")
|
||||
|
||||
|
||||
class FileContentUpdateResponse(BaseModel):
|
||||
"""文件内容修改响应"""
|
||||
|
||||
success: bool = True
|
||||
message: str = Field(..., description="操作结果说明")
|
||||
path: str = Field(..., description="基于 directory 的相对文件路径")
|
||||
absolute: str = Field(..., description="服务器完整绝对物理路径")
|
||||
|
||||
|
||||
class FileCreateRequest(BaseModel):
|
||||
"""文件 / 文件夹新建请求"""
|
||||
|
||||
type: Literal["file", "directory"] = Field(..., description="新建类型:file / directory")
|
||||
content: Optional[str] = Field(default=None, description="文件初始内容(仅 type=file 生效)")
|
||||
|
||||
|
||||
class FileOperationResponse(BaseModel):
|
||||
"""文件操作(新建 / 删除)响应"""
|
||||
|
||||
success: bool = True
|
||||
message: str = Field(..., description="操作结果说明")
|
||||
path: str = Field(..., description="基于 directory 的相对路径")
|
||||
absolute: str = Field(..., description="服务器完整绝对物理路径")
|
||||
type: str = Field(..., description="资源类型:directory / file")
|
||||
@@ -1,38 +0,0 @@
|
||||
"""
|
||||
Pydantic 模式定义
|
||||
"""
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
import re
|
||||
|
||||
|
||||
class ProjectCreateRequest(BaseModel):
|
||||
"""项目创建请求"""
|
||||
|
||||
project_name: str = Field(..., description="项目文件夹名称", min_length=1, max_length=100)
|
||||
|
||||
@field_validator("project_name")
|
||||
@classmethod
|
||||
def validate_project_name(cls, v: str) -> str:
|
||||
"""
|
||||
验证项目名:
|
||||
- 仅允许中英文、数字、下划线、横杠
|
||||
- 禁止路径逃逸字符
|
||||
"""
|
||||
# 检查是否包含路径逃逸字符
|
||||
if ".." in v or "/" in v or "\\" in v:
|
||||
raise ValueError("项目名称不能包含路径逃逸字符 (../ 或 / 或 \\)")
|
||||
|
||||
# 验证项目名称格式:仅允许中英文、数字、下划线、横杠
|
||||
pattern = r"^[\w一-龥\-]+$"
|
||||
if not re.match(pattern, v):
|
||||
raise ValueError("项目名称仅允许包含中英文、数字、下划线、横杠")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class ProjectCreateResponse(BaseModel):
|
||||
"""项目创建响应"""
|
||||
|
||||
success: bool = True
|
||||
message: str
|
||||
project_path: str
|
||||
@@ -1,188 +0,0 @@
|
||||
# 前后端连接指南
|
||||
|
||||
## 前端配置方式
|
||||
|
||||
### 1. 环境变量配置(推荐)
|
||||
|
||||
前端项目使用 `.env` 文件配置后端地址:
|
||||
|
||||
```bash
|
||||
# OpenCodeUI/.env
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
### 2. 修改位置
|
||||
|
||||
前端项目中的 API 地址配置文件:
|
||||
- **文件路径**: `OpenCodeUI/src/constants/api.ts`
|
||||
- **配置项**: `API_BASE_URL`
|
||||
|
||||
```typescript
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:4096'
|
||||
```
|
||||
|
||||
## 添加项目创建接口到前端
|
||||
|
||||
### 步骤 1: 创建 API 调用函数
|
||||
|
||||
在 `OpenCodeUI/src/api/` 目录下创建 `project.ts`:
|
||||
|
||||
```typescript
|
||||
// OpenCodeUI/src/api/project.ts
|
||||
|
||||
import { getApiBaseUrl, getAuthHeader } from './http'
|
||||
|
||||
export interface ProjectCreateRequest {
|
||||
project_name: string
|
||||
}
|
||||
|
||||
export interface ProjectCreateResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
project_path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新项目
|
||||
* POST /fs/project/create
|
||||
*/
|
||||
export async function createProject(
|
||||
params: ProjectCreateRequest
|
||||
): Promise<ProjectCreateResponse> {
|
||||
const baseUrl = getApiBaseUrl()
|
||||
const url = `${baseUrl}/fs/project/create`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: '请求失败' }))
|
||||
throw new Error(error.detail || response.statusText)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2: 在 React 组件中使用
|
||||
|
||||
```tsx
|
||||
// OpenCodeUI/src/components/ProjectCreate.tsx
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { createProject } from '@/api/project'
|
||||
|
||||
export function ProjectCreate() {
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await createProject({ project_name: projectName })
|
||||
alert(result.message)
|
||||
setProjectName('')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="输入项目名称"
|
||||
/>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '创建中...' : '创建项目'}
|
||||
</button>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 本地开发联调
|
||||
|
||||
### 1. 启动后端服务
|
||||
|
||||
```bash
|
||||
cd OpenCode-backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
### 2. 启动前端开发服务器
|
||||
|
||||
```bash
|
||||
cd OpenCodeUI
|
||||
# 确保 .env 文件中配置了正确的后端地址
|
||||
echo "VITE_API_BASE_URL=http://localhost:8000" > .env.local
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. 跨域配置(如需要)
|
||||
|
||||
后端已配置 CORS,允许所有来源访问。如需限制,修改:
|
||||
|
||||
```python
|
||||
# OpenCode-backend/app/core/config.py
|
||||
class Settings(BaseSettings):
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||
```
|
||||
|
||||
## API 接口列表
|
||||
|
||||
| 接口 | 方法 | 地址 | 描述 |
|
||||
|------|------|------|------|
|
||||
| 创建项目 | POST | `/fs/project/create` | 创建新项目目录 |
|
||||
| 健康检查 | GET | `/` | 服务状态检查 |
|
||||
|
||||
## 请求示例
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/fs/project/create" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project_name": "my_project"}'
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "项目 'my_project' 创建成功",
|
||||
"project_path": "/root/workspace/my_project"
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "项目名称仅允许包含中英文、数字、下划线、横杠"
|
||||
}
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
1. **路径安全**: 后端已锁定所有文件操作在 `/root/workspace` 目录内
|
||||
2. **输入校验**: 项目名经过正则校验,禁止路径逃逸字符
|
||||
3. **CORS**: 生产环境建议配置具体的允许来源
|
||||
@@ -1,4 +0,0 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.5.2
|
||||
Reference in New Issue
Block a user