diff --git a/Dockerfile b/Dockerfile index 93f6786..d78c0f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && npm install -g @anthropic-ai/claude-code@latest \ - && npm install -g openclaw@2026.3.13 \ + && npm install -g openclaw@2026.3.23-2 \ && npm install -g clawhub@latest \ && npm install -g @playwright/cli@latest \ && apt-get clean \ @@ -20,14 +20,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /root/.npm \ && rm -rf /tmp/* -# 创建用户 -RUN groupadd -r user && useradd -r -g user -m -s /bin/bash user - # 创建目录并设置权限 -RUN mkdir -p /data /data/user /app /home/user/.claude/skills /home/user/.openclaw /home/user/db && \ - chown -R user:user /data /app /home/user && \ - chmod 755 /data /app /home/user && \ - chmod 775 /home/user/db /home/user/.claude /home/user/.claude/skills /home/user/.openclaw +RUN mkdir -p /data /app /home/user/.claude/skills /home/user/.openclaw /home/user/db && \ + chmod 755 /home/user/.openclaw /home/user/.claude /home/user/.claude/skills && \ + chmod 755 /data /app /home/user # 安装 Python 依赖 WORKDIR /app @@ -35,38 +31,26 @@ COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt # 复制代码 -USER user -COPY --chown=user:user . /app +COPY . /app # 复制自定义 skills 到不会被挂载覆盖的目录 -COPY --chown=user:user skills/ /app/.skills-backup/ +COPY skills/ /app/.skills-backup/ ENV HOME=/home/user # 安装插件 -RUN openclaw plugins install @openclaw-china/channels +RUN openclaw plugins install @openclaw-china/channels@latest +RUN openclaw plugins install @openclaw/acpx +RUN #npx -y @tencent-weixin/openclaw-weixin-cli@latest install # 把插件数据备份到不会被挂载覆盖的目录 RUN mkdir -p /app/.openclaw-extensions-backup && \ cp -a /home/user/.openclaw/extensions /app/.openclaw-extensions-backup/ +# 复制启动脚本 +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + EXPOSE 8000 18789 -# 启动时检查 channels 插件是否存在,不存在才恢复 -CMD ["sh", "-c", "\ - if [ ! -d \"/home/user/.openclaw/extensions/channels\" ]; then \ - mkdir -p /home/user/.openclaw/extensions && \ - cp -a /app/.openclaw-extensions-backup/extensions/* /home/user/.openclaw/extensions/ 2>/dev/null || true; \ - echo 'Restored openclaw extensions (channels plugin was missing)'; \ - else \ - echo 'channels plugin exists, skipping restore'; \ - fi && \ - mkdir -p /home/user/.claude/skills && \ - for p in /app/.skills-backup/*; do \ - name=\"$(basename \"$p\")\"; \ - cp -a \"$p\" /home/user/.claude/skills/ 2>/dev/null || true; \ - echo \"Restored skill entry (overwrite): $name\"; \ - done && \ - openclaw gateway run --port 18789 --bind lan & \ - uvicorn main:app --host 0.0.0.0 --port 8000\ - "] +CMD ["/app/entrypoint.sh"] diff --git a/app/api/routes_chat.py b/app/api/routes_chat.py index 9398f51..c703ea4 100644 --- a/app/api/routes_chat.py +++ b/app/api/routes_chat.py @@ -768,7 +768,6 @@ async def _run_openclaw_cmd(): if list_sessions_result.exit_code != 0: raise Exception(list_sessions_result.stderr) - try: data = json.loads(list_sessions_result.stdout) except json.decoder.JSONDecodeError: @@ -846,6 +845,10 @@ async def _run_openclaw_cmd(): prefix = "\n\n".join(prefix_parts) + "\n\n" final_user_prompt = prefix + user_prompt + collected_text_chunks: list[str] = [] + # 确保扩展名在路径最后一个 / 之后 + file_regex = re.compile(r"^- `?(\/\S+\/[^/\s]+\.[^/\s`]+)`?$", re.MULTILINE) + async for event in oc_chat_completions_sse( oc_session_key=oc_session_key, user_prompt=final_user_prompt, @@ -853,32 +856,59 @@ async def _run_openclaw_cmd(): ): # event 是 bytes,需要对应处理 if event.strip() == b"data: [DONE]": - - # check_cmd = """find . -maxdepth 4 \( -path "./claude" -o -path "./claude/*" -o -path "./node_modules" -o -path "./node_modules/*" -o -path "./.git" -o -path "./.git/*" -o -path "./venv" -o -path "./venv/*" -o -path "./.venv" -o -path "./.venv/*" -o -path "./env" -o -path "./env/*" -o -path "./__pycache__" -o -path "./__pycache__/*" \) -prune -o -type f \( -name "package.json" -o -name "pnpm-lock.yaml" -o -name "yarn.lock" -o -name "package-lock.json" -o -name "next.config.*" -o -name "vite.config.*" -o -name "vue.config.*" -o -name "nuxt.config.*" -o -name "svelte.config.*" -o -name "astro.config.*" -o -name "remix.config.*" -o -name "angular.json" -o -name "gatsby-config.*" -o -path "./index.html" -o -path "*/public/index.html" -o -path "./server.js" -o -path "./app.js" -o -path "./index.js" -o -path "./main.js" -o -path "./server.ts" -o -path "./app.ts" -o -path "./index.ts" -o -path "./main.ts" -o -path "./src/index.js" -o -path "./src/index.ts" -o -path "./src/index.jsx" -o -path "./src/index.tsx" -o -path "./src/main.js" -o -path "./src/main.ts" -o -path "./src/main.jsx" -o -path "./src/main.tsx" -o -path "./src/App.vue" -o -path "./src/app.js" -o -path "./src/app.ts" -o -path "./src/app.jsx" -o -path "./src/app.tsx" -o -path "./src/server.js" -o -path "./src/server.ts" -o \( -path "./src/*" -a \( -name "*.js" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" -o -name "*.vue" \) \) \)""" + # 在 DONE 时,从之前累计的文本块里提取路径并插入一个额外 chunk + combined = "".join(collected_text_chunks) + matches = file_regex.findall(combined) + if matches: + # 校验路径是否存在,只保留存在的 + valid_matches = [m for m in matches if os.path.isfile(m)] + diff_file_check_info = json.dumps({ + "type": "result", + "is_error": False, + "result": "\n".join(f"-`{m}`" for m in valid_matches) if valid_matches else "", + }, ensure_ascii=False) + else: + diff_file_check_info = json.dumps({ + "type": "result", + "is_error": False, + "result": "", + }, ensure_ascii=False) + diff_file_chunk = gpt_stream_chunk(diff_file_check_info) + yield f"data: {json.dumps(diff_file_chunk, ensure_ascii=False)}\n\n".encode("utf-8") if payload.enable_pre_deploy_check: - pre_deploy_check_result = await runner.exec_json(command=check_cmd, cwd=workspace_path) if pre_deploy_check_result.exit_code == 0: - if pre_deploy_check_result.stdout: - deploy_check_info = json.dumps({ - "type": "pre_deploy_check", - "success": True, - "find_file": pre_deploy_check_result.stdout, - }) - else: - deploy_check_info = json.dumps({ - "type": "pre_deploy_check", - "success": False, - "find_file": pre_deploy_check_result.stdout, - }) + deploy_check_info = json.dumps({ + "type": "pre_deploy_check", + "success": bool(pre_deploy_check_result.stdout), + "find_file": pre_deploy_check_result.stdout, + }, ensure_ascii=False) # 插入自定义文本 chunk - chunk = gpt_stream_chunk(f"{json.dumps(deploy_check_info, ensure_ascii=False)}") + chunk = gpt_stream_chunk(deploy_check_info) yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode("utf-8") + # 放行 [DONE] yield event else: + # 非 DONE:透传,同时尽量从 OpenAI chunk 中抽取文本累计 + try: + line = event.decode("utf-8", errors="ignore").strip() + if line.startswith("data: "): + payload_json = line[6:] + if payload_json and payload_json != "[DONE]": + obj = json.loads(payload_json) + content = ( + obj.get("choices", [{}])[0] + .get("delta", {}) + .get("content") + ) + if isinstance(content, str) and content: + collected_text_chunks.append(content) + except Exception: + pass + yield event # 处理messages diff --git a/app/api/routes_session.py b/app/api/routes_session.py index 267541e..f76ca86 100644 --- a/app/api/routes_session.py +++ b/app/api/routes_session.py @@ -222,28 +222,28 @@ async def init_project(payload: ProjectInitRequest, repo: SessionRepository = De oc_agent_id = workspace_name - new_resp, list_sessions_result = await oc_new_session_and_list_active( - oc_agent_id=oc_agent_id, - runner=runner, - active=3, - ) - log_info(f"{new_resp}") - - if list_sessions_result.exit_code != 0: - return fail(list_sessions_result.stderr, status_code=400) - try: - data = json.loads(list_sessions_result.stdout) - except json.decoder.JSONDecodeError: - # fix openclaw 3.13 CLI --json没正确返回json格式 - data = await oc_load_sessions_json_as_list(oc_agent_name=oc_agent_id) - sessions = data.get("sessions", []) - - if not sessions: - return fail("No active sessions found", status_code=400) - - # 按 updatedAt 降序排序,取最新的一个 - latest_session = max(sessions, key=lambda s: s.get("updatedAt", 0)) - log_info(f"{latest_session}") + new_resp, list_sessions_result = await oc_new_session_and_list_active( + oc_agent_id=oc_agent_id, + runner=runner, + active=3, + ) + log_info(f"{new_resp}") + + if list_sessions_result.exit_code != 0: + return fail(list_sessions_result.stderr, status_code=400) + try: + data = json.loads(list_sessions_result.stdout) + except json.decoder.JSONDecodeError: + # fix openclaw 3.13 CLI --json没正确返回json格式 + data = await oc_load_sessions_json_as_list(oc_agent_name=oc_agent_id) + sessions = data.get("sessions", []) + + if not sessions: + return fail("No active sessions found", status_code=400) + + # 按 updatedAt 降序排序,取最新的一个 + latest_session = max(sessions, key=lambda s: s.get("updatedAt", 0)) + log_info(f"{latest_session}") await run_in_threadpool(lambda: repo.create_session( session_alias=payload.session_id, diff --git a/app/api/routes_skill.py b/app/api/routes_skill.py index c75bff6..ac408e3 100644 --- a/app/api/routes_skill.py +++ b/app/api/routes_skill.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import base64 import hashlib import io @@ -8,6 +9,7 @@ import shutil import tempfile import zipfile +from datetime import datetime from pathlib import Path from typing import Any, Optional from urllib.parse import quote @@ -34,6 +36,9 @@ from pydantic import BaseModel, Field +from app.repositories.skill_favorite import SkillFavoriteRepository +from app.repositories.skill_manual_import import SkillManualImportRepository + router = APIRouter() @@ -42,19 +47,34 @@ class SkillDeleteRequest(BaseModel): skill_list: list = Field([], description="skill_name list") skill_id_list: list = Field([], description="skill_id list") +class SkillFavoriteAddRequest(BaseModel): + skill_list: list = Field([], description="skill_name list") + +class SkillFavoriteCancelRequest(BaseModel): + skill_list: list = Field([], description="skill_name list") + def get_skill_desc_cache_repo(db=Depends(get_db)) -> SkillDescZhCacheRepository: return SkillDescZhCacheRepository(db) +def get_skill_favorite_repo(db=Depends(get_db)) -> SkillFavoriteRepository: + return SkillFavoriteRepository(db) + +def get_skill_manual_import_repo(db=Depends(get_db)) -> SkillManualImportRepository: + return SkillManualImportRepository(db) + @router.post("/skills") -async def create_skill(request: Request, repo: SkillDescZhCacheRepository = Depends(get_skill_desc_cache_repo)): +async def create_skill(request: Request, + repo: SkillDescZhCacheRepository = Depends(get_skill_desc_cache_repo), + manual_skill_repo: SkillManualImportRepository = Depends(get_skill_manual_import_repo)): """ 用户上传zip压缩包或者提供github链接,将数据先下载到临时文件,遍历寻找SKILL.md拷贝到实际保存位置 skill名字/描述通过解析SKILL.md里的yaml元信息获得 + :param manual_skill_repo: :param request: :param repo: :return: @@ -178,6 +198,11 @@ def cache_put_op(key=desc_key, desc=description, zh=description_zh): "description_zh": description_zh, }) + with manual_skill_repo.atomic(): + await run_in_threadpool( + lambda: manual_skill_repo.upsert(skill_name=name) + ) + await asyncio.sleep(1) # fix 操作skill之后请求马上过来,OC还没刷新SKILL return ok({"user_skills": skill_list}) @router.get("/skills/detail") @@ -237,17 +262,20 @@ async def skill_list( limit: int = Query(50, description="每页数量"), offset: int = Query(0, description="偏移量"), cache_repo: SkillDescZhCacheRepository = Depends(get_skill_desc_cache_repo), + favorite_skill_repo: SkillFavoriteRepository = Depends(get_skill_favorite_repo), + manual_skill_repo: SkillManualImportRepository = Depends(get_skill_manual_import_repo) ): # 直接通过 openclaw CLI 获取 skill 列表(包含 source 等信息) oc_runner = CommandRunner() + oc_result = await oc_runner.exec_json("openclaw skills list --json") oc_skills: list[dict] = [] - if oc_result.exit_code == 0 and oc_result.stdout: + if oc_result.exit_code == 0: try: import json - loaded = json.loads(oc_result.stdout) + loaded = json.loads(oc_result.stdout or oc_result.stderr) # 升级到openclaw 2026.3.22-2版本后,获取skills的结果意外输出到stderr # openclaw skills list --json 输出为 { ..., "skills": [...] } if isinstance(loaded, dict) and isinstance(loaded.get("skills"), list): oc_skills = [x for x in loaded["skills"] if isinstance(x, dict)] @@ -313,6 +341,55 @@ def _put_once(k=key, d=desc, z=zh): await run_in_threadpool(_put_once) zh_by_md5[key] = zh + # 1. 获取收藏列表 + favorite_skills = await run_in_threadpool( + lambda: favorite_skill_repo.list() + ) + + # 获取手动导入skills的时间信息 + manual_skills = await run_in_threadpool( + lambda: manual_skill_repo.list() + ) + + # --- 排序逻辑开始 --- + # 收藏优先级:按收藏时间倒序排列(repo 层已保证顺序),构建 name -> idx 映射 + priority_map = { + item["skill_name"]: idx + for idx, item in enumerate(favorite_skills) + } + + # 收藏时间映射:skill_name -> favorite_time + favorite_time_map = { + item["skill_name"]: item["favorite_time"] + for item in favorite_skills + } + + # 手动导入时间映射:skill_name -> manual_import_time + manual_time_map = { + item["skill_name"]: item["manual_import_time"] + for item in manual_skills + } + + # 用于排序的零时间基准 + ZERO_TIME = datetime(1970, 1, 1) + + def get_sort_key(item): + name = item.get("name", "") + if name in priority_map: + # 第一优先级:收藏的 skill,按收藏顺序排列 + return (0, priority_map[name], ZERO_TIME) + else: + # 第二优先级:非收藏的 skill,按手动导入时间倒序排列(越新越靠前) + # 不存在手动导入时间的视为 ZERO_TIME,排在最后 + import_time = manual_time_map.get(name, ZERO_TIME) + return (1, -import_time.timestamp() if import_time else 0, name) + + sorted_items = sorted( + [s for s in items if isinstance(s, dict)], + key=get_sort_key + ) + # --- 排序逻辑结束 --- + return ok( { "user_skills": [ @@ -323,15 +400,25 @@ def _put_once(k=key, d=desc, z=zh): "description_zh": zh_by_md5.get(_md5_16(s.get("description"))) if isinstance(s.get("description"), str) and s.get("description") else "", - "source": (s.get("source") or "") if isinstance(s.get("source"), str) else "", + "source": "openclaw-bundled" if ( + isinstance(s.get("name"), str) and s.get("name") == "302ai-search") else ( + (s.get("source") or "") if isinstance(s.get("source"), str) else ""), "eligible": bool(s.get("eligible")) if "eligible" in s else None, "disabled": bool(s.get("disabled")) if "disabled" in s else None, "bundled": bool(s.get("bundled")) if "bundled" in s else None, - "blockedByAllowlist": bool(s.get("blockedByAllowlist")) if "blockedByAllowlist" in s else None, + "blockedByAllowlist": bool( + s.get("blockedByAllowlist")) if "blockedByAllowlist" in s else None, "missing": s.get("missing") if isinstance(s.get("missing"), dict) else None, + "is_favorite": s.get("name") in priority_map, + # 新增:收藏时间 + "favorite_at": favorite_time_map[s.get("name")].strftime("%Y-%m-%dT%H:%M:%S.%fZ") + if s.get("name") in favorite_time_map and favorite_time_map.get(s.get("name")) + else None, + "manual_import_at": manual_time_map.get(s.get("name")).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + if manual_time_map.get(s.get("name")) + else None, } - for s in items - if isinstance(s, dict) + for s in sorted_items ], "builtin_skills": [], "project_skills": [], @@ -396,3 +483,30 @@ async def skill_delete( return ok({"data": {"result": delete_result}}) + +@router.post("/skills/favorite/add") +async def skill_favorite_add(payload: SkillFavoriteAddRequest, + repo: SkillFavoriteRepository = Depends(get_skill_favorite_repo)): + + def op(): + with repo.atomic(): + for skill_name in payload.skill_list: + repo.add(skill_name=skill_name) + + await run_in_threadpool(op) + + return ok() + + +@router.post("/skills/favorite/cancel") +async def skill_favorite_cancel(payload: SkillFavoriteCancelRequest, + repo: SkillFavoriteRepository = Depends(get_skill_favorite_repo)): + + def op(): + with repo.atomic(): + for skill_name in payload.skill_list: + repo.delete(skill_name=skill_name) + + await run_in_threadpool(op) + + return ok() \ No newline at end of file diff --git a/app/core/request_id_middleware.py b/app/core/request_id_middleware.py index 5773f1d..4c34095 100644 --- a/app/core/request_id_middleware.py +++ b/app/core/request_id_middleware.py @@ -48,7 +48,7 @@ async def dispatch(self, request: Request, call_next): "/302/claude-code/messages", "/302/claude-code/skills/detail", "/302/claude-code/chat/completions", - "/302/claude-code/sandbox/execute/stream", + "/302/claude-code/commands/stream", "/api/v1/chat/completions" ] if request.url.path in stream_url_path: diff --git a/app/models/skill_favorite.py b/app/models/skill_favorite.py new file mode 100644 index 0000000..8d2e4eb --- /dev/null +++ b/app/models/skill_favorite.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import datetime +import hashlib + +from peewee import AutoField, CharField, DateTimeField, TextField, BooleanField + +from app.models.base import BaseModel + +class SkillFavorite(BaseModel): + id = AutoField() + skill_name = CharField(max_length=64, unique=True) + favorite_time = DateTimeField(default=datetime.datetime.now) + + class Meta: + table_name = "skill_favorites" + indexes = ( + (('favorite_time',), False), + ) diff --git a/app/models/skill_manual.py b/app/models/skill_manual.py new file mode 100644 index 0000000..d556e38 --- /dev/null +++ b/app/models/skill_manual.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import datetime +import hashlib + +from peewee import AutoField, CharField, DateTimeField, TextField, BooleanField + +from app.models.base import BaseModel + +class SkillManualImport(BaseModel): + id = AutoField() + skill_name = CharField(max_length=64, unique=True) + manual_import_time = DateTimeField(default=datetime.datetime.now) + + class Meta: + table_name = "skill_manual_import" + indexes = ( + (('manual_import_time',), False), + ) diff --git a/app/repositories/skill_favorite.py b/app/repositories/skill_favorite.py new file mode 100644 index 0000000..05b64d0 --- /dev/null +++ b/app/repositories/skill_favorite.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import Iterator + +from peewee import SqliteDatabase + +from app.models.base import bind_models +from app.models.skill_favorite import SkillFavorite + + +class SkillFavoriteRepository: + def __init__(self, db: SqliteDatabase): + self.db = db + # 绑定模型到数据库 + bind_models(db, [SkillFavorite]) + + def _ensure_tables(self) -> None: + self.db.create_tables([SkillFavorite]) + + @contextmanager + def atomic(self) -> Iterator[None]: + with self.db.atomic(): + yield + + def add(self, *, skill_name: str) -> bool: + """ + 添加收藏。 + 由于 skill_name 设定了 unique=True,使用 on_conflict_ignore() 避免重复插入报错。 + 返回 True 表示新增成功,False 表示已存在(未新增)。 + """ + self._ensure_tables() + + before = SkillFavorite.select().count() + # 插入数据,favorite_time 由数据库默认值处理 + SkillFavorite.insert({SkillFavorite.skill_name: skill_name}).on_conflict_ignore().execute() + after = SkillFavorite.select().count() + + return after > before + + def list(self) -> list[dict]: + """ + 获取所有收藏的技能名。 + 按照收藏时间倒序排列(最新的在最前面)。 + """ + self._ensure_tables() + + q = ( + SkillFavorite + .select(SkillFavorite.skill_name, SkillFavorite.favorite_time) + .order_by(SkillFavorite.favorite_time.desc()) # 倒序:最新收藏的在前 + ) + return [ + { + "skill_name": row.skill_name, + "favorite_time": row.favorite_time, + } + for row in q + ] + + def delete(self, *, skill_name: str) -> bool: + """ + 删除收藏(硬删除)。 + 返回 True 表示删除成功,False 表示该技能本来就不存在。 + """ + self._ensure_tables() + + count = SkillFavorite.delete().where(SkillFavorite.skill_name == skill_name).execute() + return count > 0 diff --git a/app/repositories/skill_manual_import.py b/app/repositories/skill_manual_import.py new file mode 100644 index 0000000..05247e0 --- /dev/null +++ b/app/repositories/skill_manual_import.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import datetime +from contextlib import contextmanager +from typing import Iterator + +from peewee import SqliteDatabase, EXCLUDED + +from app.models.base import bind_models +from app.models.skill_manual import SkillManualImport + +class SkillManualImportRepository: + def __init__(self, db: SqliteDatabase): + self.db = db + # 绑定模型到数据库 + bind_models(db, [SkillManualImport]) + + def _ensure_tables(self) -> None: + self.db.create_tables([SkillManualImport]) + + @contextmanager + def atomic(self) -> Iterator[None]: + with self.db.atomic(): + yield + + import datetime + + def upsert(self, *, skill_name: str) -> bool: + """ + 添加或更新收藏。 + skill_name 不存在则插入,已存在则更新 manual_import_time。 + 返回 True 表示新增,False 表示已存在(仅更新了时间)。 + """ + self._ensure_tables() + + now = datetime.datetime.now() + + # 尝试查找已有记录 + existing = ( + SkillManualImport + .select() + .where(SkillManualImport.skill_name == skill_name) + .first() + ) + + if existing: + # 已存在,更新时间 + ( + SkillManualImport + .update({SkillManualImport.manual_import_time: now}) + .where(SkillManualImport.skill_name == skill_name) + .execute() + ) + return False + else: + # 不存在,插入 + SkillManualImport.create( + skill_name=skill_name, + manual_import_time=now, + ) + return True + + def list(self) -> list[dict]: + """ + 获取所有收藏的技能。 + 按照收藏时间倒序排列(最新的在最前面)。 + 返回包含 skill_name 和 manual_import_time 的字典列表。 + """ + self._ensure_tables() + + q = ( + SkillManualImport + .select(SkillManualImport.skill_name, SkillManualImport.manual_import_time) + .order_by(SkillManualImport.manual_import_time.desc()) + ) + return [ + { + "skill_name": row.skill_name, + "manual_import_time": row.manual_import_time, + } + for row in q + ] + + def delete(self, *, skill_name: str) -> bool: + """ + 删除收藏(硬删除)。 + 返回 True 表示删除成功,False 表示该技能本来就不存在。 + """ + self._ensure_tables() + + count = SkillManualImport.delete().where(SkillManualImport.skill_name == skill_name).execute() + return count > 0 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..ea5e636 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# 恢复插件(只替换备份中存在的,不影响用户自装的插件) +mkdir -p /home/user/.openclaw/extensions +for p in /app/.openclaw-extensions-backup/extensions/*; do + name="$(basename "$p")" + rm -rf "/home/user/.openclaw/extensions/$name" + cp -a "$p" /home/user/.openclaw/extensions/ + echo "Restored openclaw extension (overwrite): $name" +done +chmod -R 755 /home/user/.openclaw + +# 恢复 skills +mkdir -p /home/user/.claude/skills +for p in /app/.skills-backup/*; do + name="$(basename "$p")" + cp -a "$p" /home/user/.claude/skills/ 2>/dev/null || true + echo "Restored skill entry (overwrite): $name" +done + +# 启动服务 +openclaw gateway run --port 18789 --bind lan & +uvicorn main:app --host 0.0.0.0 --port 8000