diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..ab5311004 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,143 @@ +name: Bug report / 问题反馈 +description: 报告可复现的错误、崩溃或异常行为 +title: "[Bug] " +labels: + - bug +body: + - type: markdown + attributes: + value: | + 请完整填写下列信息。只有标题、缺少环境或无法复现的反馈会增加处理时间。 + + 请勿粘贴 API Key、Token、Cookie、手机号或其他隐私数据。 + + - type: checkboxes + id: preflight + attributes: + label: 提交前确认 / Before submitting + options: + - label: 我已经搜索过现有 Issue,确认这不是重复问题。 + required: true + - label: 我已经移除日志、截图和配置中的密钥及隐私信息。 + required: true + + - type: input + id: version + attributes: + label: Operit 版本 / Operit version + description: 请填写完整版本和补丁号,不要只写“最新版”。 + placeholder: 例如 1.12.0+4、Nightly 日期或 commit hash + validations: + required: true + + - type: dropdown + id: distribution + attributes: + label: 运行形态 / Distribution + options: + - Release APK + - Nightly APK + - Debug APK + - Clone APK + - Web chat + - Other / 其他 + validations: + required: true + + - type: textarea + id: environment + attributes: + label: 设备与运行环境 / Environment + description: Android 请填写设备型号和 Android 版本;Web chat 请填写操作系统、浏览器及版本。 + placeholder: | + 设备型号: + Android / 操作系统版本: + 浏览器及版本(如适用): + 其他相关环境: + validations: + required: true + + - type: dropdown + id: area + attributes: + label: 问题领域 / Area + options: + - Chat / 对话与消息 + - Model or provider / 模型或服务商 + - Tool calling or workflow / 工具调用或工作流 + - Plugin, Skill or MCP / 插件、Skill 或 MCP + - UI/UX / 界面交互 + - Local model, audio or voice / 本地模型、音频或语音 + - Permission, system or device / 权限、系统或设备 + - Web chat / 网页聊天 + - Other / 其他 + validations: + required: true + + - type: textarea + id: main_problem + attributes: + label: 主要问题描述 / Main problem description + description: 请将实际表现、预期表现、差异和影响合并描述;只用文字即可。 / Combine actual behavior, expected behavior, differences, and impact; text-only is fine. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: 复现步骤 / Steps to reproduce + description: 请按顺序列出从哪个页面或操作开始,到问题出现为止的步骤。 + placeholder: | + 1. 打开…… + 2. 点击…… + 3. 输入或执行…… + 4. 出现…… + validations: + required: true + + - type: dropdown + id: frequency + attributes: + label: 复现频率 / Reproducibility + options: + - Always / 每次发生 + - Often / 经常发生 + - Sometimes / 偶尔发生 + - Once / 只发生过一次 + - Unknown / 不确定 + validations: + required: true + + - type: textarea + id: related_context + attributes: + label: 相关配置 / Related context + description: 模型问题请填写 Provider、模型、接口类型和思考/工具调用设置;工具问题请填写工具名;不相关内容可忽略或说明“不适用”。 / Ignore unrelated details; text-only is fine. + placeholder: | + Provider / 模型: + 接口类型或相关开关: + 工具、工作流、插件或 Skill 名称: + 其他相关配置: + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: 截图或录屏 / Screenshot or recording + description: 请至少上传一张能说明问题的截图,或粘贴录屏链接;也可以忽略上传,直接用文字补充。 / Please add a screenshot or recording link; you may skip the upload and provide text only. + placeholder: 将截图拖到这里,或粘贴图片/录屏链接 + validations: + required: true + + - type: textarea + id: logs + attributes: + label: 日志或错误信息 / Logs or error output + description: 可忽略,或仅粘贴文字、错误文本或说明原因。 / Optional; text-only details are fine. Please redact sensitive data. + placeholder: | + ```text + 在这里粘贴日志或错误信息 + ``` + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3452b8fc1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: 用户指南 / User guide + url: https://aaswordman.github.io/OperitWeb + about: 先查看使用文档和常见操作说明 + - name: QQ 群 / QQ group + url: https://qm.qq.com/q/Sa4fKEH7sO + about: 加入社区交流和使用讨论 + - name: Discord + url: https://discord.gg/YnV9MWurRF + about: 加入 Discord 社区讨论 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..9219e8979 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,111 @@ +name: Feature request / 功能建议 +description: 提议一个具体的新功能或行为改进 +title: "[Feature] " +labels: + - feature-request +body: + - type: markdown + attributes: + value: | + 请描述真实使用场景和可验证的期望结果。只有一句“能不能增加……”的建议很难进入计划。 + + 请勿粘贴 API Key、Token、Cookie、手机号或其他隐私数据。 + + - type: checkboxes + id: preflight + attributes: + label: 提交前确认 / Before submitting + options: + - label: 我已经搜索过现有 Issue,确认这不是重复建议。 + required: true + - label: 我已经移除截图、配置和示例中的密钥及隐私信息。 + required: true + + - type: input + id: version + attributes: + label: 当前版本 / Current version + description: 请填写你当前使用的完整版本;纯功能建议也请提供版本作为背景。 + placeholder: 例如 1.12.0+4、Nightly 日期或 commit hash + validations: + required: true + + - type: textarea + id: environment + attributes: + label: 使用环境 / Environment + description: Android 请填写设备和系统版本;Web chat 请填写操作系统、浏览器及版本。 + placeholder: | + 设备型号: + Android / 操作系统版本: + 浏览器及版本(如适用): + validations: + required: true + + - type: dropdown + id: area + attributes: + label: 功能领域 / Feature area + options: + - Chat / 对话与消息 + - Model or provider / 模型或服务商 + - Tool calling or workflow / 工具调用或工作流 + - Plugin, Skill or MCP / 插件、Skill 或 MCP + - UI/UX / 界面交互 + - Local model, audio or voice / 本地模型、音频或语音 + - Permission, system or device / 权限、系统或设备 + - Web chat / 网页聊天 + - Documentation or contributor experience / 文档或贡献体验 + - Other / 其他 + validations: + required: true + + - type: textarea + id: problem + attributes: + label: 要解决的问题 / Problem or motivation + description: 当前有什么具体痛点?谁会受到影响? + validations: + required: true + + - type: textarea + id: use_case + attributes: + label: 使用场景 / Use case + description: 请给出一个真实、具体的使用场景,而不是只描述抽象愿望。 + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: 期望方案 / Proposed behavior + description: 你希望新增或修改什么?用户应该看到什么结果? + validations: + required: true + + - type: textarea + id: acceptance + attributes: + label: 可接受结果 / Expected outcome + description: 用几条可检查的结果描述“做成以后算解决”。 + placeholder: | + - …… + - …… + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: 截图、草图或参考图 / Screenshot, mockup or reference + description: 请至少上传一张截图、草图或参考图;也可以忽略图片,直接用文字补充。 / Please add a screenshot, mockup or reference; you may skip the image and provide text only. + placeholder: 将图片拖到这里,或粘贴图片/参考链接 + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: 替代方案与补充信息 / Alternatives and context + description: 可忽略,或仅用文字补充当前绕过方式、相关 Issue、实现建议或参考链接。 / Optional; text-only details are fine. diff --git a/.github/ISSUE_TEMPLATE/plugin_tool_mcp.yml b/.github/ISSUE_TEMPLATE/plugin_tool_mcp.yml new file mode 100644 index 000000000..5589e86c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/plugin_tool_mcp.yml @@ -0,0 +1,133 @@ +name: Plugin, Skill or MCP report / 插件、Skill 或 MCP 反馈 +description: 报告外部包、工具、Skill 或 MCP 服务的问题 +title: "[Plugin] " +labels: + - plugin +body: + - type: markdown + attributes: + value: | + 外部包和工具问题必须带上可识别的来源与版本。请勿粘贴 API Key、Token、Cookie 或其他隐私数据。 + + - type: checkboxes + id: preflight + attributes: + label: 提交前确认 / Before submitting + options: + - label: 我已经搜索过现有 Issue,确认这不是重复问题。 + required: true + - label: 我已经移除配置、日志和截图中的密钥及隐私信息。 + required: true + + - type: input + id: version + attributes: + label: Operit 版本 / Operit version + description: 请填写完整版本和补丁号。 + placeholder: 例如 1.12.0+4、Nightly 日期或 commit hash + validations: + required: true + + - type: textarea + id: environment + attributes: + label: 设备与运行环境 / Environment + description: Android 请填写设备型号和系统版本;Web chat 请填写操作系统、浏览器及版本。 + placeholder: | + 设备型号: + Android / 操作系统版本: + 浏览器及版本(如适用): + validations: + required: true + + - type: dropdown + id: package_type + attributes: + label: 类型 / Type + options: + - Plugin / 插件 + - Skill + - MCP server / MCP 服务 + - Workflow package / 工作流包 + - Other / 其他 + validations: + required: true + + - type: input + id: package_identity + attributes: + label: 包或服务名称 / Package or service + description: 请填写市场名称、仓库地址或服务标识。 + placeholder: 名称、GitHub URL 或 MCP endpoint(请移除密钥) + validations: + required: true + + - type: input + id: package_version + attributes: + label: 来源与版本 / Source and version + description: 请填写仓库 commit、发布版本、市场版本或服务端版本。 + placeholder: 例如 v1.2.3、commit hash、市场版本或 server build + validations: + required: true + + - type: textarea + id: transport_config + attributes: + label: 连接与相关配置 / Transport and configuration + description: MCP 请填写 transport 类型、请求方法和必要的非敏感配置;普通插件或 Skill 请说明“不适用”。不相关内容可忽略或仅文字补充。 / Ignore unrelated details; text-only is fine. + placeholder: | + Transport / 连接类型: + 相关开关或非敏感配置: + 请求头或参数(请脱敏): + validations: + required: true + + - type: textarea + id: tool_context + attributes: + label: Provider、模型与工具 / Provider, model and tool + description: 请填写相关 Provider、模型、工具名、工具调用模式或工作流节点;不相关时说明“不适用”。不相关内容可忽略或仅文字补充。 / Ignore unrelated details; text-only is fine. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: 复现步骤 / Steps to reproduce + description: 从安装、配置或调用开始,按顺序列出触发问题的步骤。 + placeholder: | + 1. 安装或配置…… + 2. 调用工具…… + 3. 出现…… + validations: + required: true + + - type: textarea + id: main_problem + attributes: + label: 主要问题描述 / Main problem description + description: 请将实际表现、预期表现、差异和影响合并描述;只用文字即可。 / Combine actual behavior, expected behavior, differences, and impact; text-only is fine. + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: 截图或录屏 / Screenshot or recording + description: 请至少上传一张能说明配置、错误或结果的截图,或粘贴录屏链接;也可以忽略上传,直接用文字补充。 / Please add a screenshot or recording link; you may skip the upload and provide text only. + placeholder: 将截图拖到这里,或粘贴图片/录屏链接 + validations: + required: true + + - type: textarea + id: logs + attributes: + label: 日志、请求或响应 / Logs, request or response + description: 可忽略,或仅粘贴文字、请求/响应或说明原因。 / Optional; text-only details are fine. Please redact sensitive data. + placeholder: | + ```text + 在这里粘贴日志、请求或响应 + ``` + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 000000000..76042d56d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,101 @@ +name: Question / 使用问题 +description: 询问使用方法、配置方式或项目行为 +title: "[Question] " +labels: + - question +body: + - type: markdown + attributes: + value: | + 请先查看用户指南并搜索现有 Issue。请提供完整上下文,避免只写“怎么用”或“有问题”。 + + 请勿粘贴 API Key、Token、Cookie、手机号或其他隐私数据。 + + - type: checkboxes + id: preflight + attributes: + label: 提交前确认 / Before submitting + options: + - label: 我已经搜索过现有 Issue 和用户指南。 + required: true + - label: 我已经移除配置、日志和截图中的密钥及隐私信息。 + required: true + + - type: input + id: version + attributes: + label: Operit 版本 / Operit version + description: 请填写完整版本和补丁号。 + placeholder: 例如 1.12.0+4、Nightly 日期或 commit hash + validations: + required: true + + - type: textarea + id: environment + attributes: + label: 设备与运行环境 / Environment + description: Android 请填写设备型号和系统版本;Web chat 请填写操作系统、浏览器及版本。 + placeholder: | + 设备型号: + Android / 操作系统版本: + 浏览器及版本(如适用): + validations: + required: true + + - type: dropdown + id: area + attributes: + label: 相关领域 / Area + options: + - Chat / 对话与消息 + - Model or provider / 模型或服务商 + - Tool calling or workflow / 工具调用或工作流 + - Plugin, Skill or MCP / 插件、Skill 或 MCP + - UI/UX / 界面交互 + - Local model, audio or voice / 本地模型、音频或语音 + - Permission, system or device / 权限、系统或设备 + - Web chat / 网页聊天 + - Other / 其他 + validations: + required: true + + - type: textarea + id: goal + attributes: + label: 目标与上下文 / Goal and context + description: 你想完成什么?当前处于什么使用场景? + validations: + required: true + + - type: textarea + id: tried + attributes: + label: 已尝试操作与结果 / What you tried + description: 请列出已经尝试的操作,以及每一步得到的结果。 + validations: + required: true + + - type: textarea + id: related_context + attributes: + label: 相关配置 / Related context + description: 请填写 Provider、模型、工具、插件或相关开关;不相关内容可忽略或说明“不适用”。 / Ignore unrelated details; text-only is fine. + validations: + required: true + + - type: textarea + id: question + attributes: + label: 具体问题 / Question + description: 请用一个或几个明确问题说明你需要维护者回答什么。 + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: 截图或参考图 / Screenshot or reference + description: 请至少上传一张能说明当前状态的截图,或粘贴相关参考图链接;也可以忽略图片,直接用文字补充。 / Please add a screenshot or reference link; you may skip the image and provide text only. + placeholder: 将截图拖到这里,或粘贴图片链接 + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..e0513d09b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,56 @@ +## 变更说明 / Description + + + +### 背景与动机 / Context and motivation + + + +### 改动范围 / Changes + + + +- + +### 兼容性与风险 / Compatibility and risks + + + +## 关联 Issue / Related issue + + + +## 验证方式 / Verification + + + +```text +检查或命令: +环境与变体: +结果: +``` + +## 证据 / Evidence + + + +## 检查清单 / Checklist + +- [ ] 我已确认没有引入 API Key、Token 或其他敏感信息 / No secrets or private data are included +- [ ] 我已运行与本次改动相关的本地检查 / Relevant local checks have been run +- [ ] 我已更新必要的文档、字符串或测试 / Required docs, strings, or tests are updated +- [ ] 我已确认本 PR 不包含无关改动 / This PR does not include unrelated changes diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 000000000..1d9010f33 --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,307 @@ +name: Android Build + +on: + workflow_dispatch: + inputs: + google_drive_folder: + description: Public Google Drive folder URL containing models.zip, subpack.zip, jniLibs.zip and libs.zip + required: false + default: https://drive.google.com/drive/folders/1g-Q_i7cf6Ua4KX9ZM6V282EEZvTVVfF7 + gradle_task: + description: Gradle task to run + required: true + default: assembleDebug + run_unit_tests: + description: Run JVM unit tests after the build + required: false + default: false + type: boolean + run_android_lint: + description: Run Android lint after the build + required: false + default: false + type: boolean + workflow_call: + inputs: + google_drive_folder: + description: Public Google Drive folder URL containing models.zip, subpack.zip, jniLibs.zip and libs.zip + required: false + type: string + default: https://drive.google.com/drive/folders/1g-Q_i7cf6Ua4KX9ZM6V282EEZvTVVfF7 + gradle_task: + description: Gradle task to run + required: false + type: string + default: assembleDebug + run_unit_tests: + description: Run JVM unit tests after the build + required: false + type: boolean + default: false + run_android_lint: + description: Run Android lint after the build + required: false + type: boolean + default: false + checkout_ref: + description: Commit to build + required: false + type: string + default: "" + secrets: + OPERIT_GITHUB_CLIENT_ID: + required: false + OPERIT_GITHUB_CLIENT_SECRET: + required: false + push: + branches: + - main + paths: + - .github/workflows/android-build.yml + - app/** + - dragonbones/** + - fbx/** + - llama/** + - mmd/** + - mnn/** + - quickjs/** + - showerclient/** + - terminal/** + - tools/native_ripgrep/** + - web-chat/** + - examples/** + - gradle/** + - build.gradle.kts + - settings.gradle.kts + - gradle.properties + - gradlew + - package.json + - pnpm-workspace.yaml + - packages_whitelist.txt + - sync_example_packages.py +concurrency: + group: android-build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + JAVA_VERSION: '21' + NODE_VERSION: '22' + MANUAL_DEPS_DIR: manual-deps + DEFAULT_GOOGLE_DRIVE_FOLDER: https://drive.google.com/drive/folders/1g-Q_i7cf6Ua4KX9ZM6V282EEZvTVVfF7 + +jobs: + build: + name: Build Android artifacts + runs-on: ubuntu-24.04 + timeout-minutes: 240 + + steps: + - name: Checkout clean source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.checkout_ref || github.sha }} + fetch-depth: 1 + submodules: false + + - name: Initialize required public submodules + shell: bash + run: | + set -euo pipefail + git submodule sync --recursive + required_submodules=( + terminal + mnn/src/main/cpp/MNN + llama/third_party/llama.cpp + mmd/third_party/saba + mmd/third_party/bullet3 + fbx/third_party/ufbx + quickjs/thirdparty/quickjs + app/src/main/cpp/thirdparty/ncnn + app/src/main/cpp/thirdparty/sherpa-ncnn + ) + git submodule update --init --recursive --depth 1 "${required_submodules[@]}" + git submodule status "${required_submodules[@]}" + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + cache: gradle + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android SDK, NDK and CMake + shell: bash + run: | + set -euo pipefail + yes | sdkmanager --licenses >/dev/null || true + sdkmanager --install \ + "platform-tools" \ + "platforms;android-34" \ + "platforms;android-36" \ + "build-tools;34.0.0" \ + "build-tools;35.0.0" \ + "ndk;25.1.8937393" \ + "cmake;3.22.1" + yes | sdkmanager --licenses >/dev/null || true + + - name: Configure local.properties + shell: bash + env: + GITHUB_CLIENT_ID: ${{ secrets.OPERIT_GITHUB_CLIENT_ID }} + GITHUB_CLIENT_SECRET: ${{ secrets.OPERIT_GITHUB_CLIENT_SECRET }} + run: | + set -euo pipefail + { + echo "GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}" + echo "GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}" + } > local.properties + + - name: Cache manual binary dependencies + id: cache-manual-deps + uses: actions/cache@v4 + with: + path: ${{ env.MANUAL_DEPS_DIR }} + key: manual-deps-v3-${{ runner.os }} + + - name: Download manual binary dependencies + if: steps.cache-manual-deps.outputs.cache-hit != 'true' + shell: bash + env: + FOLDER_URL: ${{ inputs.google_drive_folder || env.DEFAULT_GOOGLE_DRIVE_FOLDER }} + run: | + set -euo pipefail + python3 -m pip install --user --break-system-packages gdown + rm -rf "$MANUAL_DEPS_DIR" + mkdir -p "$MANUAL_DEPS_DIR" + python3 -m gdown --folder "$FOLDER_URL" --output "$MANUAL_DEPS_DIR" + + - name: Install manual binary dependencies + shell: bash + run: | + set -euo pipefail + + restore_zip() { + local name="$1" + if [ -f "$MANUAL_DEPS_DIR/$name" ]; then + return + fi + + local found + found="$(find "$MANUAL_DEPS_DIR" -type f -name "$name" -print -quit)" + if [ -n "$found" ]; then + cp "$found" "$MANUAL_DEPS_DIR/$name" + return + fi + + mapfile -t parts < <(find "$MANUAL_DEPS_DIR" -type f -name "$name.*" | sort) + if [ "${#parts[@]}" -gt 0 ]; then + cat "${parts[@]}" > "$MANUAL_DEPS_DIR/$name" + return + fi + + echo "Missing $name in $MANUAL_DEPS_DIR" >&2 + find "$MANUAL_DEPS_DIR" -maxdepth 3 -type f -print >&2 || true + exit 1 + } + + restore_zip models.zip + restore_zip subpack.zip + restore_zip jniLibs.zip + restore_zip libs.zip + + unzip -qo "$MANUAL_DEPS_DIR/models.zip" -d . + unzip -qo "$MANUAL_DEPS_DIR/subpack.zip" -d . + unzip -qo "$MANUAL_DEPS_DIR/jniLibs.zip" -d . + unzip -qo "$MANUAL_DEPS_DIR/libs.zip" -d . + + test -d app/src/main/assets/models + test -d app/src/main/assets/subpack + test -d app/src/main/jniLibs + find app/libs -type f \( -name '*.aar' -o -name '*.jar' \) | grep -q . + + - name: Cache native ripgrep build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + tools/native_ripgrep/target + key: native-ripgrep-arm64-${{ runner.os }}-${{ hashFiles('tools/native_ripgrep/Cargo.lock') }} + + - name: Build native ripgrep + shell: bash + run: | + set -euo pipefail + export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/25.1.8937393" + export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang" + rustup toolchain install stable --profile minimal + rustup default stable + rustup target add aarch64-linux-android + cargo build \ + --manifest-path tools/native_ripgrep/Cargo.toml \ + --release \ + --target aarch64-linux-android \ + --locked + install -Dm755 \ + tools/native_ripgrep/target/aarch64-linux-android/release/liboperit_ripgrep.so \ + app/src/main/jniLibs/arm64-v8a/liboperit_ripgrep.so + file app/src/main/jniLibs/arm64-v8a/liboperit_ripgrep.so + test -s app/src/main/jniLibs/arm64-v8a/liboperit_ripgrep.so + + - name: Install JavaScript toolchain dependencies + shell: bash + run: | + set -euo pipefail + npm install -g pnpm@10 + npm install --no-audit --no-fund + npm --prefix web-chat install --no-audit --no-fund + node --version + npm --version + pnpm --version + + - name: Build web-chat assets + run: npm run build:webchat + + - name: Build bundled tool packages + run: python3 ./sync_example_packages.py + + - name: Run Gradle build + shell: bash + env: + GRADLE_TASK: ${{ inputs.gradle_task || 'assembleDebug' }} + run: | + set -euo pipefail + chmod +x ./gradlew + ./gradlew "$GRADLE_TASK" --stacktrace --build-cache --no-daemon + + - name: Run JVM unit tests + if: inputs.run_unit_tests + run: ./gradlew :app:testDebugUnitTest --stacktrace --build-cache --no-daemon + + - name: Run Android lint + if: inputs.run_android_lint + run: ./gradlew :app:lintDebug --stacktrace --build-cache --no-daemon + + - name: Upload Android build outputs + if: always() + uses: actions/upload-artifact@v4 + with: + name: operit-android-${{ github.run_number }} + path: | + app/build/outputs/apk/**/*.apk + app/build/outputs/bundle/**/*.aab + app/build/reports/** + build/reports/** + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/close-title-only-issues.yml b/.github/workflows/close-title-only-issues.yml new file mode 100644 index 000000000..3775f2a3e --- /dev/null +++ b/.github/workflows/close-title-only-issues.yml @@ -0,0 +1,57 @@ +name: Close title-only issues + +on: + issues: + types: [opened] + workflow_dispatch: + +permissions: + issues: write + +jobs: + close-title-only: + runs-on: ubuntu-latest + steps: + - name: Close issues without a body or comments + uses: actions/github-script@v7 + with: + script: | + const issues = context.eventName === "workflow_dispatch" + ? await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100, + }) + : [context.payload.issue]; + + for (const issue of issues) { + const bodyIsEmpty = issue.body === null || issue.body.trim() === ""; + const hasNoComments = issue.comments === 0; + + if (issue.pull_request || !bodyIsEmpty || !hasNoComments) { + continue; + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ["invalid"], + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: "此 Issue 只有标题,没有正文或复现信息,因此自动关闭。请使用合适的 Issue 模板重新提交完整信息。\n\nThis issue was closed automatically because it contains only a title. Please reopen the report with the appropriate template and complete details.", + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }); + } diff --git a/.github/workflows/pr-required.yml b/.github/workflows/pr-required.yml new file mode 100644 index 000000000..a2ac64d62 --- /dev/null +++ b/.github/workflows/pr-required.yml @@ -0,0 +1,364 @@ +name: PR Required + +on: + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: pr-required-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + name: Detect changed areas + runs-on: ubuntu-24.04 + outputs: + base_sha: ${{ steps.detect.outputs.base_sha }} + head_sha: ${{ steps.detect.outputs.head_sha }} + android: ${{ steps.detect.outputs.android }} + localization: ${{ steps.detect.outputs.localization }} + web: ${{ steps.detect.outputs.web }} + docs: ${{ steps.detect.outputs.docs }} + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + - name: Detect changed areas + id: detect + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BEFORE_SHA: ${{ github.event.before }} + CURRENT_SHA: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + + case "$EVENT_NAME" in + pull_request) + resolved_base_sha="$PR_BASE_SHA" + resolved_head_sha="$PR_HEAD_SHA" + ;; + workflow_dispatch) + resolved_base_sha="" + resolved_head_sha="$CURRENT_SHA" + ;; + *) + resolved_base_sha="$BEFORE_SHA" + resolved_head_sha="$CURRENT_SHA" + ;; + esac + + if [[ -z "$resolved_base_sha" || "$resolved_base_sha" =~ ^0+$ ]]; then + changed_files="$(git ls-files)" + else + changed_files="$(git diff --name-only "$resolved_base_sha" "$resolved_head_sha")" + fi + + android=false + localization=false + web=false + docs=false + + while IFS= read -r path; do + [[ -z "$path" ]] && continue + case "$path" in + app/*|dragonbones/*|fbx/*|llama/*|mmd/*|mnn/*|quickjs/*|showerclient/*|terminal/*|tools/native_ripgrep/*|gradle/*|build.gradle.kts|settings.gradle.kts|gradle.properties|gradlew|package.json|pnpm-workspace.yaml|packages_whitelist.txt|sync_example_packages.py|.github/workflows/android-build.yml) + android=true + ;; + esac + case "$path" in + web-chat/*) + web=true + android=true + ;; + esac + case "$path" in + app/src/main/res/values*/strings.xml|tools/string/*) + localization=true + ;; + esac + if [[ "$path" == *.md || "$path" == *.mdx || "$path" == docs/* ]]; then + docs=true + fi + done <<< "$changed_files" + + { + echo "base_sha=$resolved_base_sha" + echo "head_sha=$resolved_head_sha" + echo "android=$android" + echo "localization=$localization" + echo "web=$web" + echo "docs=$docs" + } >> "$GITHUB_OUTPUT" + + printf 'Changed areas: android=%s localization=%s web=%s docs=%s\n' \ + "$android" "$localization" "$web" "$docs" + + repo-hygiene: + name: Repository hygiene + needs: changes + runs-on: ubuntu-24.04 + env: + BASE_SHA: ${{ needs.changes.outputs.base_sha }} + HEAD_SHA: ${{ needs.changes.outputs.head_sha }} + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ needs.changes.outputs.head_sha }} + fetch-depth: 0 + + - name: Check whitespace errors + shell: bash + run: | + set -euo pipefail + if [[ -n "$BASE_SHA" ]]; then + git diff --check "$BASE_SHA" "$HEAD_SHA" + else + git diff --check "$HEAD_SHA^" "$HEAD_SHA" + fi + + - name: Check changed files + run: python3 ci/script/check_repo_hygiene.py --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" + + - name: Check local Markdown links + run: python3 ci/script/check_markdown_links.py --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" + + - name: Parse changed YAML files + shell: bash + run: | + set -euo pipefail + if [[ -n "$BASE_SHA" ]]; then + mapfile -t yaml_files < <(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" | rg '\.ya?ml$' || true) + else + mapfile -t yaml_files < <(git ls-files | rg '\.ya?ml$' || true) + fi + if (( ${#yaml_files[@]} > 0 )); then + ruby -ryaml -e 'ARGV.each { |path| YAML.safe_load_file(path, aliases: true); puts "YAML OK: #{path}" }' -- "${yaml_files[@]}" + fi + + - name: Check GitHub Actions syntax + run: docker run --rm -v "$GITHUB_WORKSPACE:/repo" -w /repo rhysd/actionlint:1.7.7 + + localization: + name: Localization + needs: changes + if: needs.changes.outputs.localization == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ needs.changes.outputs.head_sha }} + fetch-depth: 0 + + - name: Check Android string resources + run: python3 ci/script/check_localizations.py --base-sha "${{ needs.changes.outputs.base_sha }}" --head-sha "${{ needs.changes.outputs.head_sha }}" + + web-checks: + name: WebChat checks + needs: changes + if: needs.changes.outputs.web == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ needs.changes.outputs.head_sha }} + fetch-depth: 1 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install WebChat dependencies + run: npm --prefix web-chat install --no-audit --no-fund + + - name: Typecheck WebChat + run: npm --prefix web-chat run typecheck + + - name: Build WebChat + run: npm --prefix web-chat run build + + android-build: + name: Android Build + needs: changes + if: needs.changes.outputs.android == 'true' + uses: ./.github/workflows/android-build.yml + with: + checkout_ref: ${{ needs.changes.outputs.head_sha }} + gradle_task: assembleDebug + run_unit_tests: true + run_android_lint: true + secrets: inherit + + pr-policy: + name: PR policy + needs: changes + if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + runs-on: ubuntu-24.04 + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + steps: + - name: Check PR title and body + run: | + python3 - <<'PY' + import os + import re + import sys + + title = os.environ["PR_TITLE"].strip() + body = os.environ.get("PR_BODY", "") + errors = [] + + title_pattern = re.compile( + r"^(feat|fix|docs|chore|refactor|test|perf|build|ci)" + r"(?:\([a-z0-9._/-]+\))?!?:\s+\S+" + ) + if not title_pattern.match(title): + errors.append("PR title must use Conventional Commit style, for example `fix(chat): preserve scroll position`.") + + required_sections = ( + "## 变更说明 / Description", + "## 关联 Issue / Related issue", + "## 验证方式 / Verification", + "## 检查清单 / Checklist", + ) + for section in required_sections: + if section not in body: + errors.append(f"PR body is missing section: {section}") + + if not re.search(r"(?m)^- \[[xX]\]", body): + errors.append("At least one checklist item must be checked.") + + kind = title.split(":", 1)[0].split("(", 1)[0] + if kind in {"feat", "fix", "perf"} and not re.search( + r"(?i)(?:closes|fixes|resolves)\s+#\d+|#\d+", body + ): + errors.append("Feature, bug-fix and performance PRs must reference an Issue.") + + if errors: + print("PR policy check failed:") + for error in errors: + print(f"- {error}") + sys.exit(1) + + print("PR policy check passed.") + PY + + documentation-advisory: + name: Documentation advisory + needs: changes + if: needs.changes.outputs.docs == 'true' + continue-on-error: true + runs-on: ubuntu-24.04 + env: + BASE_SHA: ${{ needs.changes.outputs.base_sha }} + HEAD_SHA: ${{ needs.changes.outputs.head_sha }} + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ needs.changes.outputs.head_sha }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Collect changed documentation files + id: files + shell: bash + run: | + set -euo pipefail + if [[ -n "$BASE_SHA" ]]; then + source_files="$(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA")" + else + source_files="$(git ls-files)" + fi + : > changed-docs.txt + while IFS= read -r path; do + case "$path" in + *.md|*.mdx) + case "$path" in + llama/third_party/*|mmd/third_party/*|mnn/src/main/cpp/MNN/*) + ;; + *) printf '%s\n' "$path" >> changed-docs.txt ;; + esac + ;; + esac + done <<< "$source_files" + sort -u -o changed-docs.txt changed-docs.txt + cat changed-docs.txt + + - name: Codespell advisory + continue-on-error: true + run: | + set -euo pipefail + if [[ -s changed-docs.txt ]]; then + python3 -m pip install --disable-pip-version-check codespell==2.4.1 + xargs -r codespell \ + --ignore-words-list=operit,operitai,quickjs,deepseek,mnn,mcp,skill,webchat \ + < changed-docs.txt + fi + + - name: Set up Node.js for Markdownlint + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Run Markdownlint + continue-on-error: true + run: | + set -euo pipefail + if [[ -s changed-docs.txt ]]; then + mapfile -t files < changed-docs.txt + npx --yes markdownlint-cli2@0.18.1 "${files[@]}" + fi + + required: + name: CI required + if: always() + needs: + - changes + - repo-hygiene + - localization + - web-checks + - android-build + - pr-policy + runs-on: ubuntu-24.04 + steps: + - name: Require successful checks + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + python3 - <<'PY' + import json + import os + import sys + + needs = json.loads(os.environ["NEEDS_JSON"]) + failed = { + name: value["result"] + for name, value in needs.items() + if value["result"] not in {"success", "skipped"} + } + if failed: + print("Required CI checks failed:") + for name, result in failed.items(): + print(f"- {name}: {result}") + sys.exit(1) + print("All required CI checks passed or were intentionally skipped.") + PY diff --git a/ci/README.md b/ci/README.md index 36889a8de..aae3b3acc 100644 --- a/ci/README.md +++ b/ci/README.md @@ -1,3 +1,27 @@ -script/ -存放测试,编译,构建的一键脚本,避免依赖githubAction -统一使用pixi管理依赖 \ No newline at end of file +# CI checks + +`script/` 存放可在本地复现的检查脚本,GitHub Actions 只负责编排检查和构建。 + +## Local checks + +在仓库根目录运行: + +```bash +python3 ci/script/check_repo_hygiene.py +python3 ci/script/check_markdown_links.py +python3 ci/script/check_localizations.py +npm --prefix web-chat install --no-audit --no-fund +npm --prefix web-chat run typecheck +``` + +CI 在 PR 中会使用目标分支和 PR head 的 commit 对比,只检查本次改动相关的文件;不传 commit 参数时,脚本检查当前工作树。 +字符串资源工具的用途和写入行为见 [tools/string README](../tools/string/README.md)。 + +## CI layers + +- `CI required`:PR 的聚合门禁 +- `Repository hygiene`:差异、语法、冲突标记和本地 Markdown 链接 +- `Localization`:Android 字符串资源结构、key、占位符和语言提示 +- `WebChat checks`:TypeScript 类型检查和 WebChat 构建 +- `Android Build`:涉及 Android 或构建输入时执行完整构建 +- `Documentation advisory`:Markdown 风格和拼写提示,不阻断合并 diff --git a/ci/script/check_localizations.py b/ci/script/check_localizations.py new file mode 100644 index 000000000..c5df6b003 --- /dev/null +++ b/ci/script/check_localizations.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Check Android string resources and report localization quality signals.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import xml.etree.ElementTree as ET +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + + +PRINTF_RE = re.compile(r"%(?:\d+\$)?[-+# 0,(<]*\d*(?:\.\d+)?[a-zA-Z]") +BRACE_RE = re.compile(r"\{[A-Za-z0-9_]+\}") +HAN_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]") + + +@dataclass(frozen=True) +class ResourceEntry: + name: str + tag: str + text: str + + +def resource_files() -> dict[str, Path]: + root = Path("app/src/main/res") + files: dict[str, Path] = {} + default_file = root / "values" / "strings.xml" + if default_file.is_file(): + files["zh"] = default_file + + for directory in sorted(root.glob("values-*/")): + candidate = directory / "strings.xml" + if candidate.is_file(): + files[directory.name.removeprefix("values-")] = candidate + return files + + +def parse_file(path: Path) -> tuple[dict[str, ResourceEntry], list[str], str | None]: + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError) as error: + return {}, [], f"{path}: invalid XML: {error}" + + entries: dict[str, ResourceEntry] = {} + duplicates: list[str] = [] + for element in root: + name = element.get("name") + if not name: + continue + if name in entries: + duplicates.append(name) + entries[name] = ResourceEntry( + name=name, + tag=element.tag, + text="".join(element.itertext()), + ) + return entries, duplicates, None + + +def placeholders(text: str) -> Counter[str]: + return Counter(PRINTF_RE.findall(text) + BRACE_RE.findall(text)) + + +def changed_keys(base_sha: str | None, head_sha: str) -> set[str] | None: + if not base_sha: + return None + result = subprocess.run( + [ + "git", + "diff", + "--unified=0", + base_sha, + head_sha, + "--", + "app/src/main/res", + ], + check=True, + capture_output=True, + text=True, + ) + keys: set[str] = set() + for line in result.stdout.splitlines(): + if not line.startswith("+") or line.startswith("+++"): + continue + match = re.search(r'<(?:string|plurals|string-array|integer-array)\b[^>]*\bname="([^"]+)"', line) + if match: + keys.add(match.group(1)) + return keys + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-sha") + parser.add_argument("--head-sha", default="HEAD") + args = parser.parse_args() + + files = resource_files() + if "zh" not in files: + print("Missing app/src/main/res/values/strings.xml") + return 1 + + errors: list[str] = [] + warnings: list[str] = [] + data: dict[str, dict[str, ResourceEntry]] = {} + duplicate_count = 0 + + for language, path in files.items(): + entries, duplicates, parse_error = parse_file(path) + if parse_error: + errors.append(parse_error) + continue + data[language] = entries + duplicate_count += len(duplicates) + for name in duplicates: + errors.append(f"{path}: duplicate resource name: {name}") + + base = data.get("zh", {}) + base_names = set(base) + changed = changed_keys(args.base_sha, args.head_sha) + missing_total = 0 + untranslated_total = 0 + script_hint_total = 0 + placeholder_warning_total = 0 + + for language, entries in sorted(data.items()): + if language == "zh": + continue + + missing = base_names - set(entries) + extra = set(entries) - base_names + missing_total += len(missing) + + if missing: + warnings.append(f"{language}: {len(missing)} resource(s) missing from translation") + for name in sorted(extra): + errors.append(f"{files[language]}: resource not present in zh baseline: {name}") + + for name in sorted(base_names & set(entries)): + source = base[name] + target = entries[name] + if source.tag != target.tag: + errors.append( + f"{files[language]}:{name}: resource type differs from zh ({source.tag} vs {target.tag})" + ) + + if source.text.strip() and source.text == target.text: + untranslated_total += 1 + + target_has_han = bool(HAN_RE.search(target.text)) + if target_has_han and language != "zh": + script_hint_total += 1 + + if placeholders(source.text) != placeholders(target.text): + message = f"{files[language]}:{name}: placeholder signature differs from zh" + if changed is not None and name in changed: + errors.append(message) + else: + placeholder_warning_total += 1 + + if untranslated_total: + warnings.append(f"{untranslated_total} translated value(s) are identical to zh baseline") + if script_hint_total: + warnings.append(f"{script_hint_total} non-zh value(s) contain Han characters; review language selection") + if placeholder_warning_total: + warnings.append( + f"{placeholder_warning_total} existing placeholder mismatch(es) are outside this change and remain warnings" + ) + + print( + "Localization summary: " + f"languages={len(data)}, duplicates={duplicate_count}, missing={missing_total}, " + f"untranslated={untranslated_total}" + ) + for warning in warnings: + print(f"warning: {warning}") + + if errors: + print("Localization check failed:") + for error in errors: + print(f"- {error}") + return 1 + + print("Localization structural check passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/script/check_markdown_links.py b/ci/script/check_markdown_links.py new file mode 100644 index 000000000..b43e463bf --- /dev/null +++ b/ci/script/check_markdown_links.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Check relative Markdown links changed by a commit without network access.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import urllib.parse +from pathlib import Path + + +LINK_RE = re.compile(r"!?(?:\[[^\]]*\])\(\s*(?:<([^>]+)>|([^\s)]+))") +SKIP_PREFIXES = ( + "llama/third_party/", + "mmd/third_party/", + "mnn/src/main/cpp/MNN/", +) + + +def markdown_files(base_sha: str | None, head_sha: str) -> list[Path]: + if base_sha: + command = [ + "git", + "diff", + "--name-only", + "--diff-filter=ACMR", + base_sha, + head_sha, + "--", + "*.md", + "*.mdx", + ] + else: + command = ["git", "ls-files", "*.md", "*.mdx"] + + result = subprocess.run(command, check=True, capture_output=True, text=True) + return [Path(line) for line in result.stdout.splitlines() if line] + + +def should_skip_target(target: str) -> bool: + parsed = urllib.parse.urlsplit(target) + return ( + not target + or target.startswith("#") + or parsed.scheme in {"http", "https", "mailto", "tel"} + or target.startswith("//") + ) + + +def check_file(path: Path, repo_root: Path, errors: list[str]) -> None: + if any(str(path).startswith(prefix) for prefix in SKIP_PREFIXES): + return + if not path.is_file(): + return + + in_fence = False + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + stripped = line.lstrip() + if stripped.startswith("```") or stripped.startswith("~~~"): + in_fence = not in_fence + continue + if in_fence: + continue + + for match in LINK_RE.finditer(line): + target = match.group(1) or match.group(2) or "" + if should_skip_target(target): + continue + + parsed = urllib.parse.urlsplit(target) + link_path = urllib.parse.unquote(parsed.path) + if not link_path: + continue + + if link_path.startswith("/"): + resolved = repo_root / link_path.lstrip("/") + else: + resolved = path.parent / link_path + + if not resolved.exists(): + errors.append(f"{path}:{line_number}: missing local link target: {target}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-sha") + parser.add_argument("--head-sha", default="HEAD") + args = parser.parse_args() + + repo_root = Path.cwd() + errors: list[str] = [] + files = markdown_files(args.base_sha, args.head_sha) + for path in files: + check_file(path, repo_root, errors) + + if errors: + print("Markdown link check failed:") + for error in errors: + print(f"- {error}") + return 1 + + print(f"Markdown link check passed for {len(files)} changed file(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/script/check_repo_hygiene.py b/ci/script/check_repo_hygiene.py new file mode 100644 index 000000000..74ecb415f --- /dev/null +++ b/ci/script/check_repo_hygiene.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Check changed repository files for deterministic syntax and merge errors.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def changed_files(base_sha: str | None, head_sha: str) -> list[Path]: + if base_sha: + command = [ + "git", + "diff", + "--name-only", + "--diff-filter=ACMR", + base_sha, + head_sha, + ] + else: + command = ["git", "ls-files"] + + result = subprocess.run(command, check=True, capture_output=True, text=True) + return [Path(line) for line in result.stdout.splitlines() if line] + + +def check_text_file(path: Path, errors: list[str]) -> None: + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + return + + for line_number, line in enumerate(text.splitlines(), start=1): + if line.startswith(("<<<<<<< ", ">>>>>>> ")): + errors.append(f"{path}:{line_number}: merge conflict marker") + + +def check_json_file(path: Path, errors: list[str]) -> None: + try: + with path.open(encoding="utf-8") as stream: + json.load(stream) + except (OSError, json.JSONDecodeError) as error: + errors.append(f"{path}: invalid JSON: {error}") + + +def check_xml_file(path: Path, errors: list[str]) -> None: + try: + ET.parse(path) + except (OSError, ET.ParseError) as error: + errors.append(f"{path}: invalid XML: {error}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-sha") + parser.add_argument("--head-sha", default="HEAD") + args = parser.parse_args() + + repo_root = Path.cwd() + errors: list[str] = [] + files = changed_files(args.base_sha, args.head_sha) + + for relative_path in files: + path = repo_root / relative_path + if not path.is_file(): + continue + + check_text_file(relative_path, errors) + + if relative_path.suffix.lower() == ".json": + check_json_file(path, errors) + elif relative_path.suffix.lower() == ".xml": + check_xml_file(path, errors) + + if errors: + print("Repository hygiene check failed:") + for error in errors: + print(f"- {error}") + return 1 + + print(f"Repository hygiene check passed for {len(files)} changed file(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/doc-src/dev-core/CONTRIBUTING.md b/docs/doc-src/dev-core/CONTRIBUTING.md index bae87f98b..2505edb82 100644 --- a/docs/doc-src/dev-core/CONTRIBUTING.md +++ b/docs/doc-src/dev-core/CONTRIBUTING.md @@ -1,96 +1,138 @@ -# 👨‍💻 开源共创指南 +# Operit 贡献指南 -欢迎加入 Operit 开源生态!我们欢迎不同类型的贡献者。 +感谢你为 Operit 提交 Issue、文档、脚本、插件或代码。本文说明当前仓库的协作流程;构建细节请参考 [Android 编译指南](./BUILDING.md),脚本和 ToolPkg 开发请参考 [脚本开发指南](../../SCRIPT_DEV_GUIDE.md)。 -## 脚本与插件开发者 +## 贡献类型 -### 📜 脚本开发 +- Android 应用、工具调用、工作流、数据和 UI:主要位于 `app/` +- WebChat:位于 `web-chat/`,构建结果会同步到 Android assets +- 脚本、Skill、Plugin、MCP 和示例包:位于 `examples/`,格式说明见 [ToolPkg 指南](../../TOOLPKG_FORMAT_GUIDE.md) +- 文档和协作资料:位于 `docs/` +- 构建、检查和仓库自动化:位于 `.github/`、`ci/` 和 `tools/` -Operit 支持通过 TypeScript/JavaScript 脚本来扩展 AI 的能力。完整指南请参考 [脚本开发指南 (SCRIPT_DEV_GUIDE.md)](../../SCRIPT_DEV_GUIDE.md)。 +## 提交 Issue -### 🔌 MCP 插件开发 +请先在 [Issue 区](https://github.com/AAswordman/Operit/issues) 搜索相同问题,再选择对应的 Issue Form: -你可以开发自己的 MCP 插件来扩展 AI 的能力,如网页浏览、图像处理等。在 Operit AI 中导入你的插件仓库或 zip 文件即可开始。 +- Bug report:错误、崩溃和异常行为 +- Feature request:功能和行为建议 +- Plugin, Skill or MCP report:外部包、工具和 MCP 服务问题 +- Question:使用、配置和行为咨询 -## Operit 本体开发者 +提交时请填写完整版本号、运行环境、主要问题、复现步骤和相关配置。截图或日志字段用于补充证据,文字说明也可以。请勿提交 API Key、Token、Cookie、个人信息或其他敏感内容。 -参与 Operit AI 本体开发,请遵循以下精简指南。 +只有标题、没有正文和评论的 Issue 会被自动关闭。功能、Bug 和工具问题应尽量关联已有讨论,避免重复跟进。 -### 🛠️ 环境搭建 +## 开发前准备 -在开始开发之前,请参考 [完整编译指南 (BUILDING.md)](./BUILDING.md) 搭建 Android 开发环境。 +项目是 Android 主应用,同时包含 native 子模块、WebChat 和脚本构建步骤。完整环境要求以 [Android 编译指南](./BUILDING.md) 为准,当前 CI 使用的主要版本包括: -### 🚀 开发前必读 +- JDK 21 +- Node.js 22、npm 和 pnpm +- Python 3 +- Android SDK 34/36、Build Tools 34/35 +- NDK 25.1.8937393 和 CMake 3.22.1 -1. **先沟通**: 在 [Issue 区](https://github.com/AAswordman/Operit/issues) 提出你的想法或认领任务,**避免重复造轮子**。 -2. **研究代码**: 动手前,请**深入阅读**相关模块的现有代码,理解项目的设计模式和架构。 -3. **保持兼容**: 新功能必须**向前兼容**,不能破坏现有用户体验或数据结构。 -4. **遵循结构**: 将新文件放置在项目结构中合适的目录,保持代码库整洁。 +Fork 并克隆仓库后,建议保留 `upstream` 远程: -### 🎨 代码风格 +```bash +git clone --recurse-submodules https://github.com//Operit.git +cd Operit +git remote add upstream https://github.com/AAswordman/Operit.git +git fetch upstream +git switch -c fix/short-description upstream/main +``` -我们的代码风格...比较随性。欢迎你来帮忙统一! +不要提交 `local.properties`、本地密钥、手动下载的模型和二进制依赖。修改 WebChat 或示例包时,先按照编译指南准备根目录和 `web-chat` 的依赖。 -- commit 信息非常"创意丰富" -- 注释语言混搭风,中英文随心切换 -- 代码风格多元化 +## 开发原则 -### 🔄 提交流程 +- 先阅读相关模块和现有测试,再开始修改 +- 保持 PR 聚焦,不把无关格式化、重命名和功能改动混在一起 +- 变更持久化数据、配置格式、工具参数或公开行为时,说明兼容性和迁移影响 +- 新增面向用户的文字时优先使用资源字符串或项目已有的多语言机制 +- UI、行为或文档发生变化时,在 PR 中提供验证结果和必要的截图、日志或对比信息 +- 不修改第三方子模块内容来解决主仓库问题;需要变更时单独说明来源和同步方式 -为了顺利合入你的代码,请严格遵循以下流程: +## 本地检查 -1. **准备工作**: - - Fork 本仓库并 Clone 到本地。 - - 添加上游仓库: `git remote add upstream https://github.com/AAswordman/Operit.git` +在仓库根目录可以复现主要的快速检查: -2. **开始开发**: - - 同步最新的 `main` 和 `pr-branch` 分支。 - ```bash - git fetch upstream - git checkout main - git merge upstream/main - git checkout pr-branch - git merge main - ``` - - 从 `pr-branch` 创建你的功能分支。 - ```bash - git checkout -b feature/your-feature-name - ``` +```bash +python3 ci/script/check_repo_hygiene.py +python3 ci/script/check_markdown_links.py +python3 ci/script/check_localizations.py +npm --prefix web-chat run typecheck +``` -3. **提交代码**: - - 完成开发后,**同步 `main` 分支的最新代码**。推荐使用 `rebase` 以保持历史记录清晰。 - ```bash - git fetch upstream - git rebase upstream/main # 或者 git merge upstream/main - ``` - - 解决所有冲突后,推送到你的远程分支。 - ```bash - # 如果 rebase 过,需要使用 --force - git push origin feature/your-feature-name --force - ``` +根据改动范围选择额外检查: -4. **创建 Pull Request**: - - 打开 GitHub,创建一个 Pull Request,**目标分支请选择 `pr-branch`**。 +```bash +# WebChat +npm --prefix web-chat install +npm --prefix web-chat run build -### ⚠️ 重要提醒 +# 示例包或 ToolPkg +python3 ./sync_example_packages.py -- **先沟通,再开发**,避免重复工作。 -- **所有 PR 必须提交到 `pr-branch` 分支**。 -- **提交 PR 前,请务必同步最新的 `main` 分支**,并解决所有冲突。 -- 在 PR 中清晰说明你的改动。 +# Android JVM 单测、lint 和构建 +./gradlew :app:testDebugUnitTest +./gradlew :app:lintDebug +./gradlew assembleDebug +``` ---- +本地构建需要手动依赖时,按 [Android 编译指南](./BUILDING.md) 下载并放置 `models.zip`、`subpack.zip`、`jniLibs.zip` 和 `libs.zip`,不要将这些文件提交到 Git。 -我们期待您的贡献!你的每一次 PR、Issue 和讨论都在帮助 Operit 成长。 -> **关于项目维护**: 项目的发展依赖社区的参与。感谢你的每一份贡献! +## 创建 Pull Request ---- +所有上游 PR 的目标分支是 `main`,不再使用旧的 `pr-branch` 流程。建议使用以下分支前缀: -## 社区贡献与衍生项目指南 +- `feat/`:新功能 +- `fix/`:问题修复 +- `docs/`:文档 +- `ci/`:构建和自动化 +- `refactor/`:不改变行为的重构 +- `test/`:测试改动 -我们非常欢迎并鼓励社区基于 Operit AI 进行二次创作和改进。为了维护项目的透明度和社区的健康发展,我们强烈建议所有衍生项目: +推送个人分支并创建 PR: -1. **在知名的、公开的代码托管平台(如 GitHub, GitLab, Gitee 等)上发布您的源代码。** 我们建议使用这些大型平台,而不是自行搭建难以访问的小型代码站点,因为这能确保社区可以方便地审查、学习和贡献代码,从而真正实现“开源”的价值。 -2. **在您的项目文档中明确致谢并链接回本项目。** 这有助于用户追溯代码来源,也是对我们工作的尊重和认可。 +```bash +git fetch upstream +git rebase upstream/main +git push --set-upstream origin fix/short-description +``` -遵循这些建议将帮助我们共同构建一个更加开放、协作和安全的社区环境。 +PR 页面会自动加载 [PR 模板](../../../.github/PULL_REQUEST_TEMPLATE.md)。请保留并填写以下内容: + +- 变更背景、动机和改动范围 +- 关联 Issue;纯文档、CI 或维护性改动说明 `N/A` 及其背景 +- 兼容性、数据、配置、性能和安全影响 +- 运行过的命令、测试环境、构建变体和结果 +- 必要的截图、录屏、日志或构建产物 + +PR 标题需要使用 Conventional Commits 格式,例如: + +```text +fix(chat): preserve scroll position +feat(tools): add package description +docs: update contribution guide +ci: add localization checks +``` + +`feat`、`fix` 和 `perf` 类型的 PR 必须关联 Issue。不要把 API Key、Token、私有 URL 或本地路径写入 PR。 + +## CI 检查 + +PR 会进入 [PR Required workflow](../../../.github/workflows/pr-required.yml),并生成一个 `CI required` 聚合检查: + +- `Repository hygiene`:差异空白、冲突标记、JSON/XML/YAML 语法、Actions 语法和本地 Markdown 链接 +- `Localization`:字符串 key、资源类型、重复项和占位符结构;既有缺失翻译先作为提示 +- `WebChat checks`:TypeScript 类型检查和 WebChat 构建 +- `Android Build`:涉及 Android、native、资源、构建输入或 WebChat 时执行构建、JVM 单测和 Android lint +- `Documentation advisory`:Markdown 风格和拼写提示,目前不作为阻断条件 + +请先查看失败 job 的具体日志,再更新 PR。同步上游后,应重新确认 CI 使用的是最新 `main` 和当前 PR head。 + +## 社区项目与衍生项目 + +欢迎基于 Operit 开发衍生项目。请在公开代码托管平台发布源代码,在项目文档中注明 Operit 的来源并链接回本仓库,方便社区审查、学习和继续贡献。 diff --git a/tools/string/README.md b/tools/string/README.md new file mode 100644 index 000000000..afe9ec22c --- /dev/null +++ b/tools/string/README.md @@ -0,0 +1,30 @@ +# 字符串资源工具 + +Android 的中文字符串资源位于 +`app/src/main/res/values/strings.xml`,其他语言目录以它作为 key 和占位符基准。 + +## 检查 + +在仓库根目录运行: + +```bash +python3 ci/script/check_localizations.py +python3 tools/string/check_strings.py --simple +``` + +`check_localizations.py` 是 CI 使用的结构检查,负责检查 XML、重复 key、资源类型和 +占位符。已有的缺失翻译和相同译文会作为提示;本次修改造成的占位符不一致会阻止检查。 + +`check_strings.py` 是本地诊断工具,用于查看各语言文件的 key 数量、重复项和缺失项。 + +## 翻译 + +`fill_missing_translations.py` 可以生成缺失翻译报告,也可以调用配置的翻译服务补全文件: + +```bash +python3 tools/string/fill_missing_translations.py --report-only +python3 tools/string/fill_missing_translations.py --dry-run --limit 20 +``` + +写入翻译文件前请检查生成结果,保留 `%1$s`、`%2$d`、`{name}` 和换行等占位符。完整的 +贡献流程见 [贡献指南](../../docs/doc-src/dev-core/CONTRIBUTING.md)。 diff --git a/web-chat/package.json b/web-chat/package.json index 42b14ceac..5c5bb3620 100644 --- a/web-chat/package.json +++ b/web-chat/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "typecheck": "tsc --noEmit", "preview": "vite preview", "sync:android-assets": "node ./scripts/sync-to-android-assets.mjs" }, diff --git a/web-chat/src/main.tsx b/web-chat/src/main.tsx index bdbca5749..8ca39aa92 100644 --- a/web-chat/src/main.tsx +++ b/web-chat/src/main.tsx @@ -2,4 +2,6 @@ import ReactDOM from 'react-dom/client'; import { AIChatScreen } from './ui/features/chat/screens/AIChatScreen'; import './ui/features/chat/util/chat-screen.css'; -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(); +const rootElement = document.getElementById('root') as HTMLElement; + +ReactDOM.createRoot(rootElement).render(); diff --git a/web-chat/src/ui/features/chat/util/chatApi.ts b/web-chat/src/ui/features/chat/util/chatApi.ts index ab768b04b..09ddac363 100644 --- a/web-chat/src/ui/features/chat/util/chatApi.ts +++ b/web-chat/src/ui/features/chat/util/chatApi.ts @@ -15,7 +15,7 @@ import type { WebUploadedAttachment } from './chatTypes'; -type JsonValue = Record | unknown[] | null; +type JsonValue = object | null; export class ApiError extends Error { status: number; diff --git a/web-chat/src/ui/features/chat/util/thinkingQuality.ts b/web-chat/src/ui/features/chat/util/thinkingQuality.ts index b02ab7918..108ec6b37 100644 --- a/web-chat/src/ui/features/chat/util/thinkingQuality.ts +++ b/web-chat/src/ui/features/chat/util/thinkingQuality.ts @@ -2,5 +2,8 @@ export const MIN_THINKING_QUALITY_LEVEL = 1; export const MAX_THINKING_QUALITY_LEVEL = 5; export function clampThinkingQualityLevel(value: number): number { - return Math.max(MIN_THINKING_QUALITY_LEVEL, Math.min(MAX_THINKING_QUALITY_LEVEL, value)); + return Math.min( + MAX_THINKING_QUALITY_LEVEL, + Math.max(MIN_THINKING_QUALITY_LEVEL, value) + ); }