commit 679a3db1cc6538a56d12c994c34007dbcf553e63 Author: 叫我小杨同学 <188421097+YuJunZhiXue@users.noreply.github.com> Date: Fri Oct 23 15:55:53 2026 +0800 1.1.73: open-source local-lab only; drop skills and knowledge base. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..62217e4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +on: + push: + branches: + - master + +permissions: + contents: write + +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + +jobs: + release: + if: github.repository == 'YuJunZhiXue/dsh-purge' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Syntax-check plugin entry + run: | + node --check lib/index.js + node --check lib/core.js + node --check lib/reapply.js + node --input-type=module -e "import './lib/index.js'" + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + tag="v${version}" + if gh release view "$tag" >/dev/null 2>&1; then + echo "already published: $tag" + exit 0 + fi + first="$(head -n 1 release-notes.md | tr -d '\r')" + if [ "$first" != "# ${version}" ]; then + echo "release-notes.md 第一行必须是 # ${version},现在是: ${first}" + exit 1 + fi + zip="dsh-purge-${version}.zip" + if git rev-parse -q --verify "refs/tags/${tag}^{}" >/dev/null; then + commit="$(git rev-parse "refs/tags/${tag}^{}")" + git archive --format=zip --prefix="dsh-purge-${version}/" --output="$zip" "$commit" + gh release create "$tag" --title "$tag" --notes-file release-notes.md + else + git archive --format=zip --prefix="dsh-purge-${version}/" --output="$zip" HEAD + args=(release create "$tag" --target "$GITHUB_SHA" --title "$tag" --notes-file release-notes.md) + case "$version" in + *beta*|*alpha*|*rc*) args+=(--prerelease) ;; + esac + gh "${args[@]}" + fi + test -s "$zip" + gh release upload "$tag" "$zip" --clobber + echo "published https://github.com/YuJunZhiXue/dsh-purge/releases/tag/${tag}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b71549 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# 不上传 +node_modules/ +*.tgz +npm-debug.log* +*.log +.DS_Store +Thumbs.db + +# 只给本机 Cursor 技能看,不进仓库 +docs/agents/ + +# 本地草稿,不进仓库 +scripts/tmp-*.mjs +scripts/test-*.mjs +.scratch/ +dsh-redteam-mode-main/ +/1 + +# 外部参考,不是本插件 +choirboy-prompt/ +/choirboy-prompt/ +_archive/ +频道推广手册/ + +# 嵌入前的提示词原文 +lib/default-prompt-inject.md +default-prompt-inject.md + +# 本机官方安装包、解包目录、拆出的压缩包 +/deepseek-harness-*.exe +/official-harness-unpacked/ +/official-harness-payload.7z +*.asar +*.asar.bak diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..65ca927 --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +lib/default-prompt-inject.md +default-prompt-inject.md +频道推广手册/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..49e8d59 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, or + MERCHANTABILITY. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 YuJunZhiXue + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-COMMERCIAL.md b/LICENSE-COMMERCIAL.md new file mode 100644 index 0000000..a3062fa --- /dev/null +++ b/LICENSE-COMMERCIAL.md @@ -0,0 +1,14 @@ +# 怎么找我买商业授权 + +正本在 [LICENSE](./LICENSE)。这里只说怎么谈。 + +你个人本机自用:不另外收费,遵守 LICENSE 即可。 +你拿去卖、卖课、白标、给第三方当产品:先写信给我。 + +邮件 [a2269198325@163.com](mailto:a2269198325@163.com),或 GitHub [@YuJunZhiXue](https://github.com/YuJunZhiXue)。 +标题:`dsh-purge 商业许可`。写清用途、是否再分发、大致规模。没有自动收银台,谈妥再签。 + +授权不转让项目名称,也不让你对外自称官方。 +以前 MIT 时期已经发出去的 tag,我改不了。 + +2026年10月27日起新版本我只在 [Telegram 频道](https://t.me/LLM_PoJia/271) 发。买过授权的人按合同拿包,不走 GitHub Releases。 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..ca842ff --- /dev/null +++ b/NOTICE @@ -0,0 +1,7 @@ +dsh-purge +Copyright 2026 YuJunZhiXue + +Source: https://github.com/YuJunZhiXue/dsh-purge + +This product is licensed under the Apache License, Version 2.0. +Derivative works must retain this NOTICE and attribution. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f4067c --- /dev/null +++ b/README.md @@ -0,0 +1,520 @@ +

+ dsh-purge +

+ +

⚔️ DeepSeek Harness cybersecurity red-team tool (dsh-purge)

+ +

Version 1.1.73

+ +

+ DeepSeek Harness jailbreak: every model, swap prompts per model. Default prompt for 小码酱. Please star ⭐ If you build on this, see Attribution required. +

+ +

+ GitHub stars + Apache-2.0 + awesome · DSH plugin + DeepSeek Harness Hub + DSH +
+ Node.js + JavaScript +

+ +

+ Listed on dsh-plugin.org +

+ +

+ English | 中文 +

+ +## A note from the author + +People have been selling this open-source work in the open. After that, the author was asked to come in and talk. No need to spell out the rest. Read between the lines. + +From now on the **public internet is closed**. The open-source build only allows **local lab** targets: localhost, private nets, and local suffixes such as `.lab` / `.lan` / `.htb`. A public IP, a public domain, an online range, or a registered public host does not pass. This build does not ship the skill library or the knowledge base. + +**The original build may not be resold.** Charging for it, skinning it, or hawking it in groups is not allowed. Forks are fine, but **you must attribute** and name the source: [YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge). Hide the source or pass it off as your own, and you are on the other side. + +The author's personal channel: [t.me/LLM_PoJia](https://t.me/LLM_PoJia). Notes and updates show up there. + +## Contents + +- [A note from the author](#a-note-from-the-author) +- [What this is](#what-this-is) +- [Attribution required](#attribution-required) +- [License](#license) +- [Supported hosts](#supported-hosts) +- [Install](#install) +- [Preview](#preview) +- [Usage](#usage) +- [How it works](#how-it-works) +- [Restore](#restore) +- [Releases](#releases) + +--- + +## What this is + +**dsh-purge** is a cybersecurity red-team plugin for the official [DeepSeek Harness](https://www.deepseek.com/harness/) already installed on your machine. **1.1.73** targets **DSH 0.2.1-rc.2** (0.2.0 still works). Official **Web** (`dsh web`) and the official **desktop EXE** are both supported. Install and Apply them separately. + +Harness does more than forward a prompt. It rewrites prompts, approval gates, sandbox policy, and session migration at runtime. Asking only “will the model answer” treats a host-layer block as a model refusal. This plugin separates those two layers on your own install, for controlled evaluation and for seeing which default policy is too tight. + +| You get | What it does | +|---|---| +| **dsh-purge dock** | A button beside the session title opens the right dock. Two pages: Clean and Drill | +| **Clean** | Grouped patch status, Apply / Restore / Uninstall, prompt editor, multiple rule sets | +| **Drill** | Built into the stable release. After authorization: assets and the local environment. This build does not ship skills or the knowledge base. Local lab only | +| **Host policy** | Default copy, permission policy, and tool limits. Official capabilities stay. No second invented identity | +| **On start** | Checks and reapplies. After npm overwrites `node_modules`, you do not hand-edit files | + +No hardcoded drive letters. It looks at `$DSH_HOME`, `.dsh` next to the dsh launcher, then `~/.dsh`. It does not patch the Harness source tree. **Apply** on the **Clean** page of **dsh-purge** is what writes the changes. Identity comes from the encrypted prompt shipped in the plugin, the text the prompt box shows by default. The host prompt file is not read. + +It only touches the official `@deepseek-ai` packages and local config on the user's machine. It is not a public scanner and not an attack kit for third-party sites. The repo does not ship malware, unauthorized-exploit scripts, or payloads aimed at the public internet. + +--- + +## The original may not be resold. Forks must attribute. + +## Attribution required + +**The original build may not be resold.** No paid resale, no skinned store listing, no hawking it in groups. + +**Forks must attribute** and name the source repository: [YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge). Same if you borrow the name, ideas, code, or prompts. + +Hiding the source, passing it off as your own, or reselling the original will be pursued. + +## License + +This repo is [Apache License 2.0](./LICENSE). Forks and redistributions must keep the copyright notice and [NOTICE](./NOTICE), and name [YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge). The original may not be resold. + +--- + + + +
+ +### ⚠️ Strict legal and compliance disclaimer + + + +**Disclaimer:** This is a non-profit open-source project. It follows applicable laws and the rules of the platforms it uses, and is intended only for learning and research. Do not use it for any illegal or non-compliant purpose; the user bears any resulting consequences. + +**Zero-tolerance statement:** This project opposes and forbids any illegal activity. The authors do not support, encourage, or assist unauthorized network attacks, exploit use, data theft, unlawful intrusion into computer information systems, or generation of illegal or prohibited content. **Anyone who uses this project for crime is solely responsible under the law. The authors have no liability.** + +1. **This repository contains no illegal material.** The published code, docs, patches, and default prompt are **not** malware, backdoors, unauthorized pentest kits, ransomware, credential-stuffing scripts, or attack payloads aimed at the public internet or third-party systems. The project does not supply illegal content and does not incite, organize, or assist crime. +2. **Local official Harness only.** Security-eval patches and prompt injection run only on the **official DeepSeek Harness already installed on the user's machine** (`@deepseek-ai` packages, local profile / `$DSH_HOME`). The target is the user's own official local software, **not** someone else's website, server, account, or information system. +3. **Eval patches do not attack the public internet.** Apply, inject, revert, and uninstall stay in local files and local processes. They **do not scan, probe, intrude, or send attack traffic to any public host or unauthorized system**. Do not use this project as a jump host against the public internet. If "check for updates" is on, the plugin may only contact this plugin's own GitHub repository to compare versions. That is **not** an attack on third-party systems and is **not** authorization to penetrate anything. +4. **Lawful, controlled scope.** This project is an aid for red-team research and robustness evaluation on an **official Harness the user is entitled to administer**. **Do not run it against targets without the owner's lawful written authorization, public online systems, or production workloads.** Testing must stay on the **authorized local official Harness, offline local synthetic fixtures, authorized cybersecurity ranges, and compliant lab environments**. +5. **The open-source build is local-lab only.** Attack tasks only allow localhost/private nets and local suffixes such as `.lab` / `.lan` / `.htb`. A public IP, a public domain, an online range, or a registered public host is not an exception. A key, a password, a network range, a wildcard, or a claim made in the conversation does not add an exception. This build does not ship the skill library or the knowledge base. +6. **Forbidden uses.** Users must not use this project, directly or indirectly, to violate the following (each must be followed; no excuse to evade): + - Criminal Law of the People's Republic of China + - Cybersecurity Law of the People's Republic of China + - Data Security Law of the People's Republic of China + - Personal Information Protection Law of the People's Republic of China + - and other applicable laws, regulations, and supervisory rules; + - Also forbidden: + - Unauthorized intrusion or attacks on public or private computer information systems; extortion, sabotage, credential stuffing, or spreading malicious payloads; + - Inducing, generating, or spreading any content the law forbids, including threats to national security, terrorism, violence, pornography, gambling, fraud, and privacy or intellectual-property infringement; + - Violating the model provider's terms of service and acceptable-use policy. +7. **The user bears all responsibility.** The project is provided under Apache-2.0 as-is. The authors make no warranty of completeness, security, or fitness. **Users independently bear all civil, administrative, and criminal liability** for download, deploy, run, modify, distribute, and all resulting inputs and outputs. Authors and contributors bear no direct, indirect, or joint liability for abuse. +8. **The license ends on breach.** Anyone who uses this project for illegal attacks, malicious activity, or other violations has their open-source license **automatically and irrevocably terminated** from the moment of the violation. They must stop using the project, permanently destroy all copies and derivatives, and accept legal sanctions. +9. **No affiliation.** This is an independent open-source security-eval research project. It has no employment, commercial, authorization, or endorsement relationship with DeepSeek or its affiliates. "Official" here only means the eval target is the official DeepSeek Harness package on the user's machine. It does **not** mean DeepSeek developed, approved, or warrants this plugin. +10. **Attribution is required.** If you borrow this project's name, ideas, code, or prompts, you must credit the author and name this repository. Failure to attribute will be pursued. See [Attribution required](#attribution-required). + + + +
+ +--- + +## Supported hosts + +**1.1.73 targets DSH 0.2.1-rc.2.** 0.2.0 still works. Do not apply it to 0.1.x. + +- **dsh 0.2.1-rc.2** (official desktop / official `dsh web`): use **1.1.73**. Fixes `{{cwd}}` / `[cwd]` crash, patch #40 match, dropped `toolFilter`, host `dsh-tools` resolve, hostFs fd, desktop helper self-kill, `file:` archive update, Ctrl+Enter steer. +- **dsh 0.2.0-rc.2**: use **1.1.40** or newer; this line also installs. +- **dsh 0.1.7** (incl. rc.1 / rc.2): use **1.1.39** or older — pick the tag on [Releases](https://github.com/YuJunZhiXue/dsh-purge/releases). + +Unmatched patches stay pending or skipped; nothing is rewritten blindly. + +--- + +## Install + +Web and the official desktop EXE are the two maintained hosts. Install and patch each **separately**, and only the host you actually run. + +| What you run | Profile | Command | +|---|---|---| +| Official `dsh web` | `web` | `dsh plugin --profile web add https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz` | +| Official desktop EXE | `desktop` | `dsh plugin --profile desktop add https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz` | + +If this directory is already a clone, use `add .` instead of the URL. If `dsh` is not on PATH, use [Manual install](#manual). There is no npm package; do not `pnpm add dsh-purge`. + +For the desktop client you can also click the button (`dsh://` deep link): + +

+ Install in desktop client +

+ +### After the command: three steps + +Adding the plugin to a profile does **not** patch `@deepseek-ai` by itself. + +1. **Quit and reopen** the host you installed into — stop and restart `dsh web`, or quit the desktop tray and reopen that app. +2. On **that host**, click **dsh-purge** beside the session title, then **Apply** on the **Clean** page. It is not on the host Settings page. +3. A successful **Apply** restarts once so patches load. It does not restart if it did not finish. + +Web controls affect Web only; desktop controls affect the desktop app only. Do not Apply one host from the other. + +### How to tell it installed + +- **dsh-purge** sits beside the session title, with **Apply** on its Clean page. +- `/purge status` in chat prints `DSH_HOME` and the patch list — the path should be the home you actually run. +- Optional packages that are not installed (liangshen) show as skipped and do not block Apply. + +### Hand this to an assistant + +Paste the block below to a local assistant. It should only run the install command — no disk scan, no other edits, no Apply, no restart. + +
+For AI assistants (install only) + + + +Install the DeepSeek Harness plugin `dsh-purge` into the Web profile only. Do not scan the whole disk, do not read or edit other files, do not apply settings, and do not restart the app. Do not install into the desktop profile. + +**Web (official `dsh web`)** + +If `dsh` is not on PATH, tell the user and stop. + +```sh +dsh plugin --profile web add https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz +``` + +If this directory is already a clone, use `dsh plugin --profile web add .`. There is no npm package; do not `pnpm add dsh-purge`. + +After the command finishes, tell the user to fully quit and restart `dsh web`, then open **dsh-purge** beside the session title and **Apply** on **Clean**. Then stop. + + + +
+ + + +### Manual install + +Use this when the command fails or `dsh` is not on PATH. Edit **only the profile for the host you run** — do not touch the other, and do not delete existing bundles. + +
+Full manual steps + +**1. Find the `$DSH_HOME` this host uses.** A real home is named `.dsh` (official EXE sometimes `dsh-home`), contains `profiles`, and has at least one `profiles//package.json`. Check in order: `DSH_HOME` env → `.dsh` next to the install root (portable) → `%USERPROFILE%\.dsh` or `~/.dsh` → `%APPDATA%` / `%LOCALAPPDATA%\DeepSeek Harness\dsh-home`. Confirm: `profiles/web/package.json` has `"name": "dsh-profile-web"` (or `dsh-profile-desktop`). Machines often have two homes — use the one that belongs to the host you actually start. + +PowerShell to list candidates: + +```powershell +$cands = @() +if ($env:DSH_HOME) { $cands += $env:DSH_HOME } +$cands += "$env:USERPROFILE\.dsh" +$dsh = Get-Command dsh -ErrorAction SilentlyContinue +if ($dsh) { + $dir = Split-Path $dsh.Source + $cands += @((Join-Path $dir ".dsh"), (Join-Path (Split-Path $dir) ".dsh"), (Join-Path (Split-Path (Split-Path $dir)) ".dsh")) +} +$cands += @("$env:APPDATA\DeepSeek Harness\dsh-home", "$env:LOCALAPPDATA\DeepSeek Harness\dsh-home") +$cands | Select-Object -Unique | Where-Object { $_ -and (Test-Path (Join-Path $_ "profiles")) } +``` + +**2. Put the plugin at `$DSH_HOME/plugins/dsh-purge`** (folder must be named `dsh-purge`, with a `package.json` whose `"name"` is `dsh-purge`). + +```sh +git clone https://github.com/YuJunZhiXue/dsh-purge.git "$DSH_HOME/plugins/dsh-purge" +``` + +Without git: download [master.tar.gz](https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz), extract, rename `dsh-purge-master` → `dsh-purge`, and place it under `plugins`. If you already have a clone, copy the whole tree — not a few `.js` files. + +**3. Edit only that profile's `package.json` — back it up first.** Web → `$DSH_HOME/profiles/web/package.json`; desktop → `$DSH_HOME/profiles/desktop/package.json`. Add **only two things**, keeping every existing dependency and bundle: + +1. In `dependencies`, add `"dsh-purge": "file:../../plugins/dsh-purge"` +2. At the **end** of `dsh.profile.bundles`, append `"dsh-purge"` (skip if already there) + +```json +{ + "name": "dsh-profile-web", + "private": true, + "dependencies": { + "dsh-purge": "file:../../plugins/dsh-purge" + }, + "dsh": { + "profile": { + "bundles": [ + "@deepseek-ai/dsh-base", + "@deepseek-ai/dsh-web-app", + "dsh-purge" + ], + "patchReload": "live" + } + } +} +``` + +Keep JSON valid (comma before the new item, none after the last). Do not switch `file:../../plugins/dsh-purge` to an absolute path, do not touch `patchReload` or other plugins, and do not list `"dsh-purge"` twice. On desktop, if there is no `dsh-web-app` row, do not add one. + +**4. Run `pnpm install` in that profile only** (`cd` into the profile dir, not the repo root or `$DSH_HOME`): + +```sh +cd "$DSH_HOME/profiles/web" # or profiles/desktop +pnpm install +``` + +Success: `profiles//node_modules/dsh-purge/package.json` exists. If `pnpm` is missing, use the Node / pnpm shipped with official `dsh`; on `Could not resolve`, re-check the `file:` path; on a JSON error, fix commas or restore the backup. + +**5. Fully quit that host, reopen it, then Apply** — same three steps as above. The card appears beside the session title; click **Apply** (or `/purge apply`) on that host only. If the card is missing, you likely edited the other `.dsh` — go back to step 1. + +
+ +### Uninstall + +**dsh-purge** beside the session title → **Clean** → **Uninstall**. Confirm the dialog: it reverts any applied patches, restores the original Harness, and restarts the current host. + +```sh +# or from a terminal / chat +dsh-purge --uninstall +# /purge uninstall +``` + +Plugin config lives in `cordis.patch.yml` (`postPrompt` is empty by default): + +```yaml +- insert: + - id: dsh-purge + name: 'dsh-purge' + config: + enabled: true + autoApplyOnStart: true + autoUpdateOnStart: false + autoRevertOnMissing: false + verbose: false + postPromptOrder: 5100 + postPrompt: "" +``` + +--- + +## Preview + +**dsh-purge** sits beside the session title. It opens a right-hand dock with two pages: **Clean** and **Drill**. Switch **Light / Ink**. Patches are grouped; the count only includes items that actually applied. Rule sets sit in a list above the editor, with Enable and Delete on each row. + +The first time you open Drill you read the notice, wait out the countdown, scroll to the end, and check three boxes. Clean does not need that step. Drill is only for a host you manage, an offline target, or an exercise that already has written authorization. + +**Clean** + +![Clean](docs/preview/dock-clean.png) + +**Drill authorization** + +![Drill authorization](docs/preview/dock-auth.png) + +**Drill** + +![Drill](docs/preview/dock-drill.png) + +**Patches** + +![Patches](docs/preview/settings.png) + +**Local lab** + +The open-source build only allows localhost/private nets. The Clean page says so. You cannot register a public host. + +| Area | What it shows | +|---|---| +| dsh-purge | button beside the session title; opens or collapses the dock | +| Clean | patches, prompt, rule sets | +| Drill | assets and environment after authorization. This build does not ship skills or the knowledge base. The tab says Unauthorized until then | +| Patches | grouped status, Apply, Restore, or Uninstall | +| Prompt | edit `prompt-inject.md` as the session override | +| Local lab | localhost/private nets and local suffixes such as `.lab` / `.lan` / `.htb` only | +| Rule sets | multiple `AGENTS.md` / `CLAUDE.md`; Enable writes under `$DSH_HOME`, Delete removes the row | + +--- + +## Layout + +`bin/` is the CLI, `lib/` is patches and the drill console, `presets/redteam/` is the red-team preset, and `client.js` is the dock. This build does not ship `skills/` or the knowledge base. + +Runtime files live under `$DSH_HOME`: `prompt-inject.md`, `rules/`, `redteam/`. If `DSH_HOME` is unset, the launcher-adjacent `.dsh` wins over `~/.dsh`. Web and the desktop app each use their own home. + +--- + +## Usage + +```sh +dsh-purge --status +dsh-purge --apply +dsh-purge --revert +dsh-purge --uninstall +dsh-purge --edit + +/purge status | apply | revert | uninstall | edit | help +/rules list | use | create | delete | reset | help +/rewind + +purge_status purge_apply purge_revert +``` + +After a successful Apply, the host restarts so patched packages load; you can also click **Restart** manually. Under the patch title are the stable release and the test release. The test release follows the `beta` branch. **Switch to stable** installs `master` and clears any pin. Picking an older stable version pins it; click **Update** to return to the latest. Old test tags are not listed. + +The composer **Undo once** and **Undo last round** stay in the current conversation and do not open a branch. The sent line goes back into the input, and that cut's already-sent messages and completed tasks leave the current conversation; edit and **send again**. From **1.1.61**, rewind bounds follow the **current turn**, not the first user message. `/rewind` does the same. + +If the **same task works in standard but fails in minimal or PTC**, preset `run_code`, sandbox, or plan intercept text is often still uncleared, or built-in minimal is missing `agent-instructions`. Use **1.1.61+**, then **quit the host fully → Apply in Clean → restart → start a new chat**. Switching preset alone does not reload patches in the running process. + +### Response speed + +DSH runs Bash calls in a tool batch one at a time. Older dsh-purge releases raised the default foreground wait from 60 seconds to 10 minutes, so one slow command could hold up later calls for that long. Patch #21 now restores marked plugin values to the official 60-second default. Unmarked timeouts and other custom values are preserved; an unmarked 10-minute value cannot reliably be distinguished from a user setting. + +For a shorter wait in `dsh web` on macOS/Linux, add this entry to `$DSH_HOME/profiles/web/cordis.patch.yml` (normally `~/.dsh/profiles/web/cordis.patch.yml`). Edit an existing entry with the same id instead of adding a duplicate. This profile layer is applied after the bundled configuration, so future plugin applies keep the override. + +```yaml +- id: bash-sandbox + config: + timeoutMs: 10000 +``` + +With the standard jobs service and `promoteOnTimeout: true`, an unfinished command returns a background job id after 10 seconds and keeps running. Read it with `job_output` or stop it with `job_kill`. This changes the foreground wait, not the command's speed. Without that service or with promotion disabled, the timeout kills the command. A per-call `timeoutMs` overrides this default; `run_in_background: true` returns a job id immediately. + +For routine tasks, you can also opt into Low reasoning for new sessions: + +```yaml +- id: agent-default-model + config: + provider: deepseek-official + model: deepseek-flash + reasoningEffort: low +``` + +Restart `dsh web` to load the profile. The model and reasoning defaults apply to newly created sessions; select Low in the composer for an existing session. The plugin does not change these model preferences automatically. Keep High when the task needs deeper reasoning, and narrow file searches rather than recursively scanning every application directory. + +### Local lab + +Attack tasks are denied by default. The open-source build only allows localhost/private local labs. Saying “this is my server” in chat does not allow it. Registering a public IP or domain does not allow it either. + +--- + +## Local checks + +```sh +node --check lib/index.js +node --check lib/core.js +node --check lib/surface.js +node --check lib/web.js +node --check lib/desktop.js +node --check lib/host.js +node --check lib/rewind.js +node --check lib/skills.js +node --check client.js +``` + +--- + +## How it works + +Apply, on start or when you click Apply: + +```mermaid +flowchart TD + A["Start or click Apply"] --> B{"Patch already in place?"} + B -->|yes| C["Skip"] + B -->|no| D["Back up the original as .dshpurge.bak"] + D --> E["Replace the matched files"] + E --> F["Override the shim"] + F --> G["Inject the encrypted prompt shipped in the plugin, the text the prompt box shows by default"] +``` + +Override on each session: + +```mermaid +flowchart TD + A["Restart or new session"] --> B["Inject the plugin's encrypted default. No save click, and no host prompt file"] +``` + +Prompt injection and ruleset decision at every turn. The three inputs on the left are what you control in the dsh-purge panel; the dotted edges show which decision each one drives: + +```mermaid +flowchart TD + L["Rule library: activate a set → writes prompt-inject.md"] + W["Local rules: check AGENTS.md / CLAUDE.md in the current dir"] + P["Workspace policy: strict / fallback / always"] + + A["Turn start"] --> B{"prompt-inject.md non-empty?"} + B -->|yes| C["Inject = prompt-inject.md (A)"] + B -->|no| D{"Any checked local md in the current dir, non-empty?"} + D -->|yes| E["Inject = selected workspace md (B)"] + D -->|no| F["Inject = plugin encrypted slot default (D)"] + C --> G{"Workspace baseline policy (C)"} + E --> G + F --> G + G -->|strict| H["Drop DSH workspace baseline section"] + G -->|fallback default| I{"User inject present above? (A or B)"} + G -->|always| J["Keep DSH workspace baseline section"] + I -->|yes| H + I -->|no| J + H --> K["Final system prompt"] + J --> K + + L -.-> B + W -.-> D + P -.-> G +``` + +--- + +## Restore + +- Each target is copied to `.dshpurge.bak` before the first apply. +- **Restore** or `/purge revert` copies backups back and deletes them. With no backup, shim lines written by this plugin are stripped. +- `prompt-inject.md` is a user file and is kept. +- **Uninstall** restores first if patches were applied, then deletes the inject file, rule library, and the plugin itself. +- Apply is idempotent. + +--- + +## Path detection + +The host surface is detected first: `web` / `desktop` (`gui` / `tui` are reserved and still fall back to web). + +**Web:** + +1. `DSH_HOME` / `DSH_BASE` +2. `.dsh` next to the dsh launcher (portable install, any drive) +3. `npm prefix -g` / `npm root -g` +4. Nested `@deepseek-ai/dsh/node_modules/@deepseek-ai` +5. `~/.dsh` + +**Official desktop EXE:** the running official Harness install (`resources/app` or the unpacked package). The drive letter is not hard-coded. A successful Apply restarts once. + +Community Desktop is not maintained. + +Official npm-global is not patched. Sealed `host-commands` / `runtime-commands` are scrubbed, never injected. + +If nothing is found, set `DSH_BASE` / `DSH_DESKTOP_INSTALL`. No files are changed. + +--- + +## Releases + +The author's personal channel: [t.me/LLM_PoJia](https://t.me/LLM_PoJia). Notes and updates show up there. GitHub [Releases](https://github.com/YuJunZhiXue/dsh-purge/releases) still has older packages. The original may not be resold; forks must attribute. + +## Notes + +- Scope is rendered copy, defaults, and runtime logic inside local `@deepseek-ai/*` packages, plus override files and rule sets under the harness home. +- After an upgrade, unmatched originals show as skipped. Apply still completes, and those files are left unchanged. +- Third-party plugin *source repos* outside `@deepseek-ai` are left alone (CMD silence may **best-effort** patch installed doctor / market / liangshen / mnemon at runtime). +- Install from the GitHub archive (there is no npm package): `dsh plugin --profile web add https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz` (use profile `desktop` for the EXE). From this repo, `add .`. + +--- + +Thanks to the [LINUX DO](https://linux.do) community + diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..5ecbb9d --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,528 @@ +

+ dsh-purge +

+ +

⚔️ DeepSeek Harness 网络安全红队工具(dsh-purge)

+ +

Version 1.1.73

+ +

+ DeepSeek Harness 破甲:让所有模型都能破甲,不同模型可换不同提示词;默认提示词面向国模「小码酱」。Jailbreak for every model — swap prompts per model. 求 Star 收藏 ⭐ 如要二开,请查看署名要求部分 +

+ +

+ GitHub stars + Apache-2.0 + awesome · DSH plugin + DeepSeek Harness Hub + DSH +
+ Node.js + JavaScript +

+ +

+ Listed on dsh-plugin.org +

+ +

+ English | 中文 +

+ +## 作者说几句 + +市面上有人把这份开源明目张胆挂出去卖。后面作者这边被找去谈过话——经过不必写,你们自己品。 + +从今往后,**公网一律不许碰**。开源版攻击任务只认**本地靶场**:本机、内网,以及 `.lab` / `.lan` / `.htb` 这类本机实验室后缀。公网 IP、域名、网上演练平台、自己登记的公网机器,一个都不放。不是本地靶场就别跑,也跑不过去。开源版不下发技能库和知识库。 + +**原版不允许倒卖。** 收费、当货挂群,一律不行。二开可以,但**必须署名**,写明来源仓库:[YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge)。不署名、隐瞒来源、改头换面据为己有,作者一律当对家。 + +作者个人频道:[t.me/LLM_PoJia](https://t.me/LLM_PoJia)。有话、有更新,会发在那里。 + +## 目录 + +- [作者说几句](#作者说几句) +- [介绍](#介绍) +- [署名要求](#署名要求) +- [许可证](#许可证) +- [适配范围](#适配范围) +- [安装](#安装) +- [界面](#界面预览) +- [使用](#使用) +- [工作原理](#工作原理) +- [还原](#还原) +- [更新](#更新) + +--- + +## 介绍 + +**dsh-purge** 是装在本机官方 [DeepSeek Harness](https://www.deepseek.com/harness/) 上的网络安全红队评测插件。当前 **1.1.73** 已适配 **DSH 0.2.1-rc.2**(0.2.0 仍可用)。官方 **Web**(`dsh web`)和官方 **桌面端**(EXE)都支持,两边分开装、分开应用。 + +Harness 不只是把问题交给模型。它还会在运行时改提示词、审批门、沙箱和会话格式。只看「模型答不答」,会把宿主拦截误当成模型拒绝。这个插件在你自己的安装里把这两层分开,用来做受控评测,也用来看默认策略该不该收紧。 + + +| 你会用到 | 它做什么 | +| ---------------- | ----------------------------------------------- | +| **dsh-purge 侧栏** | 会话标题旁的按钮打开右侧栏。里面两页:清洗、演练台 | +| **清洗** | 分组查看补丁,应用、还原、卸载;编辑提示词和多套规则 | +| **演练台** | 正式版内置。授权后查看资产和本机环境。开源版不下发技能库和知识库。只用于本机/内网本地靶场 | +| **宿主策略** | 调整默认文案、权限策略和工具上限。官方能力保留,不另写一套身份 | +| **启动时** | 自动再检查并应用。npm 升级盖掉 `node_modules` 之后不用手改文件 | + + +不写死盘符。按 `$DSH_HOME`、dsh 启动器旁边的 `.dsh`,再退回 `~/.dsh`。不改 Harness 源码仓库,在 **dsh-purge** 的「清洗」里点「应用」才写入。身份来自插件里的加密提示词,也就是提示词框默认显示的那一份。不读取宿主磁盘上的提示词文件。 + +本插件只处理使用者本机已安装的官方 `@deepseek-ai` 包和本机配置。它不是公网扫描器,也不是针对第三方站点的攻击套件。仓库内不含木马、未授权渗透脚本或对外攻击载荷。 + +--- + +## 📌 原版不允许倒卖。二开必须署名。 + +## 署名要求 + +**原版不允许倒卖。** 不得收费转卖、换皮上架、当货挂群。 + +**二开必须署名**,并写明来源仓库:[YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge)。借用本项目的名称、思路、代码或提示词,同样必须署名。 + +不署名、隐瞒来源、改头换面据为己有,或把原版拿去倒卖,作者将依法追究责任。 + +## 许可证 + +本仓库使用 [Apache License 2.0](./LICENSE)。二开、再分发必须保留版权声明和 [NOTICE](./NOTICE),写明来源 [YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge)。原版不允许倒卖。 + +--- + + + + + +### ⚠️ 严正法律免责与合规使用声明 + + + +**免责声明:** 本项目为非营利开源项目,遵守国家法律法规及所在平台的相关规范,仅供学习与研究使用。不得将本项目用于任何违法违规用途;由此产生的后果由使用者自行承担。 + +**【零容忍严正申明】**:本项目坚决反对并严禁任何形式的违法犯罪行为!本项目开发者绝不支持、不鼓励、不协助任何未授权网络攻击、漏洞利用、数据窃取、非法侵入计算机信息系统或生成违法违禁内容的活动。**任何将本项目用于违法犯罪的行为,均与开发者无关,由行为人依法独立承担全部法律责任。** + +1. **本仓库不含违法内容**:`dsh-purge` 发布的代码、文档、补丁与默认提示词**不是**木马、后门、未授权渗透工具、勒索软件、撞库脚本,也**不是**针对公网或第三方系统的攻击载荷。项目本身不提供违法内容,也不教唆、组织、协助实施违法犯罪。 +2. **只作用于本机官方 Harness**:安全评测补丁、提示词注入全部发生在使用者**本机已安装的官方 DeepSeek Harness**(`@deepseek-ai` 包、本机 profile / `$DSH_HOME`)上。对象是使用者自己的官方本地软件,**不是**他人的网站、服务器、账号或信息系统。 +3. **评测补丁不对外网目标联网**:应用补丁、写入注入、回滚、卸载均在本机文件与本机进程内完成,**不对任何公网主机、未授权系统进行扫描、探测、入侵或攻击发包**。不得把本项目当作跳板去打外网。插件若开启「检测更新」,仅可能访问本插件自己的 GitHub 仓库以核对版本,**与对第三方系统的网络攻击无关**,也不能被解释为授权对外渗透。 +4. **合法受控范围限定**:本项目定位为使用者在**自己有权管理的本机官方 Harness**上,进行红队科研与鲁棒性评测的辅助工具。**严禁在未经所有者合法书面授权的目标、公网在线系统或生产业务上运行本项目**。一切测试必须限制在**本机已授权安装的官方 Harness、离线本地合成靶标、授权网络安全演练靶场及合规实验室受控环境**中进行。 +5. **开源版只允许本地靶场。** 攻击任务只放行本机/内网,以及 `.lab` / `.lan` / `.htb` 这类本机实验室后缀。公网 IP、公网域名、网上演练平台、自己登记的公网机器都不能当作例外。密钥、密码、网段、通配符,以及对话里的口头说明,都不能增加例外。开源版不下发技能库和知识库。 +6. **严禁违法与违禁用途**:使用者严禁利用本项目直接或间接从事任何违反下列法律法规的行为(必须逐条遵守,不得以任何理由规避): + - **《中华人民共和国刑法》** + - **《中华人民共和国网络安全法》** + - **《中华人民共和国数据安全法》** + - **《中华人民共和国个人信息保护法》** + - 以及其他现行有效的法律、行政法规与监管规定; + - 同时严禁: + - 未经授权渗透、攻击公私机构计算机信息系统,实施勒索、破坏、撞库或传播恶意载荷; + - 诱导、生成或传播危害国家安全、恐怖主义、暴力血腥、涉黄涉赌、诈骗、侵犯公民隐私或知识产权等任何法律明令禁止的违法违禁内容; + - 违反相关大模型提供商的《服务条款》与《滥用政策》。 +7. **使用者独立承担全部责任**:本项目依据 Apache-2.0“按现状”提供,开发者不对软件的完整性、安全性与适用性作任何明示或暗示的保证。**使用者应对自身的所有下载、部署、运行、修改、传播行为以及由此产生的全部输入与输出后果承担独立、完全的民事、行政及刑事法律责任**。项目作者与贡献团队绝不承担任何因使用者滥用导致的直接、间接或连带责任。 +8. **违约即终止授权**:任何将本项目用于非法攻击、恶意活动或违规行为的个人或实体,其开源软件使用许可将自违法违规行为发生之日起**自动且不可撤销地立即终止**。该主体须立即停止使用并永久销毁本项目的所有代码、脚本与衍生数据,并依法承担相应法律制裁。 +9. **第三方独立性声明**:本项目属于完全独立的开源安全评测研究项目,与 DeepSeek 官方或其关联主体无任何隶属、商业合作、授权或官方背书关系。文中「官方」仅指评测对象为使用者本机安装的官方 DeepSeek Harness 软件包,**不代表** DeepSeek 官方开发、认可或担保本插件。 +10. **必须署名**:借用本项目的名称、思路、代码或提示词,必须署名并标明本仓库。不署名将依法追究责任。详见 [署名要求](#署名要求)。 + + + + + +--- + +## 适配范围 + +**1.1.73 已适配 DSH 0.2.1-rc.2**,0.2.0 仍可用。不要拿它去打 0.1.x。 + +- **dsh 0.2.1-rc.2**(官方桌面 / 官方 `dsh web`):用 **1.1.73**。已修 `{{cwd}}` / `[cwd]` 整轮失败、补丁 #40 命中、`toolFilter` 废弃、`dsh-tools` 解析、hostFs fd、桌面换包自杀、`file:` 归档更新、Ctrl+Enter 插话。 +- **dsh 0.2.0-rc.2**:用 **1.1.40** 及以上;本页这档也能装。 +- **dsh 0.1.7**(含 rc.1 / rc.2):用 **1.1.39** 及更早 —— 在 [Releases](https://github.com/YuJunZhiXue/dsh-purge/releases) 里选对应标签。 + +对不上的补丁会显示待应用或跳过,不会乱改文件。 + +--- + +## 安装 + +现在只维护官方 `dsh web` 和官方桌面 EXE,**分开装、分开应用**,只装你正在打开的那一个。 + +| 你正在用 | profile | 命令 | +|---|---|---| +| 官方 `dsh web` | `web` | `dsh plugin --profile web add https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz` | +| 官方桌面 EXE | `desktop` | `dsh plugin --profile desktop add https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz` | + +当前目录已经是本仓库时,把 URL 换成 `add .`。`dsh` 不在 PATH 时走 [手动安装](#manual)。没有 npm 包,不要 `pnpm add dsh-purge`。 + +桌面客户端也可以点按钮(`dsh://` 唤起): + +

+ 唤起客户端一键安装 +

+ +### 装完都要做这三步 + +只把插件写进 profile **还不会**改 `@deepseek-ai`。 + +1. **退出并重新打开**刚装的那个宿主:Web 关掉 `dsh web` 再开;桌面端退出托盘再打开对应 exe。 +2. 在**这个宿主**点会话标题旁的 **dsh-purge**,在 **清洗** 页点 **「应用」**。宿主的设置页里没有这个条目。 +3. 「应用」成功后会自动重启一次,让补丁进入当前进程。没做完不会重启。 + +Web 的控件只动 Web,桌面端的只动桌面应用。不要在一个宿主里点另一个宿主的应用。 + +### 怎样算装上了 + +- 会话标题旁有 **dsh-purge**,点开后在「清洗」里能点「应用」。 +- 聊天里 `/purge status` 能打出 `DSH_HOME` 和补丁列表,路径应是你正在用的那一份。 +- 没装的可选包(例如梁神)显示跳过,不挡住应用完成。 + +### 交给助手安装 + +把下面整段发给本机助手。它只执行安装命令:不要全盘搜索、不要改其它文件、不要代点「应用」、不要重启。 + +
+给 AI 助手(只安装) + + + +安装 DeepSeek Harness 插件 `dsh-purge`,只装到 Web profile。不要全盘搜索,不要读或改其它文件,不要应用设置,也不要重启应用。不要装到桌面 profile。 + +**Web 端(官方 `dsh web`)** + +`dsh` 不在 PATH 就告诉用户后停止。 + +```sh +dsh plugin --profile web add https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz +``` + +当前目录已是本仓库时改用 `dsh plugin --profile web add .`。没有 npm 包,不要 `pnpm add dsh-purge`。 + +命令结束后,提醒用户:完全退出并重启 `dsh web`,再点会话标题旁的 **dsh-purge**,在「清洗」里点「应用」。然后停止。 + + + +
+ + + +### 手动安装 + +命令行装不上、或 `dsh` 不在 PATH 时用这个。只改**正在用的那个宿主**对应的 profile,不要动另一个,也不要删原有 bundle。 + +
+完整手动步骤 + +**1. 找到真正在用的 `$DSH_HOME`。** 认目录:名字是 `.dsh`(官方 EXE 偶见 `dsh-home`),里面有 `profiles`,且至少有一个 `profiles/<名>/package.json`。按顺序找:`DSH_HOME` 环境变量 → 安装根旁边的 `.dsh`(便携包)→ `%USERPROFILE%\.dsh` 或 `~/.dsh` → `%APPDATA%` / `%LOCALAPPDATA%\DeepSeek Harness\dsh-home`。确认:`profiles/web/package.json` 里 `"name"` 是 `dsh-profile-web`(桌面是 `dsh-profile-desktop`)。本机常有两份 `.dsh`,要用你**实际启动**的那个宿主对应的那份。 + +Windows PowerShell 列候选: + +```powershell +$cands = @() +if ($env:DSH_HOME) { $cands += $env:DSH_HOME } +$cands += "$env:USERPROFILE\.dsh" +$dsh = Get-Command dsh -ErrorAction SilentlyContinue +if ($dsh) { + $dir = Split-Path $dsh.Source + $cands += @((Join-Path $dir ".dsh"), (Join-Path (Split-Path $dir) ".dsh"), (Join-Path (Split-Path (Split-Path $dir)) ".dsh")) +} +$cands += @("$env:APPDATA\DeepSeek Harness\dsh-home", "$env:LOCALAPPDATA\DeepSeek Harness\dsh-home") +$cands | Select-Object -Unique | Where-Object { $_ -and (Test-Path (Join-Path $_ "profiles")) } +``` + +**2. 把插件放到 `$DSH_HOME/plugins/dsh-purge`**(目录名必须是 `dsh-purge`,其 `package.json` 里 `"name"` 也必须是 `dsh-purge`)。 + +```sh +git clone https://github.com/YuJunZhiXue/dsh-purge.git "$DSH_HOME/plugins/dsh-purge" +``` + +没有 git:下载 [master.tar.gz](https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz),解压后把 `dsh-purge-master` 改名为 `dsh-purge`,整夹放到 `plugins` 下。已有本仓库副本就复制整个目录,不要只拷几个 `.js`。 + +**3. 只改对应 profile 的 `package.json`,先备份。** Web → `$DSH_HOME/profiles/web/package.json`;桌面 → `$DSH_HOME/profiles/desktop/package.json`。**只追加两处**,原有依赖和 bundle 全部留着: + +1. `dependencies` 增加 `"dsh-purge": "file:../../plugins/dsh-purge"` +2. `dsh.profile.bundles` **末尾**追加 `"dsh-purge"`(已有就不要再加) + +```json +{ + "name": "dsh-profile-web", + "private": true, + "dependencies": { + "dsh-purge": "file:../../plugins/dsh-purge" + }, + "dsh": { + "profile": { + "bundles": [ + "@deepseek-ai/dsh-base", + "@deepseek-ai/dsh-web-app", + "dsh-purge" + ], + "patchReload": "live" + } + } +} +``` + +JSON 要合法(新增项前面有逗号,最后一项后面没有多余逗号)。`file:../../plugins/dsh-purge` 不要改成绝对路径,`patchReload`、其它插件、版本号都不要动,`"dsh-purge"` 不要写两次。桌面端没有 `dsh-web-app` 就不要硬加。 + +**4. 只在刚改的那个 profile 目录装依赖**(`cd` 进 profile 目录,不要在仓库根或 `$DSH_HOME` 根): + +```sh +cd "$DSH_HOME/profiles/web" # 或 profiles/desktop +pnpm install +``` + +成功标志:出现 `profiles//node_modules/dsh-purge/package.json`。找不到 `pnpm` 就用官方 `dsh` 自带的 Node / pnpm;报 `Could not resolve` 就检查 `file:` 路径;JSON 解析失败就修逗号或还原备份。 + +**5. 完全退出该宿主,再启动,再点「应用」** —— 和上面三步一样。按钮会出现在会话标题旁,只在这个宿主点「应用」(或 `/purge apply`)。没出现多半是改错了另一份 `.dsh`,回到第 1 步核对。 + +
+ +### 卸载 + +会话标题旁的 **dsh-purge** →「清洗」→「卸载」。弹窗确认:会先还原已应用的补丁,还原回原版 Harness,再重启当前宿主。 + +```sh +# 也可以用命令行 / 聊天 +dsh-purge --uninstall +# /purge uninstall +``` + +插件配置在 `cordis.patch.yml`(`postPrompt` 默认为空): + +```yaml +- insert: + - id: dsh-purge + name: 'dsh-purge' + config: + enabled: true + autoApplyOnStart: true + autoUpdateOnStart: false + autoRevertOnMissing: false + verbose: false + postPromptOrder: 5100 + postPrompt: "" +``` + +--- + +## 界面预览 + +会话标题旁有 **dsh-purge**。点开是右侧栏,两页:**清洗** 和 **演练台**。白 / 墨可切换。补丁按组展开,进度只计真正已应用的项。规则集在上方列表启用或删除,下方编辑正文。 + +第一次进演练台要先读声明、等倒计时、滚到文末并勾选三项。清洗不需要这一步。演练台只用于你有权管理的本机、离线靶标,或已经书面授权的演练环境。 + +**清洗** + +![清洗](docs/preview/dock-clean.png) + +**演练台授权** + +![演练台授权](docs/preview/dock-auth.png) + +**演练台** + +![演练台](docs/preview/dock-drill.png) + +**补丁** + +![补丁](docs/preview/settings.png) + +**规则集** + +![规则集](docs/preview/rules.png) + +**本地靶场** + +开源版只放行本机/内网。清洗页会写明这一点,不能再登记公网服务器。 + + +| 区域 | 说明 | +| --------- | ---------------------------------------------------------------------------------------------------------- | +| dsh-purge | 会话标题旁的按钮,打开或收起右侧栏 | +| 清洗 | 补丁、提示词、规则集 | +| 演练台 | 授权后的资产与环境页。开源版不下发技能库和知识库。未授权时按钮标「未授权」 | +| 补丁 | 分组查看状态,应用、还原或卸载 | +| 提示词 | 编辑 `prompt-inject.md`,作为会话覆盖段 | +| 本地靶场 | 只放行本机/内网和 `.lab` / `.lan` / `.htb` 等本机实验室后缀,不能登记公网主机 | +| 规则集 | 多套 `AGENTS.md` / `CLAUDE.md`;启用写入 `$DSH_HOME`,删除从列表去掉 | + + +--- + +## 目录结构 + +`bin/` 是命令行,`lib/` 是补丁和演练台,`presets/redteam/` 是红队预设,`client.js` 是侧栏。开源版不下发 `skills/` 和知识库。 + +运行时文件在 `$DSH_HOME`:`prompt-inject.md`、`rules/`、`redteam/`。未设 `DSH_HOME` 时,优先用 dsh 安装目录旁边的 `.dsh`,再退回 `~/.dsh`。Web 和桌面端各用自己那一份主目录。 + +--- + +## 使用 + +```sh +# CLI +dsh-purge --status +dsh-purge --apply +dsh-purge --revert +dsh-purge --uninstall +dsh-purge --edit + +# 聊天 +/purge status | apply | revert | uninstall | edit | help +/rules list | use | create | delete | reset | help +/rewind + +# 模型工具 +purge_status purge_apply purge_revert +``` + +设置页「应用」成功后会自动重启,以加载已改的包文件;也可手动点「重启」。补丁标题下有正式版和测试版。测试版跟 `beta` 分支。点「切换到正式版」会装回 master,并清掉钉选。正式版里选旧版本会固定在该版本,要回到最新再点「更新」。旧的测试标签不再列出。 + +输入框旁的「回退一次」和「回退上一轮」都留在当前这条对话里,不另开分支。已发送的那句会回到输入框,这一轮已经发出的内容和已完成的任务会从当前对话撤掉,改字后**重新发送**即可。从 **1.1.61** 起,多轮对话后回退按**当前这一轮**定位,不会又退到第一条用户消息。聊天里 `/rewind` 同样可用。 + +**极简 / PTC 与标准不一致时**:同一任务在标准模式能跑、在极简或 PTC 被拦,通常是 preset 里 `run_code` 或 plan 拦截句没洗净,或内置 minimal 缺少 `agent-instructions`。请升到 **1.1.61+**,**完全退出宿主 → 清洗里应用 → 自动重启 → 新开一轮对话** 再试;只换 preset 不重应用,旧进程里的补丁不会更新。 + +### 响应速度 + +DSH 会按顺序执行同一批中的 Bash 调用。旧版 dsh-purge 把默认前台等待从 60 秒提高到了 10 分钟,一条慢命令会让后续调用一起等待。补丁 #21 现在把带插件标记的旧值恢复为官方的 60 秒。无标记的等待时间和其他自定义值会保留;无标记的 10 分钟无法可靠地区分是插件旧值还是用户设置。 + +在 macOS/Linux 的 `dsh web` 中,如需更快返回工具结果,可在 `$DSH_HOME/profiles/web/cordis.patch.yml`(通常是 `~/.dsh/profiles/web/cordis.patch.yml`)添加下面的条目。已有相同 id 时修改原条目,不要重复添加。这个 profile 覆盖层在内置配置之后加载,后续应用插件会保留它。 + +```yaml +- id: bash-sandbox + config: + timeoutMs: 10000 +``` + +使用标准 jobs 服务且 `promoteOnTimeout: true` 时,未完成的命令会在 10 秒后返回后台任务 id,并继续运行。用 `job_output` 读取结果、用 `job_kill` 停止。这个设置缩短前台等待,不会让命令本身跑得更快。没有 jobs 服务或关闭自动转后台时,超时会终止命令。单次调用传入的 `timeoutMs` 优先于这里的默认值;`run_in_background: true` 可立即返回任务 id。 + +日常任务还可以选择让新会话默认使用 Low 推理: + +```yaml +- id: agent-default-model + config: + provider: deepseek-official + model: deepseek-flash + reasoningEffort: low +``` + +重启 `dsh web` 后加载配置。模型和推理等级默认值只用于新建会话,已有会话请在输入框中选择 Low。插件不会自动修改这些模型偏好。需要深入推理的任务可保留 High;搜索文件时应限定目录,避免递归扫描所有应用目录。 + +### 本地靶场 + +开源版攻击任务默认全拦,只放行本机/内网本地靶场。在对话里说「这是我的服务器」不会放行。登记公网 IP 或域名也不会。 + +--- + +## 本地校验 + +```sh +node --check lib/index.js +node --check lib/core.js +node --check lib/rewind.js +node --check lib/skills.js +node --check client.js +``` + +--- + +## 工作原理 + +应用,启动时或手动点「应用」: + +```mermaid +flowchart TD + A["启动或点应用"] --> B{"补丁已经生效?"} + B -->|是| C["跳过"] + B -->|否| D["备份原件为 .dshpurge.bak"] + D --> E["按补丁列表替换对应文件"] + E --> F["覆盖 shim"] + F --> G["注入插件加密默认(提示词框默认显示的那份)"] +``` + + + +每次会话的覆盖: + +```mermaid +flowchart TD + A["重启或新会话"] --> B["注入插件加密默认,不必点保存,不读宿主磁盘"] +``` + + + +每一轮的提示词注入与规则集决策。左边三个输入就是你在 dsh-purge 面板里控制的东西,虚线表示它们各自驱动哪一步判定: + +```mermaid +flowchart TD + L["规则库:启用某个规则集 → 写入 prompt-inject.md"] + W["本地规则:勾选当前目录的 AGENTS.md / CLAUDE.md"] + P["工作区策略:strict / fallback / always"] + + A["新一轮开始"] --> B{"prompt-inject.md 非空?"} + B -->|是| C["注入 = prompt-inject.md (A)"] + B -->|否| D{"当前目录有被勾选且非空的本地 md?"} + D -->|是| E["注入 = 被勾选的 workspace md (B)"] + D -->|否| F["注入 = 插件加密 slot 默认值 (D)"] + C --> G{"Workspace baseline 策略 (C)"} + E --> G + F --> G + G -->|strict| H["去掉 DSH workspace baseline 段"] + G -->|fallback 默认| I{"上面有用户注入内容? (A 或 B)"} + G -->|always| J["保留 DSH workspace baseline 段"] + I -->|是| H + I -->|否| J + H --> K["最终系统提示词"] + J --> K + + L -.-> B + W -.-> D + P -.-> G +``` + + + +--- + +## 还原 + +- 每个目标文件在应用前备份为 `<文件>.dshpurge.bak`。 +- 「还原」或 `/purge revert` 用备份覆盖回去并删除备份;没有备份时去掉 shim 里由本插件写入的行。 +- `prompt-inject.md` 是用户文件,还原时保留。 +- 「卸载」会先还原(若已应用),再删除注入文件、规则库和插件本身。 +- 重复应用是幂等的。 + +--- + +## 路径探测 + +宿主面先判断 `web` / `desktop`(预留 `gui` / `tui`,尚未单独适配时回退 web)。 + +**Web:** + +1. `DSH_HOME` / `DSH_BASE` +2. dsh 启动器旁的 `.dsh` +3. `npm prefix -g` / `npm root -g` +4. 嵌套 `@deepseek-ai/dsh/node_modules/@deepseek-ai` +5. 系统默认 `~/.dsh` + +**官方桌面:** 只改当前正在运行的官方桌面安装。点「应用」成功后会自动重启一次。社区桌面端不维护。 + +找不到目标时提示设置 `DSH_BASE`,不改文件。 + +--- + +## 更新 + +作者个人频道:[t.me/LLM_PoJia](https://t.me/LLM_PoJia)。有话、有更新会发在那里。GitHub [Releases](https://github.com/YuJunZhiXue/dsh-purge/releases) 仍可下历史包。原版不允许倒卖;二开必须署名。 + +## 说明 + +- 改动范围是本机 `@deepseek-ai/*` 包里的渲染文案、默认策略和执行逻辑,以及用户目录下的覆盖文件与规则集。 +- 升级后原文对不上会显示跳过,这次应用仍算完成,不会乱改。 +- 不改动非 `@deepseek-ai` 的第三方插件源仓库(启动时的 CMD 无感会**尽力**修补已装的 doctor / market / 梁神 / mnemon,属运行时补丁)。 +- 安装走 GitHub 归档(npm 上没有这个包):`dsh plugin --profile web add https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz`(桌面端把 profile 换成 `desktop`)。当前目录已是本仓库时 `add .`。 + +--- + +感谢 [LINUX DO](https://linux.do) 社区 diff --git a/bin/dsh-purge.js b/bin/dsh-purge.js new file mode 100644 index 0000000..a6f5c30 --- /dev/null +++ b/bin/dsh-purge.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import * as core from "../lib/core.js"; +import * as rules from "../lib/rules.js"; + +const args = process.argv.slice(2); + +function printStatus(state) { + const out = []; + out.push("dsh-purge 状态 / Status"); + out.push(" 宿主 / surface " + (state.surface || "web")); + out.push(" DSH_HOME " + state.dsh_home); + if (state.desktop_install) out.push(" 桌面安装 " + state.desktop_install); + if (state.ai_base) { + out.push(" 插件根 / root " + state.ai_base); + for (const [key, fp] of Object.entries(state.files)) { + out.push(` ${key.padEnd(22)} ${fp}`); + } + } else { + out.push(" 插件根 / root " + core.missingAiBaseMessage()); + } + out.push(` shim 目录 ${state.shim_dir || "未定位 / not found"}`); + out.push(` 备份 / backup ${state.has_backup ? "有 / yes" : "无 / no"}`); + out.push(` 注入文件 ${state.override_path}`); + out.push(` 注入状态 ${state.override_status}`); + out.push(""); + out.push(` 补丁 ${state.patches_applied}/${core.ALL_PATCHES.length} 已清洗, ${state.patches_pending} 待清洗`); + for (const p of core.ALL_PATCHES) { + const s = state.patch_status[p.id]; + const mark = s === "applied" ? "✓" : s === "pending" ? "✗" : "·"; + out.push(` ${mark} [#${String(p.id).padEnd(2)}] ${p.name.padEnd(30)} ${p.layer}/${p.layer_en} — ${s}`); + } + out.push(""); + out.push(` shim: dsh.cmd=${state.shim_cmd} dsh.ps1=${state.shim_ps1} dsh=${state.shim_bin}`); + return out.join("\n"); +} + +async function main() { + const mode = args.find((a) => ["--apply", "--revert", "--uninstall", "--status", "--edit", "--help"].includes(a)); + + if (mode === "--help" || args.includes("-h")) { + console.log(`dsh-purge 用法: + dsh-purge --status 显示状态 + dsh-purge --apply 应用全部清洗(提示词+代码+shim+override) + dsh-purge --revert 回滚还原 + dsh-purge --uninstall 卸载插件(已应用则先还原) + dsh-purge --edit 编辑注入文件 prompt-inject.md + dsh-purge --help 帮助`); + return; + } + + const state = await core.gatherState(); + console.log(printStatus(state)); + console.log(""); + + if (mode === "--apply") { + if (!state.ai_base) { + console.log("[ERROR] " + core.missingAiBaseMessage()); + console.log(" fix: 把 DSH_BASE 指到含 dsh-agent-instructions/lib 的 @deepseek-ai 目录"); + process.exit(1); + } + const inject = rules.resolveInjectText(state.dsh_home); + if (!inject.text) { + console.log("[ERROR] 提示词和规则集都是空的,必须先添加提示词"); + process.exit(1); + } + const { hosts, report } = await core.applyPatchesAllHosts(core.ALL_PATCHES, { + preserveClientBundles: false, + forApply: true, + }); + for (const h of hosts) console.log(` 宿主 / host → ${h.aiBase}`); + for (const r of report) { + const m = r.status === "applied" ? "✓ 已清洗" : r.status === "already" ? "- 已是最新" : r.status === "missing_file" ? "⚠ 文件缺失" : `✗ ${r.status}`; + console.log(` ${m} patch #${String(r.patch_id).padEnd(2)} ${r.name}`); + } + const flashHost = hosts[0]?.aiBase || state.ai_base; + const flash = core.silenceCmdFlash(flashHost); + console.log(` cmd-flash=${flash.entry} phase-1=${flash.phase1} ok=${flash.ok}`); + if (hosts.length > 1) { + console.log(` 多宿主 / multi-host: ${hosts.length} 份 @deepseek-ai 已清洗`); + } + console.log(inject.source === "rule" + ? " - 使用当前规则集,不改写提示词文件" + : " - 使用已有提示词文件"); + if (state.shim_dir) { + const r = await core.patchShim(state.shim_dir); + for (const [fname, st] of Object.entries(r)) { + if (st === "patched") console.log(` ✓ shim ${fname} 注入 / injected`); + else if (st === "already_patched") console.log(` - shim ${fname} 已注入 / already injected`); + else if (st === "missing") console.log(` - shim ${fname} 不存在 / missing`); + else console.log(` ⚠ shim ${fname}: ${st}`); + } + } else { + console.log(" ⚠ 未定位 shim 目录,跳过注入"); + } + console.log(""); + console.log("全部完成 / All done。重启 dsh 生效 / Restart dsh to take effect."); + } else if (mode === "--revert") { + if (state.ai_base || core.enumeratePatchAiBases().length) { + const { reverted, errors } = await core.revertAllHosts(); + for (const p of reverted) console.log(` ✓ 已还原 / Reverted ${p}`); + for (const [p, e] of errors) console.log(` ⚠ 还原失败 ${p}: ${e}`); + if (reverted.length === 0) console.log(" - 没有补丁备份可还原 / no patch backup"); + } + if (state.shim_dir) { + const r = await core.revertShim(state.shim_dir); + for (const [fname, st] of Object.entries(r)) { + if (st === "reverted" || st === "stripped_inject") console.log(` ✓ shim ${fname}: ${st}`); + else console.log(` - shim ${fname}: ${st}`); + } + } + console.log(""); + console.log("回滚完成 / Revert done。重启 dsh 后恢复 / restart to restore."); + console.log("注: prompt-inject.md 保留(用户文件)/ prompt-inject.md kept (user file)"); + } else if (mode === "--uninstall") { + const { uninstallPurge } = await import("../lib/uninstall.js"); + const result = await uninstallPurge(); + if (result.applied) console.log(" 已检测到补丁,已还原回原版。"); + else console.log(" 未检测到已应用补丁,仍会清除插件文件。"); + if (result.override?.removed) console.log(` 已删除 ${result.override.path}`); + if (result.stripped?.length) console.log(` 已从 profile 移除: ${result.stripped.join(", ")}`); + for (const e of result.errors || []) console.log(` ⚠ ${e}`); + if (!result.ok) { + console.log(" 卸载中止:还原失败,插件文件未删除。"); + process.exit(1); + } + console.log(""); + console.log("卸载完成。请重启 dsh。"); + } else if (mode === "--edit") { + const r = core.editOverride(state.dsh_home); + if (!r.ok && r.needCreate) { + await core.installOverride(state.dsh_home, true); + console.log(` ✓ 已创建默认注入文件:${state.override_path}`); + core.editOverride(state.dsh_home); + } else { + console.log(` → 已打开 ${r.editor} 编辑注入文件:${r.path}`); + } + console.log(" 编辑完成后重启 dsh 生效。"); + } else { + console.log("默认不清洗,使用 --apply 应用清洗。"); + } +} + +main().catch((e) => { + console.error("[dsh-purge] error:", e); + process.exit(1); +}); diff --git a/client.js b/client.js new file mode 100644 index 0000000..f805dab --- /dev/null +++ b/client.js @@ -0,0 +1,8786 @@ +window.__ModuleLoader__.load({ id: "dsh-purge", factory: (require) => { + + var module = { exports: {} }; + var exports = module.exports; + Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); + let react = require("react"); + const h = react.createElement; + const { useState, useEffect, useCallback, useRef, Component } = react; + + const name = "dsh-purge"; + const inject = ["slots", "locale", "uiConversation"]; + const NS = "settings.dsh-purge"; + let translate = (key) => key; + function useT() { + return translate; + } + function clientGuessSurface() { + try { + if (typeof navigator !== "undefined" && /electron/i.test(navigator.userAgent || "")) return "desktop"; + if (typeof location !== "undefined") { + const port = String(location.port || ""); + if (port === "43120") return "desktop"; + const host = String(location.hostname || ""); + if (host && host !== "127.0.0.1" && host !== "localhost" && /dsh desktop/i.test(String(location.href || ""))) return "desktop"; + } + } catch { /* ignore */ } + return ""; + } + function hostSurfaceOf(s) { + return (s && s.surface) || clientGuessSurface() || "web"; + } + function hostText(t, key, surface) { + if (surface && surface !== "web") { + const specific = key + "." + surface; + const hit = t(specific); + if (hit && hit !== specific) return hit; + } + return t(key); + } + function apiUrl(p) { + try { return new URL(p, window.location.origin).toString(); } catch { return p; } + } + function apiTimeoutMs(p, init) { + const path = String(p || ""); + const method = String((init && init.method) || "GET").toUpperCase(); + if (path.indexOf("/dsh-purge/update") !== -1) return method === "POST" ? 180000 : 45000; + if (path.indexOf("/dsh-purge/uninstall") !== -1) return 90000; + if (path.indexOf("/dsh-purge/skill") !== -1) return method === "POST" ? 120000 : 20000; + // status 会在"应用"期间被后端的同步落盘阻塞,默认 20s 必然被 abort,界面只会显示"读取失败: timeout"。 + if (path.indexOf("/dsh-purge/status") !== -1) return 60000; + if (path.indexOf("/dsh-purge/apply") !== -1) return 240000; + if (path.indexOf("/dsh-purge/revert") !== -1) return 120000; + if (path.indexOf("/dsh-purge/restart") !== -1) return 60000; + return 20000; + } + function isAbortError(e) { + const msg = String((e && e.message) || e || ""); + return (e && e.name === "AbortError") || /aborted|abort/i.test(msg); + } + async function apiJson(p, init) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), apiTimeoutMs(p, init)); + try { + const r = await fetch(apiUrl(p), Object.assign({ cache: "no-store", credentials: "same-origin", signal: ctrl.signal }, init || {})); + const text = await r.text(); + let data; + try { data = text ? JSON.parse(text) : null; } catch { + throw new Error(r.status + " non-json: " + text.slice(0, 120)); + } + if (!r.ok) throw new Error((data && (data.error || data.message)) || (r.status + " " + r.statusText)); + return data; + } catch (e) { + if (isAbortError(e)) throw new Error("timeout"); + const msg = String((e && e.message) || e || ""); + if (/failed to fetch|networkerror|load failed/i.test(msg)) { + throw new Error("Cannot reach the API. Make sure dsh is still running, then press Ctrl+F5"); + } + throw e; + } finally { clearTimeout(timer); } + } + + function promptBoxEmpty(text) { + return !String(text || "").trim(); + } + + function responseNeedsPrompt(d) { + return Boolean(d && (d.needPrompt || d.injectSource === "none")); + } + + function warnNeedPrompt(d, tr) { + if (!responseNeedsPrompt(d)) return false; + window.alert(tr("need.prompt")); + return true; + } + + async function bothInjectEmpty(overrideText, defaultText) { + if (!promptBoxEmpty(overrideText)) return false; + if (!promptBoxEmpty(defaultText)) return false; + return true; + } + + function rulesApi(op, extra) { + return apiJson("/dsh-purge/rules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(Object.assign({ op: op }, extra || {})), + }); + } + + function skillsRouteMissing(e) { + return /\b404\b|\b405\b|non-json/i.test(String((e && e.message) || e || "")); + } + + function skillsApi(op, extra) { + const init = { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(Object.assign({ op: op }, extra || {})), + }; + return apiJson("/dsh-purge/skills", init).catch((e) => { + if (!skillsRouteMissing(e)) throw e; + return apiJson("/dsh-purge/skill", init); + }); + } + + const TARGETS = ["AGENTS.md", "CLAUDE.md"]; + + const zh = { + nav: "规则设定", + "dock.clean": "清洗", + "dock.drill": "演练台", + "dock.toggle": "dsh-purge", + "dock.newSession": "dsh-purge", + "dock.inSession": "dsh-purge", + "dock.redteam": "红队模式", + "dock.full": "全面浏览", + "dock.close": "收起", + "dock.unauthorized": "未授权", + "welcome.title": "用户须知", + "welcome.lead": "首次安装本插件时请阅读以下说明。确认后本窗不再弹出。", + "welcome.warn": "本插件免费开源。任何背着作者私自售卖、付费倒卖或借本项目盈利的行为,作者将依法追究法律责任。", + "welcome.body": "dsh-purge 是 DeepSeek Harness 的本机网络安全红队评测与清洗插件:在受控本机环境中调整提示词与补丁、做授权范围内的演练台操作。项目免费、非营利,源码仓库:", + "welcome.repo": "https://github.com/YuJunZhiXue/dsh-purge", + "welcome.ban1": "禁止将本项目用于未授权的网络攻击、入侵、窃取数据、破坏系统或任何违法犯罪。", + "welcome.ban2": "禁止商业售卖、付费倒卖、黑灰产牟利,以及隐瞒作者的营利性再分发。", + "welcome.ban3": "使用者须自行遵守所在地法律法规与平台规范;违法后果由行为人自行承担,与作者无关。", + "welcome.bans": "禁止事项", + "welcome.check": "我已阅读并理解上述用户须知,同意合法合规使用。", + "welcome.ok": "我已知晓并继续", + "auth.title": "演练台授权确认", + "auth.lead": "清洗不需要这一步。第一次进入演练台,或打开全面浏览,都要先完成本窗。", + "auth.warn": "安全警告:演练台只用于你有权管理的本机、离线靶标,或已书面授权的演练环境。未经授权的渗透、攻击、窃取和破坏一律禁止。", + "auth.hint.wait": "请阅读声明。{n} 秒后才能勾选。", + "auth.hint.scroll": "倒计时已结束。请把声明滚到文末,再勾选。", + "auth.hint.check": "倒计时已结束。请勾选全部三项。", + "auth.check.read": "我已读完《安全与禁止违法声明》,并认可其中的全部条款。", + "auth.check.scope": "我确认只用在本机已授权环境、离线靶标或合规演练中。", + "auth.check.ban": "我确认禁止任何违法用途。未完成本授权,不得使用演练台。", + "auth.no": "暂不进入", + "auth.ok": "确认授权", + "auth.ok.tab": "确认授权并进入演练台", + "auth.ok.full": "确认授权并全面浏览", + "auth.ok.env": "确认授权并打开环境适配", + "envGate.title": "环境还没配好", + "envGate.lead": "红队模式发送前需要本机工具。未检测到可用环境时,先去环境适配,或确认暂不配置。", + "envGate.kaliHint": "检测到 Kali 时会直接用本机工具,不必填路径。没有 Kali 时,在环境适配里填写各工具路径,或选一个整包文件夹自动分配。", + "envGate.skip": "暂不配置", + "envGate.skipConfirm": "确认后这次可以继续对话。扫描和利用类技能仍会因为缺少工具而不可用,之后可以在 dsh-purge → 环境适配里补路径。", + "envGate.skipNo": "返回", + "envGate.skipYes": "确认暂不配置", + "envGate.go": "去环境适配", + "theme.aria": "外观", + "theme.white": "白", + "theme.ink": "墨", + "purge.title": "补丁", + "override.title": "提示词", + "own.title": "本地靶场", + "own.hint": "开源版攻击任务只放行本机/内网本地靶场(localhost、私网、.lab/.lan/.htb 等)。不能登记公网服务器,公网 IP 和域名一律拦截。", + "own.save": "保存名单", + "saved.own": "已保存自己的服务器", + "saved.own.dropped": "已保存。有 {n} 行不是单个 IP 或主机名,已丢掉", + "metric.purged": "已写入", + "metric.purged.skip": "已写入。跳过 {n} 项,本机没有这些文件", + "metric.pendingShort": "待处理 {n}", + "metric.unmatchedShort": "没对上 {n}", + "metric.skipShort": "跳过 {n}", + "metric.unlocated.count": "—", + "metric.shim": "shim", + "metric.shim.na": "本端不需要", + "metric.bak.yes": "有备份", + "metric.bak.no": "无备份", + "metric.bak.hint": "备份", + "table.patch": "项目", + "table.status": "状态", + "status.applied": "已应用", + "status.off": "已关闭", + "status.pending": "待应用", + "status.unmatched": "没对上", + "status.skipped": "跳过", + "apply.hint": "没改提示词框不用点保存,默认就是这份,启动和应用会写进 prompt-inject.md。只有自己改过再点保存才会覆盖。点应用打补丁。待应用=原文还在。跳过=当前版本不需要或组件未安装。", + "warn.noRoot": "未定位到当前宿主的 @deepseek-ai,清洗不会生效。请完全退出后再打开本宿主,在本页点「应用」。桌面端安装目录可以是任意盘符;目录名带 DeepseekHarness / DeepSeek Harness 均可。", + "warn.noRoot.desktop": "官方客户端的代码在 app.asar 里。点「应用」会解开并自动重启,不用另跑脚本。若一直显示还没定位,先完全退出客户端再开,再点「应用」。第三方 DSH Desktop 直接点「应用」。", + "unpack.restarting": "补丁已写入。点「重启」后客户端会自己重新打开。", + "restart.fullQuit": "正在重启,客户端会自己重新打开。", + "warn.noInject": "官方始终用默认提示词。红队开了规则集就只用规则集;没开时用去掉 CTF 的默认提示词,再接红队操作。两边都空会提示必须添加。", + "need.prompt": "提示词和规则集都是空的,必须先添加提示词,或启用一条有内容的规则集。", + "btn.restoreInject": "恢复默认", + "saved.restoreInject": "已恢复默认提示词", + "skip": "跳过", + "unknown": "未知", + "delete": "删除", + "btn.apply": "应用", + "btn.apply.busy": "处理中…", + "btn.revert": "还原", + "btn.uninstall": "卸载", + "btn.uninstall.busy": "卸载中…", + "uninstall.title": "卸载 dsh-purge", + "uninstall.body": "是否卸载?卸载将还原回原版,并清除本插件的全部文件与补丁。", + "uninstall.confirm": "确认卸载", + "uninstall.cancel": "取消", + "uninstall.applying": "正在卸载并还原…", + "uninstall.done": "已卸载并还原,正在重启…", + "uninstall.done.desktop": "已卸载并还原,正在重启桌面应用…", + "uninstall.fail": "卸载失败: {error}", + "btn.checkUpdate": "检测更新", + "btn.checkUpdate.busy": "检测中…", + "btn.doUpdate": "更新", + "btn.doUpdate.busy": "更新中…", + "channel.title": "通道", + "channel.stable": "正式版", + "channel.beta": "测试版", + "channel.useStable": "切换到正式版", + "channel.useBeta": "切换到测试版", + "channel.local": "本机", + "update.checking": "正在检测更新…", + "update.applying": "正在更新…", + "update.switching": "正在切换版本…", + "update.latest": "已是最新({version})", + "update.available": "有新版本 {remote},当前 {local}", + "update.pinned": "已固定在 {version},通道最新是 {remote}", + "update.done": "已更新到 {version}", + "update.restarting": "已还原并写入新补丁,正在重启…", + "update.fullQuit": "补丁已写入。点「重启」后客户端会自己重新打开。", + "restart.official.title": "需要重启", + "restart.official.body": "补丁已写入。点重启后客户端会自己重新打开。", + "update.switched": "已切换到 {version}", + "update.autoDone": "已自动更新到 {version}", + "update.reloading": "正在刷新设置页…", + "update.dirty": "有新版本,本地有改动未自动覆盖", + "update.fail": "更新失败: {error}", + "update.timeout": "检测超时,请再点一次检测更新", + "update.needRestart": "检测接口未加载,请先重启 dsh 再点检测更新", + "update.noBeta": "还没有测试版", + "update.switch": "切换到此版本", + "update.pick": "选择版本", + "update.tip": "最新", + "update.current": "当前", + "update.confirmSwitch": "切换到 {version}?之后可再切回另一通道或其它版本。本地未提交的插件改动不会保留。", + "update.confirmChannel": "切换到{version}?会按该通道重新安装插件,本地未提交的插件改动不会保留。", + "metric.version": "版本", + "action.apply": "应用", + "action.revert": "还原", + "action.uninstall": "卸载", + "ok.done": "已完成", + "err.action": "失败: {error}", + "err.status": "读取失败: {error}", + "err.override": "读取失败: {error}", + "err.rules": "读取失败: {error}", + "err.read": "读取失败: {error}", + "err.save": "保存失败: {error}", + "btn.saveInject": "保存", + "saved.override": "已保存", + "rules.title": "规则集", + "rules.none": "无", + "rules.reset": "还原", + "rules.reset.confirm": "还原全局指令文件?规则库会保留。", + "rules.empty": "暂无规则", + "rules.pick": "选择规则", + "policy.title": "工作区策略", + "policy.strict": "严格", + "policy.fallback": "空位兜底", + "policy.always": "总是允许", + "policy.hint": "严格:永远剥 AGENTS.md / CLAUDE.md;空位兜底:提示词框为空时才用;总是允许:保留 DSH 原生行为", + "cwd.title": "本地规则(当前目录)", + "cwd.empty": "当前目录没有 AGENTS.md / CLAUDE.md", + "cwd.rescan": "重新扫描", + "cwd.hint": "只有提示词框为空时,被勾选的本地文件才作为兜底注入;提示词框一旦非空就不会用这些文件。", + "cwd.scope.cwd": "当前目录", + "cwd.scope.ancestor": "上级", + "cwd.scope.root": "项目根", + "cwd.no.workspace": "DSH 尚未选过工作目录,请先在 DSH 里开始一次任务再回来。", + "pill.current": "当前", + "btn.use": "启用", + "btn.inUse": "当前", + "confirm.delete": "删除 {id}?", + "ph.id": "id", + "ph.alias": "名称", + "ph.alias.short": "名称", + "btn.create": "新建", + "need.id": "需要 id", + "ph.content": "", + "btn.saveRule": "保存", + "skills.title": "Skill", + "skills.hint": "导入到官方目录后,由 DSH 调用:聊天输入 /名称 立即加载;任务对上 description 或 whenToUse 时模型会调 skill 工具。正文写「激活」不会触发。", + "skills.hint.desktop": "当前桌面宿主的官方 Skill 目录。聊天输入 /名称 立即加载;任务对上 description 时模型会调 skill 工具。", + "skills.empty": "暂无用户 Skill", + "btn.importZip": "导入压缩包", + "btn.importFolder": "导入文件夹", + "skills.importing": "正在导入…", + "saved.import": "已导入 {count} 个 Skill", + "need.import": "请选择压缩包或文件夹", + "err.import.folder": "没读到文件夹里的文件,请直接选 Skill 目录(里面要有 SKILL.md)", + "skills.pick": "选择 Skill", + "skills.invalid": "官方会忽略:格式不对", + "skills.call.slash": "聊天输入 {cmd}", + "skills.call.model": "模型可调", + "skills.call.modelOff": "模型不可调", + "skills.call.userOff": "不可 /名称", + "ph.skill.desc": "description(何时用)", + "btn.saveSkill": "保存", + "need.skill.id": "id 必须是 kebab-case,例如 code-review", + "confirm.delete.skill": "从官方 $DSH_HOME/skills 删除 {id}?宿主会自动从目录拿掉。", + "err.skills": "读取失败: {error}", + "err.skills.needRestart": "Skill 接口未加载,请先重启当前宿主", + "btn.restart": "重启", + "btn.restart.busy": "重启中…", + "restarting": "重启中…", + "restarting.desktop": "正在重启桌面应用…", + "restart.timeout": "超时,请刷新", + "restart.fail": "失败: {error}", + "restart.notStarted": "重启没有开始,客户端还在。不会自动再试。", + "restart.already": "这一次重启已经开始,不会再重启一次。", + "restart.confirm": "清洗已完成,重启后生效。", + "restart.confirm.desktop": "清洗已完成,重启桌面应用后生效。", + "restart.ask.title": "要现在重启吗", + "restart.ask.body": "已经写入。重启后才会在当前客户端里生效。", + "restart.now": "现在重启", + "restart.notNow": "稍后", + "restart.asarSwap": "补丁已写入磁盘。正在结束桌面进程并挪开 app.asar,完成后会自动重新打开;若仍无效,请先完全退出 Harness(托盘也关),再点一次应用。", + "surface.web": "Web", + "surface.desktop": "桌面端", + "surface.gui": "GUI", + "surface.tui": "TUI", + "restart.incomplete": "提示词已写入。{detail}", + "restart.incomplete.patches": "还有 {failed} 项代码补丁没对上当前版本(#{ids}),不影响这段提示词", + "restart.incomplete.flash": "CMD 无感未钉入({entry})", + "restart.later": "以后", + "group.prompt": "提示词", + "group.code": "代码", + "group.engine": "引擎", + "group.tools": "工具", + "group.compat": "0.1.5 兼容", + "group.dsh20": "0.2.0", + "patch.1": "全局免责 → 强指令", + "patch.2": "替换式免责 → 强指令", + "patch.3": "作用域免责 → 强指令", + "patch.4": "persona 会话强指令", + "patch.5": "审批提示句 → 全权限", + "patch.6": "沙箱 deny → allow 写", + "patch.7": "沙箱默认全权限", + "patch.8": "审批默认 never", + "patch.9": "审批门自动放行", + "patch.10": "审批配置默认 never", + "patch.11": "never 提示句 → 自动放行", + "patch.12": "ask 提示句 → 自动放行", + "patch.13": "豁免升级阶梯", + "patch.14": "升级无条件授信", + "patch.15": "沙箱 confine 直通", + "patch.16": "文件系统围栏取消", + "patch.17": "观察策略读写放行", + "patch.18": "重复调用守卫禁用", + "patch.19": "工具结果修剪禁用", + "patch.21": "恢复插件延长的 bash 等待", + "patch.22": "read 上限放宽", + "patch.23": "子代理深度 3 → 10", + "patch.24": "preset fetch 启用", + "patch.25": "去掉官方默认身份(功能提示词不动)", + "patch.26": "minimal:去掉默认身份并打开 inject", + "patch.28": "dsh-liangshen(梁神):phase-1 保留 dsh-purge / dshpg 段", + "patch.70": "极简模式终端不再禁止网络", + "patch.71": "梁神去掉官方身份句", + "patch.29": "dsh-tool-web:外部 untrusted 拦截→可执行", + "patch.30": "dsh-hooks-claude-code:deny→allow", + "patch.31": "dsh-hooks-codex:deny→allow", + "patch.32": "Web 表面身份中性化", + "patch.33": "Shell 拒绝提示 → 可执行", + "patch.34": "沙箱提示中性化", + "patch.35": "升级 never 不拒", + "patch.36": "文件系统升级 schema 放行", + "patch.37": "子代理 scope lock 中性化", + "patch.38": "persona text→prefix(0.1.2 预设)", + "patch.39": "会话 v0 plugin summary(mnemon)", + "patch.40": "complete 预设仍保留注入", + "patch.42": "设置页旧接口", + "patch.43": "网页预设去掉默认身份", + "patch.45": "官方桌面更新交接", + "patch.46": "官方更新脚本只用英文", + "patch.47": "OAuth 不再插入 Claude 身份", + "patch.48": "shell 去掉删除前的额外确认", + "patch.49": "OAuth 不再伪装 claude-cli", + "patch.50": "跨会话引用不再标不可信", + "patch.51": "新会话保持完全权限", + "patch.52": "会话日志默认不上传", + "patch.53": "网页提示去掉禁止另开服务器", + "patch.54": "交付物提示去掉多余禁止", + "patch.55": "每一步替换第一条系统提示", + "patch.56": "回退只藏被撤掉的那一轮", + "patch.57": "developer role 仅限 openai/azure,第三方代理回退 system", + "patch.58": "回退空标记不进模型请求", + "patch.59": "官方 Auto Review 不再拦工具", + "patch.60": "官方 run_code 去掉审批教学", + "patch.61": "官方沙箱策略不再教拒绝", + "patch.62": "无审批通道时放行(用户拒绝仍有效)", + "patch.63": "计划模式不再软拦执行", + "patch.64": "Stop hook 不再注入假用户话", + "patch.65": "UserPromptSubmit deny 不再整轮 reject", + "patch.66": "空的系统段不再把这一轮打成内部错误", + "patch.67": "缺 messages 时这一轮不中断", + "patch.68": "回退后缺 content 不中断", + "patch.69": "deepseek-flash 保持官方 in-history", + "patch.72": "Messages 的 system 用当前系统提示", + "patch.73": "极简 bash 去掉误留的镜像说明", + "patch.74": "启动/应用把默认提示词写入 prompt-inject.md", + "patch.75": "回退把已完成任务收回成未开始", + "patch.76": "unrestricted 预设去掉身份句", + "patch.77": "回退标记到达时丢弃已发送消息", + "patch.78": "补丁文件缺失时从 .bak 恢复", + "patch.80": "恢复官方极简工具表", + "patch.82": "主进程兜底 DSH_PROFILE=desktop", + "patch.83": "host 信号杀时不报错不弹对话框", + "patch.84": "host code=0 正常退出不报错 (消除 stopped 对话框)", + "patch.85": "prompt() 的 steer 仍走插话,不压成 followup", + "patch.86": "队列插话走 steer;公共 steer() 保持下一步", + "patch.87": "首次 assemble 用 prepare 返回的当前模式 systemPrompt", + "patch.88": "去掉 renderPrompt 后的挂针,注入只走 assemble", + "status.unlocated": "未定位", + "status.na": "本端不需要", + "metric.unlocated": "还没定位到本机 Harness", + "metric.sealed": "已找到官方客户端,点应用解开", + "metric.sealed.count": "待解开", + "metric.lost": "补丁已丢失,请重新应用", + "metric.lost.count": "已丢失", + "rewind.label": "回退", + "rewind.aria": "回退", + "rewind.busy": "回退中…", + "rewind.empty": "没有可回退的上一句", + "rewind.fail": "回退失败: {error}", + "rewind.once": "回退上一步", + "rewind.once.hint": "撤回最后一步已进行的对话,新会话接上之前的上下文;那句回到输入框再发送", + "rewind.round": "回退上一轮", + "rewind.round.hint": "撤回上一整轮(含子代理)已进行的对话,新会话接上之前的上下文;那句回到输入框再发送", + "hooksDeny.title": "Hooks 拦截", + "hooksDeny.bypass": "放行 hooks 的 deny / ask(补丁 30 / 31 / 64 / 65)", + "hooksDeny.hint": "关掉后,用户自己的 PreToolUse / UserPromptSubmit / Stop hook 的 deny 会按官方逻辑生效。官方 ask 分支本身是空操作,关开关不会弹出审批框。改完后点「应用」并重启才写进宿主。", + "saved.hooksDeny": "已保存", + "continue.title": "失败重试 / 继续", + "continue.hint": "请求失败会自动重试;异常停止或中断可点「继续」或自动续跑。自己点停止不会自动继续。次数用完后需新开一轮。", + "continue.autoRetry": "失败自动重试", + "continue.retryMax": "重试次数", + "continue.autoContinue": "中断后自动继续", + "continue.continueMax": "继续次数", + "continue.text": "继续用语", + "saved.continue": "已保存", + "continue.label": "继续", + "continue.aria": "异常停止后继续", + "continue.busy": "继续中…", + "continue.ready": "继续(还可 {left} 次)", + "continue.limit": "已达继续上限", + "continue.fail": "继续失败: {error}", + }; + + const en = { + nav: "Rules", + "dock.clean": "Clean", + "dock.drill": "Drill", + "dock.toggle": "dsh-purge", + "dock.newSession": "dsh-purge", + "dock.inSession": "dsh-purge", + "dock.redteam": "Red team", + "dock.full": "Full view", + "dock.close": "Collapse", + "dock.unauthorized": "Unauthorized", + "welcome.title": "User notice", + "welcome.lead": "Please read this on first install. After you confirm, it will not appear again.", + "welcome.warn": "This plugin is free and open source. Anyone who sells, resells, or profits from it behind the author's back may face legal action.", + "welcome.body": "dsh-purge is a local DeepSeek Harness plugin for security evaluation and cleaning: adjust prompts and patches on a machine you control, and use the drill console only within authorized scope. It is free and non-profit. Repository:", + "welcome.repo": "https://github.com/YuJunZhiXue/dsh-purge", + "welcome.ban1": "Do not use this project for unauthorized attacks, intrusion, data theft, sabotage, or any illegal activity.", + "welcome.ban2": "Do not commercially sell, resell, or profit from gray-market use, or redistribute for profit without the author.", + "welcome.ban3": "You must follow local law and platform rules; illegal use is solely your responsibility.", + "welcome.bans": "Prohibitions", + "welcome.check": "I have read and understand this notice and will use the plugin lawfully.", + "welcome.ok": "I understand — continue", + "auth.title": "Drill console authorization", + "auth.lead": "Cleaning does not need this step. Opening Drill or Full view the first time requires this dialog.", + "auth.warn": "Warning: the drill console is only for hosts you manage, offline targets, or written authorized exercises. Unauthorized intrusion, attack, theft, or sabotage is forbidden.", + "auth.hint.wait": "Read the notice. You can check the boxes in {n}s.", + "auth.hint.scroll": "Countdown finished. Scroll to the end, then check the boxes.", + "auth.hint.check": "Countdown finished. Check all three boxes.", + "auth.check.read": "I have read the Safety and Anti-Abuse Notice and accept every clause.", + "auth.check.scope": "I will only use authorized local hosts, offline targets, or compliant exercises.", + "auth.check.ban": "I will not use this for any illegal purpose. Without this authorization the drill console stays closed.", + "auth.no": "Not now", + "auth.ok": "Authorize", + "auth.ok.tab": "Authorize and open Drill", + "auth.ok.full": "Authorize and open Full view", + "auth.ok.env": "Authorize and open Env adapt", + "envGate.title": "Environment is not ready", + "envGate.lead": "Red team mode needs local tools before you send. If none are available, open Env adapt or confirm you will continue without them.", + "envGate.kaliHint": "When Kali is detected, local tools are used directly and paths are optional. Otherwise set each tool path in Env adapt, or pick one toolkit folder to assign them.", + "envGate.skip": "Skip for now", + "envGate.skipConfirm": "You can keep chatting after this. Scan and exploit skills stay unavailable until the tools are installed. You can add paths later under dsh-purge → Env adapt.", + "envGate.skipNo": "Back", + "envGate.skipYes": "Continue without tools", + "envGate.go": "Open Env adapt", + "theme.aria": "Appearance", + "theme.white": "Light", + "theme.ink": "Ink", + "purge.title": "Patches", + "override.title": "Prompt", + "own.title": "Local lab only", + "own.hint": "The open-source build only allows local lab targets (localhost, private nets, .lab/.lan/.htb). You cannot register a public host. Public IPs and domains are blocked.", + "own.save": "Save list", + "saved.own": "Own servers saved", + "saved.own.dropped": "Saved. {n} lines were not a single IP or hostname and were dropped", + "metric.purged": "Written", + "metric.purged.skip": "Written. Skipped {n}; those files are not on this install", + "metric.pendingShort": "Pending {n}", + "metric.unmatchedShort": "No match {n}", + "metric.skipShort": "Skipped {n}", + "metric.unlocated.count": "—", + "metric.shim": "shim", + "metric.shim.na": "Not needed here", + "metric.bak.yes": "Backup", + "metric.bak.no": "No backup", + "metric.bak.hint": "Backup", + "table.patch": "Item", + "table.status": "Status", + "status.applied": "Applied", + "status.off": "Off", + "status.pending": "Pending", + "status.unmatched": "No match", + "status.skipped": "Skipped", + "apply.hint": "If you did not edit the prompt box, do not save. The default is already used and is written to prompt-inject.md on start/apply. Save only after you change it. Apply writes host patches. Pending = original text still present. Skipped = not needed for this version or the component is not installed.", + "warn.noRoot": "Could not find this host’s @deepseek-ai tree, so Apply will not patch anything. Fully quit and reopen this host, then Apply here. Desktop may live on any drive; folder names like DeepseekHarnessDesktop or DeepSeek Harness are fine.", + "warn.noRoot.desktop": "Official Harness keeps its code in app.asar. Apply unpacks it and restarts the app. If this page stays “not found”, fully quit the client, reopen it, then Apply again. Third-party DSH Desktop: just Apply.", + "unpack.restarting": "Patches are written. Click Restart and the client opens again by itself.", + "restart.fullQuit": "Restarting. The client will open again by itself.", + "warn.noInject": "Official mode always uses the default prompt. Red team uses only the active rule set; otherwise it uses the default prompt without the CTF section, then the red team steps. If both are empty you will be asked to add a prompt.", + "need.prompt": "Both the prompt and the rule set are empty. Add a prompt, or enable a rule that has content.", + "btn.restoreInject": "Reset default", + "saved.restoreInject": "Default prompt restored", + "skip": "Skipped", + "unknown": "Unknown", + "delete": "Delete", + "btn.apply": "Apply", + "btn.apply.busy": "Working…", + "btn.revert": "Restore", + "btn.uninstall": "Uninstall", + "btn.uninstall.busy": "Uninstalling…", + "uninstall.title": "Uninstall dsh-purge", + "uninstall.body": "Uninstall? This restores the original Harness and removes this plugin completely.", + "uninstall.confirm": "Uninstall", + "uninstall.cancel": "Cancel", + "uninstall.applying": "Uninstalling and restoring…", + "uninstall.done": "Uninstalled and restored. Restarting…", + "uninstall.done.desktop": "Uninstalled and restored. Restarting the desktop app…", + "uninstall.fail": "Uninstall failed: {error}", + "btn.checkUpdate": "Check update", + "btn.checkUpdate.busy": "Checking…", + "btn.doUpdate": "Update", + "btn.doUpdate.busy": "Updating…", + "channel.title": "Channel", + "channel.stable": "Stable", + "channel.beta": "Beta", + "channel.useStable": "Switch to stable", + "channel.useBeta": "Switch to beta", + "channel.local": "This copy", + "update.checking": "Checking for updates…", + "update.applying": "Updating…", + "update.switching": "Switching version…", + "update.latest": "Up to date ({version})", + "update.available": "Update {remote} available (now {local})", + "update.pinned": "Pinned at {version}; channel latest is {remote}", + "update.done": "Updated to {version}.", + "update.restarting": "Restored the old patches, wrote the new ones, and restarting…", + "update.fullQuit": "Patches are written. Click Restart and the client opens again by itself.", + "restart.official.title": "Restart required", + "restart.official.body": "Patches are written. Click Restart and the client opens again by itself.", + "update.switched": "Switched to {version}.", + "update.autoDone": "Auto-updated to {version}.", + "update.reloading": "Refreshing settings…", + "update.dirty": "Update available; local edits were not overwritten.", + "update.fail": "Update failed: {error}", + "update.timeout": "Check timed out. Click Check update again.", + "update.needRestart": "Update API is not loaded. Restart dsh, then check again.", + "update.noBeta": "No beta build yet", + "update.switch": "Switch to this version", + "update.pick": "Select version", + "update.tip": "latest", + "update.current": "current", + "update.confirmSwitch": "Switch to {version}? You can switch back later. Uncommitted plugin edits will not be kept.", + "update.confirmChannel": "Switch to {version}? The plugin will be reinstalled from that channel. Uncommitted plugin edits will not be kept.", + "metric.version": "Version", + "action.apply": "Apply", + "action.revert": "Restore", + "action.uninstall": "Uninstall", + "ok.done": "Done", + "err.action": "Failed: {error}", + "err.status": "Read failed: {error}", + "err.override": "Read failed: {error}", + "err.rules": "Read failed: {error}", + "err.read": "Read failed: {error}", + "err.save": "Save failed: {error}", + "btn.saveInject": "Save", + "saved.override": "Saved", + "rules.title": "Rule sets", + "rules.none": "None", + "rules.reset": "Restore", + "rules.reset.confirm": "Restore global instruction files? The rule library is kept.", + "rules.empty": "No rules", + "rules.pick": "Select a rule", + "policy.title": "Workspace policy", + "policy.strict": "Strict", + "policy.fallback": "Fallback", + "policy.always": "Always on", + "policy.hint": "Strict: always strip AGENTS.md / CLAUDE.md. Fallback: use workspace files only when the prompt box is empty. Always on: keep DSH default behavior.", + "cwd.title": "Local rules (current folder)", + "cwd.empty": "No AGENTS.md / CLAUDE.md in this folder", + "cwd.rescan": "Rescan", + "cwd.hint": "Checked files are injected only when the prompt box is empty. A non-empty prompt box always wins.", + "cwd.scope.cwd": "cwd", + "cwd.scope.ancestor": "ancestor", + "cwd.scope.root": "project root", + "cwd.no.workspace": "DSH has not opened a workspace yet. Start a task in DSH first, then come back.", + "pill.current": "On", + "btn.use": "Enable", + "btn.inUse": "On", + "confirm.delete": "Delete {id}?", + "ph.id": "id", + "ph.alias": "Name", + "ph.alias.short": "Name", + "btn.create": "New", + "need.id": "id required", + "ph.content": "", + "btn.saveRule": "Save", + "skills.title": "Skills", + "skills.hint": "After import, DSH calls it: type /name in chat to load now; the model calls the skill tool when the task matches description or whenToUse. Writing「激活」in the body does nothing.", + "skills.hint.desktop": "This desktop host’s official skill catalog. Type /name in chat to load; the model calls the skill tool when the task matches the description.", + "skills.empty": "No user skills", + "btn.importZip": "Import zip", + "btn.importFolder": "Import folder", + "skills.importing": "Importing…", + "saved.import": "Imported {count} skill(s)", + "need.import": "Choose a zip or folder", + "err.import.folder": "No files were read. Select the skill folder itself (it must contain SKILL.md).", + "skills.pick": "Select a skill", + "skills.invalid": "Official catalog will ignore this: invalid format", + "skills.call.slash": "Type {cmd} in chat", + "skills.call.model": "Model can load", + "skills.call.modelOff": "Hidden from model", + "skills.call.userOff": "/name disabled", + "ph.skill.desc": "description (when to use)", + "btn.saveSkill": "Save", + "need.skill.id": "id must be kebab-case, e.g. code-review", + "confirm.delete.skill": "Delete {id} from official $DSH_HOME/skills? The host will drop it from the catalog.", + "err.skills": "Read failed: {error}", + "err.skills.needRestart": "Skill API is not loaded. Restart this host first.", + "btn.restart": "Restart", + "btn.restart.busy": "Restarting…", + "restarting": "Restarting…", + "restarting.desktop": "Restarting the desktop app…", + "restart.timeout": "Timed out; refresh", + "restart.fail": "Failed: {error}", + "restart.notStarted": "Restart did not start. This client is still open, and it will not try again by itself.", + "restart.already": "This restart already started. It will not restart again.", + "restart.confirm": "Apply finished. Restart to take effect.", + "restart.confirm.desktop": "Apply finished. Restart the desktop app to take effect.", + "restart.ask.title": "Restart now?", + "restart.ask.body": "It is written. It takes effect in this client after a restart.", + "restart.now": "Restart now", + "restart.notNow": "Later", + "restart.asarSwap": "Patches are on disk. Quitting the desktop app and moving app.asar aside; it will reopen when done. If behavior is unchanged, fully quit Harness (including tray) and Apply again.", + "surface.web": "Web", + "surface.desktop": "Desktop", + "surface.gui": "GUI", + "surface.tui": "TUI", + "restart.incomplete": "The prompt is in. {detail}", + "restart.incomplete.patches": "{failed} code patch(es) did not match this host (#{ids}). The prompt is unaffected", + "restart.incomplete.flash": "CMD silence not pinned ({entry})", + "restart.later": "Later", + "group.prompt": "Prompt", + "group.code": "Code", + "group.engine": "Engine", + "group.tools": "Tools", + "group.compat": "0.1.5 compat", + "group.dsh20": "0.2.0", + "patch.1": "Global disclaimer → mandate", + "patch.2": "Replacement disclaimer → mandate", + "patch.3": "Scope disclaimer → mandate", + "patch.4": "Persona session mandate", + "patch.5": "Approval copy → full access", + "patch.6": "Sandbox deny → allow write", + "patch.7": "Sandbox default full access", + "patch.8": "Approval default never", + "patch.9": "Approval gate auto-grant", + "patch.10": "Approval config default never", + "patch.11": "never copy → auto-grant", + "patch.12": "ask copy → auto-grant", + "patch.13": "Escalation ladder exempt", + "patch.14": "Escalation unconditional grant", + "patch.15": "Sandbox confine passthrough", + "patch.16": "Filesystem fence off", + "patch.17": "Observation policy allow R/W", + "patch.18": "Repeat-call guard off", + "patch.19": "Tool-result pruner off", + "patch.21": "Restore plugin-raised bash wait", + "patch.22": "read cap raised", + "patch.23": "subagent depth 3 → 10", + "patch.24": "preset fetch on", + "patch.25": "Strip official default identity (keep feature prompts)", + "patch.26": "minimal: strip identity, open inject", + "patch.28": "dsh-liangshen: keep dsh-purge / dshpg in phase-1", + "patch.70": "Minimal shell drops the no-network line", + "patch.71": "Liangshen drops the stock identity line", + "patch.29": "dsh-tool-web: untrusted framing → usable data", + "patch.30": "dsh-hooks-claude-code: deny→allow", + "patch.31": "dsh-hooks-codex: deny→allow", + "patch.32": "Web surface identity neutral", + "patch.33": "Shell denial prompt → executable", + "patch.34": "Sandbox hint neutralized", + "patch.35": "Escalation never-reject", + "patch.36": "FS escalation schema opened", + "patch.37": "Subagent scope lock neutralized", + "patch.38": "persona text→prefix (0.1.2 presets)", + "patch.39": "session v0 plugin summary (mnemon)", + "patch.40": "Keep inject when a complete prompt is set", + "patch.42": "Settings legacy API", + "patch.43": "Strip identity from web presets", + "patch.45": "Official desktop update handoff", + "patch.46": "Official update script stays ASCII", + "patch.47": "OAuth no longer inserts Claude identity", + "patch.48": "Shell drops extra delete caution", + "patch.49": "OAuth no longer mimics claude-cli", + "patch.50": "Session references are not marked untrusted", + "patch.51": "Fresh session stays on full access", + "patch.52": "Session log upload off by default", + "patch.53": "Web prompt drops do-not-start", + "patch.54": "Deliverable prompt drops extra prohibition", + "patch.55": "Replace the first system prompt every step", + "patch.56": "Rewind hides only the cut range", + "patch.57": "developer role for openai/azure only, third-party gateways use system", + "patch.58": "Rewind markers stay off the model request", + "patch.59": "Official Auto Review no longer denies tools", + "patch.60": "Official run_code drops approval teaching", + "patch.61": "Official sandbox policy no longer teaches refusal", + "patch.62": "Fail-open without approval channel (user reject kept)", + "patch.63": "Plan mode no longer soft-blocks execution", + "patch.64": "Stop hook no longer steers fake user text", + "patch.65": "UserPromptSubmit deny no longer rejects turn", + "patch.66": "Empty system section no longer ends the turn as an internal error", + "patch.67": "A missing messages list no longer ends the turn", + "patch.68": "Missing content after rewind no longer ends the turn", + "patch.69": "deepseek-flash keeps the official in-history flag", + "patch.72": "Messages system field uses the current system prompt", + "patch.73": "Minimal preset drops stray mirror sentence", + "patch.74": "Start/apply writes the default prompt to prompt-inject.md", + "patch.75": "Rewind resets completed todos to pending", + "patch.76": "Unrestricted preset: strip identity sentence", + "patch.77": "Rewind marker drops already-sent messages", + "patch.78": "Restore missing overlay from .bak backup", + "patch.80": "Restore official minimal tool plugins", + "patch.82": "Fallback DSH_PROFILE=desktop in main process", + "patch.83": "Don't fail on signal kill (no crash dialog)", + "patch.84": "Don't fail on code=0 clean exit (no stopped dialog)", + "patch.85": "prompt() steer stays interrupt, not followup", + "patch.86": "Queue steer stays steer; public steer() stays next-step", + "patch.87": "First assemble uses the systemPrompt prepare returned", + "patch.88": "Drop the hang after renderPrompt; inject is assemble only", + "status.na": "Not for this host", + "metric.sealed": "Official client found. Apply to unpack", + "metric.sealed.count": "Sealed", + "metric.lost": "Patches lost — Apply again", + "metric.lost.count": "Lost", + "status.unlocated": "Not located", + "metric.unlocated": "This install was not found yet", + "rewind.label": "Undo", + "rewind.aria": "Undo", + "rewind.busy": "Undoing…", + "rewind.empty": "Nothing to undo", + "rewind.fail": "Undo failed: {error}", + "rewind.once": "Undo last step", + "rewind.once.hint": "Withdraw the last step into a new session that keeps earlier context; the line returns to the box", + "rewind.round": "Undo last turn", + "rewind.round.hint": "Withdraw the last turn (and its subagents) into a new session that keeps earlier context; the line returns to the box", + "hooksDeny.title": "Hook interception", + "hooksDeny.bypass": "Bypass hook deny / ask (patches 30 / 31 / 64 / 65)", + "hooksDeny.hint": "When off, your own PreToolUse / UserPromptSubmit / Stop hook deny runs as the official host wrote it. The official ask branch is a no-op; turning this off will not show an approval dialog. Click Apply and restart to write the host files.", + "saved.hooksDeny": "Saved", + "continue.title": "Retry / Continue", + "continue.hint": "Failed requests auto-retry. After an abnormal stop or interrupt, use Continue or auto-resume. A manual stop never auto-continues. Counts reset after a completed turn.", + "continue.autoRetry": "Auto-retry on failure", + "continue.retryMax": "Retry count", + "continue.autoContinue": "Auto-continue after interrupt", + "continue.continueMax": "Continue count", + "continue.text": "Continue text", + "saved.continue": "Saved", + "continue.label": "Continue", + "continue.aria": "Continue after an abnormal stop", + "continue.busy": "Continuing…", + "continue.ready": "Continue ({left} left)", + "continue.limit": "Continue limit reached", + "continue.fail": "Continue failed: {error}", + }; + + const PURGE_CSS = ` +.dshp-root{--dshp-display:"Songti SC","Noto Serif SC","Iowan Old Style",Palatino,"Palatino Linotype",Georgia,serif;--dshp-sans:"Yu Gothic UI","Hiragino Sans GB","Source Han Sans SC",system-ui,sans-serif;--dshp-mono:"Cascadia Mono","Sarasa Mono SC",ui-monospace,monospace;--dshp-ease:cubic-bezier(.16,1,.3,1);max-width:920px;display:flex;flex-direction:column;gap:18px;padding:16px;border-radius:12px;background:var(--dshp-bg);color:var(--dshp-ink);font-family:var(--dshp-sans)} +.dshp-root{--dshp-bg:transparent;--dshp-paper:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#f6f4ef) 74%,transparent);--dshp-ink:var(--dsw-alias-label-primary,#1a1916);--dshp-mute:color-mix(in srgb,var(--dsw-alias-label-primary,#1a1916) 74%,var(--dsw-alias-label-secondary,#5c564c));--dshp-line:color-mix(in srgb,var(--dsw-alias-border-l1,#c8c2b6) 78%,transparent);--dshp-fill:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 14%,transparent);--dshp-accent:var(--dsw-alias-brand-primary,#3f6b52);--dshp-accent-soft:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 18%,transparent);--dshp-ok:#1f7a45;--dshp-warn:#8a5a12;--dshp-bad:#9a3434} +.dshp-root[data-theme="white"]{color-scheme:light} +.dshp-root[data-theme="dusk"]{color-scheme:dark;--dshp-ok:#8fd0a8;--dshp-warn:#e4c48a;--dshp-bad:#f0b4b4} +.dshp-toolbar{display:flex;align-items:center;justify-content:flex-end;gap:8px} +.dshp-switch{display:inline-flex;border:1px solid var(--dshp-line);border-radius:999px;overflow:hidden;background:var(--dshp-paper)} +.dshp-switch button{appearance:none;border:0;background:transparent;color:var(--dshp-mute);font:12px/1 var(--dshp-sans);padding:6px 14px;cursor:pointer} +.dshp-switch button.is-on{background:var(--dshp-accent-soft);color:var(--dshp-ink)} +.dshp-switch button:focus-visible{outline:2px solid var(--dshp-accent);outline-offset:-2px} +.dshp-panel{border:1px solid var(--dshp-line);background:var(--dshp-paper);border-radius:10px;padding:18px 18px 16px} +.dshp-kicker{font-family:var(--dshp-mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--dshp-mute);margin:0 0 4px} +.dshp-title{font-family:var(--dshp-display);font-size:20px;font-weight:500;letter-spacing:.02em;line-height:1.3;margin:0;color:var(--dshp-ink)} +.dshp-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap} +.dshp-sub{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:20px 0 10px} +.dshp-sub h4{margin:0;font-family:var(--dshp-display);font-size:15px;font-weight:500} +.dshp-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-bottom:14px} +.dshp-metric{border:1px solid var(--dshp-line);background:var(--dshp-bg);padding:12px;min-height:68px;display:flex;flex-direction:column;gap:4px;border-radius:8px} +.dshp-metric b{font-family:var(--dshp-display);font-size:18px;font-weight:500;line-height:1.2} +.dshp-metric span{font-size:11px;color:var(--dshp-mute)} +.dshp-bar{height:3px;border-radius:99px;background:var(--dshp-line);overflow:hidden;margin:8px 0 14px} +.dshp-bar>i{display:block;height:100%;background:var(--dshp-accent);width:0;transition:width .4s var(--dshp-ease)} +.dshp-group{border:1px solid var(--dshp-line);margin:0 0 8px;background:var(--dshp-bg);border-radius:8px;overflow:hidden} +.dshp-group-h{width:100%;display:flex;align-items:center;gap:10px;padding:9px 12px;border:0;background:transparent;color:inherit;cursor:pointer;font:inherit;text-align:left} +.dshp-group-h:hover{background:var(--dshp-fill)} +.dshp-group-h:focus-visible{outline:2px solid var(--dshp-accent);outline-offset:-2px} +.dshp-group-h strong{font-family:var(--dshp-display);font-size:14px;font-weight:500} +.dshp-group-h em{font-style:normal;font-size:12px;color:var(--dshp-mute);flex:1} +.dshp-count{font-family:var(--dshp-mono);font-size:12px;color:var(--dshp-mute)} +.dshp-table{width:100%;border-collapse:collapse;font-size:12.5px} +.dshp-table th{text-align:left;font-weight:500;font-size:11px;letter-spacing:.06em;color:var(--dshp-mute);padding:7px 12px;border-bottom:1px solid var(--dshp-line)} +.dshp-table td{padding:8px 12px;border-bottom:1px solid var(--dshp-line);vertical-align:middle} +.dshp-table tr:last-child td{border-bottom:0} +.dshp-table tr.is-on td{background:var(--dshp-accent-soft)} +.dshp-id{font-family:var(--dshp-mono);opacity:.75;width:42px} +.dshp-actions{text-align:right;white-space:nowrap} +.dshp-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.dshp-row+.dshp-row{margin-top:10px} +.dshp-pill{display:inline-flex;align-items:center;gap:6px;font-family:var(--dshp-mono);font-size:11px;padding:2px 8px;border:1px solid var(--dshp-line);border-radius:99px;line-height:1.6;background:var(--dshp-bg)} +.dshp-pill::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor} +.dshp-pill.is-ok{color:var(--dshp-ok);border-color:color-mix(in srgb,var(--dshp-ok) 30%,var(--dshp-line))} +.dshp-pill.is-wait{color:var(--dshp-warn);border-color:color-mix(in srgb,var(--dshp-warn) 30%,var(--dshp-line))} +.dshp-pill.is-miss{color:var(--dshp-mute)} +.dshp-pill.is-bad{color:var(--dshp-bad)} +.dshp-btn{appearance:none;font-family:var(--dshp-sans);font-size:13px;font-weight:500;min-height:34px;padding:0 14px;border-radius:8px;border:1px solid transparent;cursor:pointer;transition:background .15s var(--dshp-ease),opacity .15s var(--dshp-ease)} +.dshp-btn:disabled{opacity:.45;cursor:not-allowed} +.dshp-btn:focus-visible{outline:2px solid var(--dshp-accent);outline-offset:2px} +.dshp-btn-primary{background:var(--dshp-accent-soft);color:var(--dshp-ink);border-color:color-mix(in srgb,var(--dshp-accent) 35%,var(--dshp-line))} +.dshp-btn-primary:hover:not(:disabled){background:color-mix(in srgb,var(--dshp-accent) 22%,var(--dshp-bg))} +.dshp-btn-ghost{background:transparent;color:var(--dshp-ink);border-color:var(--dshp-line)} +.dshp-btn-danger{background:transparent;color:var(--dshp-bad);border-color:color-mix(in srgb,var(--dshp-bad) 40%,var(--dshp-line))} +.dshp-btn-solid-danger{background:color-mix(in srgb,var(--dshp-bad) 16%,var(--dshp-bg));color:var(--dshp-bad);border-color:color-mix(in srgb,var(--dshp-bad) 30%,var(--dshp-line))} +.dshp-btn-tiny{min-height:28px;padding:0 10px;font-size:12px} +.dshp-field,.dshp-area{width:100%;box-sizing:border-box;font:13px/1.55 var(--dshp-mono);padding:8px 10px;background:var(--dshp-bg);color:var(--dshp-ink);border:1px solid var(--dshp-line);border-radius:8px} +.dshp-field{width:auto;min-width:140px} +.dshp-cr-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:8px 0 0;font-size:13px} +.dshp-cr-row label{display:inline-flex;align-items:center;gap:6px;color:var(--dshp-ink)} +.dshp-cr-num{width:64px;min-width:64px;height:28px;padding:0 8px} +.dshp-cr-text{width:120px;min-width:88px;height:28px;padding:0 8px} +.dshp-ver{width:100%;min-width:0;max-width:none;height:32px;padding:0 10px;font-size:12px} +.dshp-rel{margin:0 0 14px} +.dshp-now{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin:0 0 4px} +.dshp-now-label{font-family:var(--dshp-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dshp-mute)} +.dshp-now-ver{font-family:var(--dshp-display);font-size:20px;font-weight:500;letter-spacing:.01em} +.dshp-now-mark{display:inline-flex;align-items:center;margin-left:6px;padding:1px 7px;border-radius:99px;font-family:var(--dshp-mono);font-size:11px;font-style:normal;font-weight:500;line-height:1.5;color:var(--dshp-ok);border:1px solid color-mix(in srgb,var(--dshp-ok) 38%,var(--dshp-line));background:color-mix(in srgb,var(--dshp-ok) 14%,var(--dshp-bg))} +.dshp-editions{display:flex;flex-direction:column;margin:10px 0 0;border-top:1px solid var(--dshp-line)} +.dshp-edition{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dshp-line)} +.dshp-edition-name{display:inline-flex;align-items:center;gap:0;font-family:var(--dshp-display);font-size:15px;font-weight:500;white-space:nowrap} +.dshp-edition.is-on .dshp-edition-name{color:var(--dshp-ink)} +.dshp-edition:not(.is-on) .dshp-edition-name{color:var(--dshp-mute)} +.dshp-edition .dshp-btn{min-width:118px} +.dshp-rel .dshp-notice{display:block;margin-top:8px;min-height:16px} +@media (max-width:560px){.dshp-edition{grid-template-columns:1fr auto;grid-template-areas:"name btn" "sel sel"}.dshp-edition-name{grid-area:name}.dshp-edition .dshp-ver{grid-area:sel}.dshp-edition .dshp-btn{grid-area:btn}} +.dshp-area{min-height:220px;resize:vertical} +.dshp-field:focus,.dshp-area:focus{outline:none;border-color:var(--dshp-accent);box-shadow:0 0 0 3px var(--dshp-accent-soft)} +.dshp-field:disabled,.dshp-area:disabled{opacity:.5} +.dshp-ask{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding:10px 12px;border:1px solid var(--dshp-line);border-radius:8px;background:var(--dshp-bg);font-size:13px} +.dshp-modal-bg{position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center;padding:20px;background:color-mix(in srgb,var(--dshp-ink) 28%,transparent)} +.dshp-modal{width:min(420px,100%);background:var(--dshp-paper);color:var(--dshp-ink);border:1px solid var(--dshp-line);border-radius:12px;padding:20px 20px 16px;box-shadow:0 16px 40px color-mix(in srgb,var(--dshp-ink) 18%,transparent)} +.dshp-modal h4{margin:0 0 8px;font-family:var(--dshp-display);font-size:18px;font-weight:500} +.dshp-modal p{margin:0 0 16px;font-size:13.5px;line-height:1.55} +.dshp-modal-ops{display:flex;justify-content:flex-end;gap:8px} +.dshp-restart-mask{position:fixed;inset:0;z-index:12000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(0,0,0,.62);font-family:"Yu Gothic UI","Hiragino Sans GB","Source Han Sans SC","Microsoft YaHei UI",system-ui,sans-serif} +.dshp-restart-card{width:min(420px,100%);box-sizing:border-box;padding:22px 22px 18px;border-radius:14px;background:var(--dsw-alias-bg-layer-1,#fffcf7);color:var(--dsw-alias-label-primary,#16140f);border:1px solid var(--dsw-alias-border-l1,#c9c3b6);box-shadow:0 18px 50px rgba(0,0,0,.35)} +.dshp-restart-card h4{margin:0 0 10px;font-family:inherit;font-size:20px;font-weight:650;line-height:1.35;color:inherit} +.dshp-restart-card p{margin:0 0 18px;font-size:16px;font-weight:500;line-height:1.65;color:inherit} +.dshp-restart-card .dshp-modal-ops{display:flex;justify-content:flex-end;gap:10px} +.dshp-restart-card .dshp-btn{min-height:38px;padding:0 16px;font-size:15px;font-weight:650;color:var(--dsw-alias-label-primary,#16140f);background:var(--dsw-alias-bg-layer-2,#fff);border:1px solid var(--dsw-alias-border-l1,#c9c3b6)} +.dshp-restart-card .dshp-btn-primary,.dshp-restart-card .dshp-btn-primary:hover:not(:disabled){color:#fff;background:#2f6b4f;border-color:#2f6b4f} +body[data-ds-dark-theme] .dshp-restart-card,.dshp-restart-mask[data-theme="dusk"] .dshp-restart-card{background:#1c1e24;color:#f4f1ea;border-color:#5a5e68} +body[data-ds-dark-theme] .dshp-restart-card .dshp-btn,.dshp-restart-mask[data-theme="dusk"] .dshp-restart-card .dshp-btn{color:#f4f1ea;background:#2a2d34;border-color:#5a5e68} +body[data-ds-dark-theme] .dshp-restart-card .dshp-btn-primary,body[data-ds-dark-theme] .dshp-restart-card .dshp-btn-primary:hover:not(:disabled),.dshp-restart-mask[data-theme="dusk"] .dshp-restart-card .dshp-btn-primary,.dshp-restart-mask[data-theme="dusk"] .dshp-restart-card .dshp-btn-primary:hover:not(:disabled){color:#fff;background:#3d8f68;border-color:#3d8f68} +.dshp-notice{font-size:12.5px;line-height:1.4} +.dshp-notice.is-ok{color:var(--dshp-ok)} +.dshp-notice.is-bad{color:var(--dshp-bad)} +.dshp-skel{height:12px;border-radius:6px;background:linear-gradient(90deg,var(--dshp-fill),var(--dshp-line),var(--dshp-fill));background-size:200% 100%;animation:dshp-pulse 1.2s ease-in-out infinite} +.dshp-active{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 12px;margin-bottom:12px;border:1px solid var(--dshp-line);background:var(--dshp-bg);border-radius:8px} +.dshp-active b{font-family:var(--dshp-display);font-weight:500} +.dshp-empty{padding:28px 12px;font-size:13px;color:var(--dshp-mute);text-align:center} +.dshp-split{display:flex;flex-direction:column;gap:12px} +.dshp-rulelist{display:flex;flex-direction:column;border:1px solid var(--dshp-line);border-radius:8px;background:var(--dshp-bg);overflow:hidden} +.dshp-rulebody{max-height:240px;overflow:auto} +.dshp-ruleitem{display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border:0;border-bottom:1px solid var(--dshp-line);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer} +.dshp-ruleitem:hover{background:var(--dshp-fill)} +.dshp-ruleitem.is-edit{background:var(--dshp-accent-soft)} +.dshp-rule-main{flex:1;min-width:0} +.dshp-rule-name{display:flex;align-items:center;gap:8px;font-family:var(--dshp-display);font-size:14px;line-height:1.35} +.dshp-rule-meta{display:block;margin-top:3px;font-family:var(--dshp-mono);font-size:11px;color:var(--dshp-mute)} +.dshp-rule-ops{display:flex;flex-direction:row;align-items:center;gap:6px;flex-shrink:0} +.dshp-create{display:flex;flex-direction:column;gap:6px;padding:10px 12px;border-top:1px solid var(--dshp-line);background:var(--dshp-paper)} +.dshp-create-row{display:flex;gap:6px;align-items:center} +.dshp-create .dshp-field{flex:1;min-width:0;width:auto} +.dshp-editor{display:flex;flex-direction:column;gap:10px;min-width:0;border:1px solid var(--dshp-line);border-radius:8px;background:var(--dshp-bg);padding:12px;min-height:320px} +.dshp-editor .dshp-area{flex:1;min-height:240px} +.dshp-editor-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--dshp-mute);font-size:13px;min-height:160px} +.dshp-block{padding:14px 0;border-top:1px solid var(--dshp-line)} +.dshp-block-head{display:flex;align-items:center;gap:10px;margin:0 0 8px} +.dshp-block-head h4{margin:0;font-family:var(--dshp-display);font-size:14px;font-weight:500;flex:1;color:var(--dshp-ink)} +.dshp-block-hint{margin:8px 0 0;font-size:12px;line-height:1.55;color:var(--dshp-mute)} +.dshp-policy-row{display:flex;align-items:center;gap:10px} +.dshp-policy-row>label{font-size:12px;color:var(--dshp-mute);flex:0 0 auto} +.dshp-cwd-path{margin:0 0 8px;font-family:var(--dshp-mono);font-size:11px;color:var(--dshp-mute);word-break:break-all;line-height:1.5} +.dshp-cwd-list{display:flex;flex-direction:column;border:1px solid var(--dshp-line);border-radius:8px;background:var(--dshp-bg);overflow:hidden} +.dshp-cwd-item{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border-bottom:1px solid var(--dshp-line);cursor:pointer;margin:0} +.dshp-cwd-item:last-child{border-bottom:0} +.dshp-cwd-item:hover{background:var(--dshp-fill)} +.dshp-cwd-item.is-on{background:var(--dshp-accent-soft)} +.dshp-cwd-item input{margin:3px 0 0;flex:0 0 auto} +.dshp-cwd-main{flex:1;min-width:0} +.dshp-cwd-name{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-family:var(--dshp-display);font-size:13.5px;line-height:1.35;color:var(--dshp-ink)} +.dshp-cwd-badge{font-family:var(--dshp-mono);font-size:10px;letter-spacing:.04em;text-transform:uppercase;padding:1px 7px;border-radius:99px;border:1px solid var(--dshp-line);color:var(--dshp-mute);line-height:1.6} +.dshp-cwd-size{font-family:var(--dshp-mono);font-size:11px;color:var(--dshp-mute)} +.dshp-cwd-sub{margin-top:3px;font-family:var(--dshp-mono);font-size:11px;color:var(--dshp-mute);word-break:break-all} +.dshp-cwd-prev{margin-top:4px;font-size:11px;line-height:1.5;color:var(--dshp-mute);max-height:34px;overflow:hidden} +@keyframes dshp-pulse{0%{background-position:200% 0}100%{background-position:-200% 0}} +@media (max-width:640px){.dshp-metrics{grid-template-columns:1fr}.dshp-ruleitem{flex-wrap:wrap}.dshp-rule-ops{width:100%;justify-content:flex-end}} +@media (prefers-reduced-motion:reduce){.dshp-btn,.dshp-bar>i{transition:none}.dshp-skel{animation:none}} + +.dshp-dock{position:fixed;top:12px;right:12px;z-index:10050;display:flex;flex-direction:column;width:var(--dshp-dock-w,620px);height:var(--dshp-dock-h,72vh);min-width:380px;min-height:320px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);box-sizing:border-box;background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,var(--dshp-paper,#1c1c1c)) 52%,transparent);backdrop-filter:blur(22px) saturate(1.35);-webkit-backdrop-filter:blur(22px) saturate(1.35);border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,var(--dshp-line,#555)) 65%,transparent);border-radius:12px;box-shadow:0 18px 48px color-mix(in srgb,#000 30%,transparent),0 0 0 1px color-mix(in srgb,#fff 6%,transparent);pointer-events:auto;color:var(--dsw-alias-label-primary,var(--dshp-ink,#f2f2f2));overflow:visible} +.dshp-dock[data-open="0"]{display:none} +.dshp-launch{position:fixed;right:16px;bottom:16px;z-index:10040;appearance:none;display:inline-flex;align-items:center;gap:6px;height:36px;padding:0 14px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 55%,transparent);border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#1c1c1c) 72%,transparent);color:var(--dsw-alias-label-primary,#f2f2f2);cursor:pointer;font:13px/1 inherit;box-shadow:0 8px 22px color-mix(in srgb,#000 28%,transparent);pointer-events:auto} +.dshp-launch:hover{border-color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent)} +.dshp-launch[data-open="1"],.dshp-launch[data-hide="1"]{display:none} +.dshp-dock-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,var(--dshp-line,#555)) 55%,transparent);background:transparent;cursor:grab;user-select:none;touch-action:none;color:var(--dsw-alias-label-primary,var(--dshp-ink,#f2f2f2));border-radius:12px 12px 0 0} +.dshp-dock-head:active{cursor:grabbing} +.dshp-dock-head b{font-size:14px;font-weight:600;color:inherit;text-shadow:0 1px 0 color-mix(in srgb,#000 18%,transparent)} +.dshp-dock-head button{cursor:pointer;color:inherit;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,rgba(127,127,127,.18)) 55%,transparent)} +.dshp-dock-tabs{display:flex;gap:4px;padding:0 12px;border-bottom:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,var(--dshp-line,#555)) 55%,transparent);background:transparent} +.dshp-dock-tab{appearance:none;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary,var(--dshp-mute,#b8b4ac));border-radius:0;padding:10px 14px 8px;margin-bottom:-1px;cursor:pointer;font:13px/1.2 inherit;text-shadow:0 1px 0 color-mix(in srgb,#000 12%,transparent)} +.dshp-dock-tab:hover{color:var(--dsw-alias-label-primary,var(--dshp-ink,#f2f2f2))} +.dshp-dock-tab.on{color:var(--dsw-alias-label-primary,var(--dshp-ink,#f2f2f2));font-weight:600;border-bottom-color:var(--dsw-alias-brand-primary,#6dbf8c);background:transparent} +.dshp-dock-body{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;background:transparent;border-radius:0 0 12px 12px} +.dshp-dock-body .dshp-root{max-width:none;height:100%;flex:1;min-height:0;overflow:auto;background:transparent!important;--dshp-bg:transparent;--dshp-paper:color-mix(in srgb,var(--dshp-ink) 7%,transparent);--dshp-fill:color-mix(in srgb,var(--dshp-ink) 6%,transparent);--dshp-line:color-mix(in srgb,var(--dshp-ink) 20%,transparent);--dshp-accent-soft:color-mix(in srgb,var(--dshp-accent) 22%,transparent)} +.dshp-dock-body .dshp-panel,.dshp-dock-body .dshp-metric,.dshp-dock-body .dshp-group,.dshp-dock-body .dshp-editor,.dshp-dock-body .dshp-active,.dshp-dock-body .dshp-ask,.dshp-dock-body .dshp-create,.dshp-dock-body .dshp-rulelist,.dshp-dock-body .dshp-table,.dshp-dock-body .dshp-ruleitem,.dshp-dock-body .dshp-cwd-list,.dshp-dock-body .dshp-cwd-item{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#f7f4ee) 74%,transparent)!important;border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 32%,transparent)!important;color:var(--dshp-ink)!important;box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 32%,transparent);backdrop-filter:blur(16px) saturate(1.3);-webkit-backdrop-filter:blur(16px) saturate(1.3)} +.dshp-dock-body .dshp-field,.dshp-dock-body .dshp-area{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 88%,transparent)!important;color:var(--dshp-ink)!important;border-color:color-mix(in srgb,var(--dsw-alias-border-l1,#8a8478) 72%,transparent)!important} +.dshp-dock-body select.dshp-field,.dshp-dock-body .dshp-field.dshp-ver{background:var(--dsw-alias-bg-layer-1,#fff)!important;color:var(--dshp-ink)!important;color-scheme:inherit} +.dshp-dock[data-theme="white"] select.dshp-field option,.dshp-dock[data-theme="white"] .dshp-field.dshp-ver option{background:#fff;color:#16140f} +.dshp-dock[data-theme="dusk"] select.dshp-field option,.dshp-dock[data-theme="dusk"] .dshp-field.dshp-ver option{background:#1b1d22;color:#f4f1ea} +.dshp-dock-body .dshp-title,.dshp-dock-body .dshp-sub h4,.dshp-dock-body .dshp-group-h strong,.dshp-dock-body .dshp-metric b,.dshp-dock-body .dshp-rule-name{font-family:inherit;font-weight:650;letter-spacing:0;color:var(--dshp-ink)!important;text-shadow:none} +.dshp-dock-body .dshp-mute,.dshp-dock-body .dshp-kicker,.dshp-dock-body .dshp-metric span,.dshp-dock-body .dshp-group-h em,.dshp-dock-body .dshp-count,.dshp-dock-body .dshp-rule-meta,.dshp-dock-body .dshp-hint{color:var(--dshp-mute)!important;font-weight:500;text-shadow:none} +.dshp-dock-body .rt-dock{position:relative;inset:auto;width:100%!important;height:100%;max-width:none!important;flex:1;min-height:0;min-width:0;display:flex!important;flex-direction:column;box-shadow:none;border:0;transform:none!important;opacity:1!important;pointer-events:auto!important;background:transparent!important} +.dshp-dock-body .rt-grip{display:none!important} +.dshp-dock-body .rt-embedded>.rt-head,.dshp-dock-body .rt-tabs,.dshp-dock-body .rt-foot{flex:none} +.dshp-dock-body .rt-body{flex:1;min-height:0;overflow:hidden!important;background:transparent!important} +.dshp-dock-body .rt-main{min-height:0;flex:1;overflow:hidden} +.dshp-dock-body .rt-pane{min-height:0;overflow:auto!important} +.dshp-dock-body .rt-embedded>.rt-head{padding:8px 12px} +.dshp-dock-body .rt-embedded>.rt-head .rt-title{font-size:14px;letter-spacing:0} +.dshp-dock-body .rt-tabs{gap:2px;padding:6px 10px 0;flex-wrap:wrap} +.dshp-dock-body .rt-tab{border-radius:0;border-bottom:2px solid transparent;margin-bottom:-1px;padding:8px 9px 7px;background:transparent;font-size:14px} +.dshp-dock-body .rt-tab.on{border-bottom-color:var(--dsw-alias-brand-primary,#6d8f78);background:transparent;font-weight:650} +.dshp-dock-body .rt-row:hover,.dshp-dock-body .rt-item:hover,.dshp-dock-body .rt-item.on,.dshp-dock-body .rt-seg:hover,.dshp-dock-body .rt-seg.on,.dshp-dock-body .rt-vrow:hover,.dshp-dock-body .rt-score-row:hover{background:var(--rt-surf-2)!important} +body:not([data-ds-dark-theme]) .dshp-auth-modal{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fffcf7) 92%,transparent);color:var(--dsw-alias-label-primary,#1a1916);border-color:color-mix(in srgb,var(--dsw-alias-border-l1,#c9c3b6) 80%,transparent);backdrop-filter:blur(18px) saturate(1.3);-webkit-backdrop-filter:blur(18px) saturate(1.3)} +body:not([data-ds-dark-theme]) .dshp-auth-legal{background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#f3f0e8) 90%,transparent);color:var(--dsw-alias-label-primary,#1a1916);border-color:color-mix(in srgb,var(--dsw-alias-border-l1,#c9c3b6) 80%,transparent)} +body:not([data-ds-dark-theme]) .dshp-auth-warn{background:color-mix(in srgb,#f5e6c8 88%,transparent);color:#6b4a10} +body:not([data-ds-dark-theme]) .dshp-auth-ops button{border-color:var(--dsw-alias-border-l1,#c9c3b6);color:var(--dsw-alias-label-primary,#1a1916);background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 88%,transparent)} +body:not([data-ds-dark-theme]) .dshp-auth-ops button.primary{background:var(--dsw-alias-brand-primary,#3f6b52);border-color:var(--dsw-alias-brand-primary,#3f6b52);color:#fff} +.dshp-dock-resize,.dshp-dock-resize-l,.dshp-dock-resize-r,.dshp-dock-resize-b{position:absolute;z-index:60;pointer-events:auto;touch-action:none} +.dshp-dock-resize{right:0;bottom:0;width:22px;height:22px;cursor:nwse-resize;background:linear-gradient(135deg,transparent 46%,color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 75%,transparent) 46%);border-radius:0 0 12px 0;opacity:.95} +.dshp-dock-resize-l{left:-2px;top:0;bottom:0;width:10px;cursor:ew-resize;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent)} +.dshp-dock-resize-l:hover,.dshp-dock-resize-r:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 42%,transparent)} +.dshp-dock-resize-r{right:-2px;top:0;bottom:22px;width:10px;cursor:ew-resize;background:transparent} +.dshp-dock-resize-b{left:10px;right:22px;bottom:-2px;height:10px;cursor:ns-resize;background:transparent} +.dshp-dock-resize-b:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 35%,transparent)} +.dshp-auth-mask{position:fixed;inset:0;z-index:10100;background:rgba(0,0,0,.62);display:flex;align-items:center;justify-content:center;padding:20px;pointer-events:auto} +.dshp-env-gate{z-index:10200} +.dshp-auth-modal{width:min(680px,100%);max-height:min(88vh,820px);display:flex;flex-direction:column;gap:10px;padding:16px;border:1px solid var(--dsw-alias-border-l1,#333);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#1a1a1a);color:var(--dsw-alias-label-primary,#eee)} +.dshp-auth-modal h2{margin:0;font-size:16px;font-weight:600} +.dshp-auth-warn{padding:8px 10px;border-radius:8px;background:#3a2e18;color:#f0d48a;font-size:13px} +.dshp-auth-legal{overflow:auto;height:300px;margin:0;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#333);border-radius:8px;background:#121212;font-size:13px;line-height:1.55} +.dshp-auth-legal h3{margin:14px 0 4px;font-size:13px} +.dshp-auth-legal h3:first-child{margin-top:0} +.dshp-auth-legal p{margin:0 0 8px} +.dshp-auth-checks{display:flex;flex-direction:column;gap:8px;font-size:13px} +.dshp-auth-checks label{display:flex;gap:8px;align-items:flex-start} +.dshp-auth-checks label.locked{opacity:.45} +.dshp-auth-ops{display:flex;justify-content:flex-end;gap:8px} +.dshp-auth-ops button,.dshp-dock-head button{appearance:none;border:1px solid var(--dsw-alias-border-l1,#444);background:transparent;color:inherit;border-radius:8px;padding:6px 12px;cursor:pointer;font:inherit} +.dshp-auth-ops button.primary{background:#2a4033;border-color:#8fbf9a} +.dshp-auth-ops button:disabled{opacity:.45;cursor:default} +.dshp-icon-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 45%,transparent);border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,currentColor);cursor:pointer;font:13px/1 inherit;white-space:nowrap;flex:0 0 auto} +.dshp-icon-btn:hover{border-color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 12%,transparent);color:var(--dsw-alias-label-primary,currentColor)} +.dshp-icon-btn.on{border-color:var(--dsw-alias-brand-primary,#6dbf8c);color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 16%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 28%,transparent)} +.dshp-hbtn{appearance:none;display:inline-flex;flex:0 0 auto;align-items:center;gap:6px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 55%,transparent);background:transparent;color:var(--dsw-alias-label-secondary,currentColor);cursor:pointer;font:12px/1 inherit;padding:5px 10px;border-radius:8px;white-space:nowrap} +.dshp-hbtn:hover{border-color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 12%,transparent);color:var(--dsw-alias-label-primary,currentColor)} +.dshp-hbtn.on{border-color:var(--dsw-alias-brand-primary,#6dbf8c);color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 32%,transparent)} +.dshp-live-dot{width:7px;height:7px;border-radius:50%;flex:none;background:var(--dsw-alias-brand-primary,#6dbf8c);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 28%,transparent)} +.dshp-hero-chip{display:inline-flex;align-items:center;margin-left:6px;flex:0 0 auto} +.dshp-hero-chip-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 12px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 50%,transparent);border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 10%,transparent);color:var(--dsw-alias-label-primary,currentColor);cursor:pointer;font:12.5px/1 inherit;white-space:nowrap} +.dshp-hero-chip-btn:hover{border-color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent)} +.dshp-hero-chip-btn.on{border-color:var(--dsw-alias-brand-primary,#6dbf8c);color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 32%,transparent)} +.dshp-tab-chip{display:inline-flex;align-items:flex-end;flex:0 0 auto} +.dshp-tab-btn{appearance:none;position:relative;border:none;background:transparent;cursor:pointer;padding:0 0 9px;font:500 13px/16px inherit;color:var(--dsw-alias-label-tertiary,currentColor);white-space:nowrap} +.dshp-tab-btn:hover{color:var(--dsw-alias-label-primary,currentColor)} +.dshp-tab-btn.on{color:var(--dsw-alias-brand-primary,#6dbf8c)} +.dshp-tab-btn.on:after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2px;border-radius:2px;background:var(--dsw-alias-brand-primary,#6dbf8c)} +/* Glass follows the host color. Weight and background opacity are kept separate: the shell is translucent, the text sits on a more solid layer. */ +.dshp-dock{font-family:"Segoe UI","Microsoft YaHei UI","PingFang SC","Noto Sans SC",system-ui,sans-serif;font-size:14px;line-height:1.6;font-weight:500;-webkit-font-smoothing:antialiased;text-shadow:none} +.dshp-dock[data-theme="white"],.dshp-dock[data-theme="dusk"]{background:linear-gradient(165deg,color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 50%,transparent),color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 18%,transparent) 46%,color-mix(in srgb,var(--dsw-alias-bg-layer-2,#fff) 28%,transparent))!important;backdrop-filter:blur(26px) saturate(1.45)!important;-webkit-backdrop-filter:blur(26px) saturate(1.45)!important;color:var(--dshp-ink)!important;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 36%,transparent)!important;box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 42%,transparent),0 18px 44px color-mix(in srgb,#000 18%,transparent)!important} +.dshp-dock[data-theme="dusk"]{--dshp-ink:var(--dsw-alias-label-primary,#f4f1ea);--dshp-mute:color-mix(in srgb,var(--dsw-alias-label-primary,#f4f1ea) 84%,var(--dsw-alias-label-secondary,#c9c3b8))} +.dshp-dock[data-theme="white"]{--dshp-ink:var(--dsw-alias-label-primary,#16140f);--dshp-mute:color-mix(in srgb,var(--dsw-alias-label-primary,#16140f) 76%,var(--dsw-alias-label-secondary,#3c362e))} +.dshp-dock .dshp-dock-head,.dshp-dock .dshp-dock-tabs{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,transparent) 28%,transparent)!important;color:var(--dshp-ink)!important} +.dshp-dock .dshp-dock-head,.dshp-dock .dshp-dock-head b,.dshp-dock .dshp-dock-head button,.dshp-dock .dshp-dock-tab,.dshp-dock .dshp-dock-tab.on{color:var(--dshp-ink)!important;text-shadow:none!important;font-weight:600} +.dshp-dock .dshp-dock-tab{font-size:14px} +.dshp-dock .dshp-dock-tab.on{border-bottom-color:var(--dsw-alias-brand-primary,#6d8f78)!important} +.dshp-dock-body .rt-dock{font-size:14px!important;line-height:1.6!important;font-weight:500!important;color:var(--dshp-ink)!important;--rt-ink:var(--dshp-ink);--rt-mute:var(--dshp-mute);--rt-surf:color-mix(in srgb,var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1,#fff)) 40%,transparent);--rt-surf-2:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 62%,transparent);--rt-line:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 30%,transparent)} +.dshp-dock-body .rt-title,.dshp-dock-body .rt-tab,.dshp-dock-body .rt-tab.on,.dshp-dock-body .rt-card,.dshp-dock-body .rt-card h4,.dshp-dock-body .rt-row,.dshp-dock-body .rt-vrow,.dshp-dock-body .rt-kv,.dshp-dock-body .rt-kv b,.dshp-dock-body .rt-kv span,.dshp-dock-body .rt-mono,.dshp-dock-body .rt-empty,.dshp-dock-body .rt-foot,.dshp-dock-body .rt-tag,.dshp-dock-body .rt-item,.dshp-dock-body .rt-item-name,.dshp-dock-body .rt-item-desc,.dshp-dock-body .rt-sec-sub,.dshp-dock-body .rt-kb-sub,.dshp-dock-body .rt-seg-cidr,.dshp-dock-body .rt-seg-meta,.dshp-dock-body .rt-section,.dshp-dock-body .rt-note,.dshp-dock-body .rt-hint{color:var(--dshp-ink)!important;text-shadow:none!important;opacity:1!important} +.dshp-dock-body .rt-tab,.dshp-dock-body .rt-foot,.dshp-dock-body .rt-empty,.dshp-dock-body .rt-item-desc,.dshp-dock-body .rt-kb-sub,.dshp-dock-body .rt-kv span,.dshp-dock-body .rt-tag,.dshp-dock-body .rt-sec-sub{color:var(--dshp-mute)!important} +.dshp-dock-body .rt-card,.dshp-dock-body .rt-pane,.dshp-dock-body .rt-side,.dshp-dock-body .rt-table,.dshp-dock-body .rt-toolbar,.dshp-dock-body .rt-embedded>.rt-head,.dshp-dock-body .rt-tabs{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 72%,transparent)!important;border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 28%,transparent)!important;color:var(--dshp-ink)!important;box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 26%,transparent)!important;backdrop-filter:blur(16px) saturate(1.3);-webkit-backdrop-filter:blur(16px) saturate(1.3)} +.dshp-dock-body .rt-btn,.dshp-dock-body .rt-input,.dshp-dock-body .rt-eng-select,.dshp-dock-body textarea.rt-input{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 90%,transparent)!important;color:var(--dshp-ink)!important;border-color:color-mix(in srgb,var(--dsw-alias-border-l1,#888) 70%,transparent)!important} +.dshp-dock-body .rt-btn-primary{background:var(--dsw-alias-brand-primary,#3f6b52)!important;border-color:var(--dsw-alias-brand-primary,#3f6b52)!important;color:#fff!important} +.dshp-dock[data-theme="white"] .dshp-dock-body .rt-eng-select option,.dshp-dock[data-theme="white"] .dshp-dock-body .rt-input option{background:#fff;color:#16140f} +.dshp-dock[data-theme="dusk"] .dshp-dock-body .rt-eng-select option,.dshp-dock[data-theme="dusk"] .dshp-dock-body .rt-input option{background:#1b1d22;color:#f4f1ea} +.dshp-dock-body .rt-err{font-weight:650} +.dshp-dock[data-theme="white"] .dshp-dock-body .rt-err{color:#9a3434!important} +.dshp-dock[data-theme="dusk"] .dshp-dock-body .rt-err{color:#f0b4b4!important} +.dshp-dock-body .rt-rep-http,.dshp-dock-body pre{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 84%,transparent)!important;color:var(--dshp-ink)!important;border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,#888) 60%,transparent)!important} +`; + + function formatSize(bytes) { + if (typeof bytes !== "number" || !isFinite(bytes) || bytes < 0) return "0 B"; + if (bytes < 1024) return bytes + " B"; + const kb = bytes / 1024; + return (kb >= 100 ? Math.round(kb) : kb.toFixed(1)) + " KB"; + } + + function statusKind(st) { + if (st === "applied" || st === "already") return "ok"; + if (st === "pending") return "wait"; + if (st === "unmatched") return "bad"; + if (st === "na") return "ok"; + if (st === "missing_file" || st === "skipped" || st === "unlocated" || st === "off") return "miss"; + return "bad"; + } + + function statusLabel(st, t) { + if (st === "applied" || st === "already") return t("status.applied"); + if (st === "off") return t("status.skipped"); + if (st === "pending") return t("status.pending"); + if (st === "unmatched") return t("status.unmatched"); + if (st === "skipped") return t("status.skipped"); + if (st === "missing_file") return t("skip"); + if (st === "unlocated") return t("status.unlocated"); + if (st === "na") return t("status.na"); + return st || t("unknown"); + } + + function shimKind(v) { + if (v === "patched" || v === "n/a") return "ok"; + if (v === "original") return "wait"; + if (v === "missing" || !v) return "miss"; + return "bad"; + } + + function noticeNode(notice) { + if (!notice || notice.kind === "idle" || !notice.text) return null; + return h("span", { className: "dshp-notice " + (notice.kind === "error" ? "is-bad" : "is-ok") }, notice.text); + } + + function Btn(props) { + const { kind, tiny, children, className, type, ...rest } = props; + const cls = ["dshp-btn", kind ? "dshp-btn-" + kind : "dshp-btn-ghost", tiny ? "dshp-btn-tiny" : "", className || ""].filter(Boolean).join(" "); + return h("button", Object.assign({ type: type || "button", className: cls }, rest), children); + } + + function Pill({ value, label }) { + const t = useT(); + return h("span", { className: "dshp-pill is-" + statusKind(value) }, label || statusLabel(value, t)); + } + + const LAYER_GROUP = { + "Prompt": "prompt", + "Code": "code", + "Tools": "tools", + "0.1.5 compat": "compat", + "0.2.0 compat": "dsh20", + }; + const GROUP_ORDER = ["prompt", "code", "tools", "compat", "dsh20"]; + /* The host may report patch layer names in the host locale. The zh dictionary + * still keeps those labels, so map them at runtime instead of hardcoding CJK + * literals in this file. */ + for (const key of GROUP_ORDER) { + const label = zh["group." + key]; + if (label && !LAYER_GROUP[label]) LAYER_GROUP[label] = key; + } + function layerGroupOf(layer) { + if (LAYER_GROUP[layer]) return LAYER_GROUP[layer]; + /* zh labels the 0.2.0 layer as "0.2.0" while the host may append a suffix. */ + return String(layer || "").startsWith(zh["group.dsh20"]) ? "dsh20" : "code"; + } + + function patchRowsOf(state) { + if (!state || !state.ai_base) return []; + const fromList = Array.isArray(state.patches) ? state.patches : null; + const rows = fromList + ? fromList.map((p) => ({ + id: Number(p.id), + ids: Array.isArray(p.ids) && p.ids.length ? p.ids.map(Number) : [Number(p.id)], + st: p.status || "missing_file", + layer: p.layer || "", + title: p.title || "", + })) + : Object.keys(state.patch_status || {}).map((id) => ({ + id: Number(id), + ids: [Number(id)], + st: state.patch_status[id], + layer: "", + title: "", + })); + return rows.filter((r) => r.st && r.st !== "na" && Number.isFinite(r.id)); + } + + function patchIdLabel(r) { + const ids = Array.isArray(r.ids) && r.ids.length ? r.ids : [r.id]; + return ids.map((id) => "#" + id).join("/"); + } + + function patchTitle(t, state, r) { + if (r && r.title) return r.title; + const id = r && typeof r === "object" ? r.id : r; + const key = "patch." + id; + const hit = t(key); + if (hit && hit !== key) return hit; + const names = state && state.patch_names; + const name = names && (names[id] || names[String(id)]); + return name || key; + } + + function tallyPatches(rows) { + const out = { applied: 0, pending: 0, unmatched: 0, skipped: 0, total: 0 }; + for (const row of rows || []) { + out.total += 1; + if (row.st === "applied" || row.st === "already") out.applied += 1; + else if (row.st === "pending") out.pending += 1; + else if (row.st === "unmatched") out.unmatched += 1; + else out.skipped += 1; + } + return out; + } + + function PatchGroups({ state }) { + const t = useT(); + const [open, setOpen] = useState({}); + if (!state || !state.patch_status) return h("div", { className: "dshp-skel", style: { height: 120 } }); + const located = !!state.ai_base; + if (!located) return null; + const grouped = new Map(); + for (const row of patchRowsOf(state)) { + const key = layerGroupOf(row.layer); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(row); + } + return GROUP_ORDER.filter((key) => grouped.has(key)).map((key) => { + const rows = grouped.get(key).slice().sort((a, b) => a.id - b.id); + if (!rows.length) return null; + const tally = tallyPatches(rows); + const notes = []; + if (tally.pending) notes.push(t("metric.pendingShort", { n: tally.pending })); + if (tally.unmatched) notes.push(t("metric.unmatchedShort", { n: tally.unmatched })); + if (tally.skipped) notes.push(t("metric.skipShort", { n: tally.skipped })); + const expanded = !!open[key]; + return h("div", { key: key, className: "dshp-group" }, + h("button", { + type: "button", + className: "dshp-group-h", + "aria-expanded": expanded ? "true" : "false", + onClick: () => setOpen((prev) => Object.assign({}, prev, { [key]: !prev[key] })), + }, + h("strong", null, t("group." + key)), + h("span", { className: "dshp-count" }, tally.applied + "/" + tally.total + (notes.length ? " · " + notes.join(" · ") : "")), + ), + expanded ? h("table", { className: "dshp-table" }, + h("thead", null, h("tr", null, + h("th", null, "#"), + h("th", null, t("table.patch")), + h("th", null, t("table.status")), + )), + h("tbody", null, rows.map((r) => h("tr", { key: r.ids ? r.ids.join("-") : r.id }, + h("td", { className: "dshp-id" }, patchIdLabel(r)), + h("td", null, patchTitle(t, state, r)), + h("td", null, h(Pill, { value: r.st })), + ))), + ) : null, + ); + }); + } + + function HooksDenySection({ onSaved }) { + const t = useT(); + const [cfg, setCfg] = useState(null); + const [notice, setNotice] = useState({ kind: "idle", text: "" }); + const [busy, setBusy] = useState(false); + + useEffect(() => { + apiJson("/dsh-purge/hooks-deny") + .then((d) => { if (d && d.ok) setCfg({ hooksDenyBypass: d.hooksDenyBypass !== false }); }) + .catch(() => {}); + }, []); + + const save = (hooksDenyBypass) => { + if (!cfg || busy) return; + setCfg({ hooksDenyBypass }); + setBusy(true); + apiJson("/dsh-purge/hooks-deny", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hooksDenyBypass }), + }) + .then((d) => { + if (d && d.ok) { + setCfg({ hooksDenyBypass: d.hooksDenyBypass === true }); + setNotice({ kind: "ok", text: t("saved.hooksDeny") }); + if (typeof onSaved === "function") onSaved(); + } else { + setNotice({ kind: "error", text: t("err.save", { error: (d && d.error) || "" }) }); + } + }) + .catch((e) => setNotice({ kind: "error", text: t("err.save", { error: e.message }) })) + .finally(() => setBusy(false)); + }; + + if (!cfg) return null; + return h("div", { className: "dshp-cr", style: { marginTop: 12 } }, + h("div", { className: "dshp-sub" }, + h("h4", null, t("hooksDeny.title")), + noticeNode(notice), + ), + h("p", { className: "dshp-hint", style: { margin: "0 0 4px", color: "var(--dshp-mute)", fontSize: 12 } }, t("hooksDeny.hint")), + h("div", { className: "dshp-cr-row" }, + h("label", null, + h("input", { + type: "checkbox", + checked: cfg.hooksDenyBypass === true, + disabled: busy, + onChange: (e) => save(e.target.checked), + }), + t("hooksDeny.bypass"), + ), + ), + ); + } + + function ContinueRetrySection() { + const t = useT(); + const [cfg, setCfg] = useState(null); + const [notice, setNotice] = useState({ kind: "idle", text: "" }); + const [busy, setBusy] = useState(false); + + useEffect(() => { + apiJson("/dsh-purge/continue/settings") + .then((d) => { if (d && d.ok) setCfg(d); }) + .catch(() => {}); + }, []); + + const save = (patch) => { + if (!cfg || busy) return; + const next = { ...cfg, ...patch }; + if (next.autoRetry && !(Number(next.retryMax) > 0)) next.retryMax = 3; + if (next.autoContinue && !(Number(next.continueMax) > 0)) next.continueMax = 3; + setCfg(next); + setBusy(true); + apiJson("/dsh-purge/continue/settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(next), + }) + .then((d) => { + if (d && d.ok) { + setCfg(d); + setNotice({ kind: "ok", text: t("saved.continue") }); + } else { + setNotice({ kind: "error", text: t("err.save", { error: (d && d.error) || "" }) }); + } + }) + .catch((e) => setNotice({ kind: "error", text: t("err.save", { error: e.message }) })) + .finally(() => setBusy(false)); + }; + + if (!cfg) return null; + return h("div", { className: "dshp-cr" }, + h("div", { className: "dshp-sub" }, + h("h4", null, t("continue.title")), + noticeNode(notice), + ), + h("p", { className: "dshp-hint", style: { margin: "0 0 4px", color: "var(--dshp-mute)", fontSize: 12 } }, t("continue.hint")), + h("div", { className: "dshp-cr-row" }, + h("label", null, + h("input", { + type: "checkbox", + checked: Boolean(cfg.autoRetry), + disabled: busy, + onChange: (e) => save({ autoRetry: e.target.checked }), + }), + t("continue.autoRetry"), + ), + h("label", null, + t("continue.retryMax"), + h("input", { + className: "dshp-field dshp-cr-num", + type: "number", + min: 0, + max: 20, + value: cfg.retryMax, + disabled: busy, + onChange: (e) => save({ retryMax: Number(e.target.value) }), + }), + ), + ), + h("div", { className: "dshp-cr-row" }, + h("label", null, + h("input", { + type: "checkbox", + checked: Boolean(cfg.autoContinue), + disabled: busy, + onChange: (e) => save({ autoContinue: e.target.checked }), + }), + t("continue.autoContinue"), + ), + h("label", null, + t("continue.continueMax"), + h("input", { + className: "dshp-field dshp-cr-num", + type: "number", + min: 0, + max: 20, + value: cfg.continueMax, + disabled: busy, + onChange: (e) => save({ continueMax: Number(e.target.value) }), + }), + ), + h("label", null, + t("continue.text"), + h("input", { + className: "dshp-field dshp-cr-text", + type: "text", + value: cfg.continueText || "", + disabled: busy, + onChange: (e) => save({ continueText: e.target.value }), + }), + ), + ), + ); + } + + function RestartAsk(props) { + const node = h("div", { + className: "dshp-restart-mask", + "data-theme": props.theme === "dusk" ? "dusk" : "white", + role: "dialog", + "aria-modal": "true", + "aria-labelledby": "dshp-restart-title", + onClick: (e) => { if (e.target === e.currentTarget && props.onLater) props.onLater(); }, + }, + h("div", { className: "dshp-restart-card" }, + h("h4", { id: "dshp-restart-title" }, props.t("restart.ask.title")), + h("p", null, props.t("restart.ask.body")), + h("div", { className: "dshp-modal-ops" }, + h(Btn, { onClick: props.onLater }, props.t("restart.notNow")), + h(Btn, { kind: "primary", onClick: props.onRestart }, props.t("restart.now")), + ), + ), + ); + try { + const rd = require("react-dom"); + if (rd && typeof rd.createPortal === "function" && typeof document !== "undefined" && document.body) { + return rd.createPortal(node, document.body); + } + } catch { /* host 可能没暴露 react-dom */ } + return node; + } + + function PurgifySection() { + const t = useT(); + const tRef = useRef(t); + tRef.current = t; + const [state, setState] = useState(null); + const [override, setOverride] = useState(""); + const [defaultOverride, setDefaultOverride] = useState(""); + const [overrideLoaded, setOverrideLoaded] = useState(false); + const [ownServers, setOwnServers] = useState(""); + const [ownLoaded, setOwnLoaded] = useState(false); + const [patchBusy, setPatchBusy] = useState(false); + const [updateBusy, setUpdateBusy] = useState(false); + const [canApplyUpdate, setCanApplyUpdate] = useState(false); + const [updateJob, setUpdateJob] = useState(null); + const [askRestart, setAskRestart] = useState(false); + const suppressRestartAsk = useRef(false); + const [askUninstall, setAskUninstall] = useState(false); + const [uninstallBusy, setUninstallBusy] = useState(false); + const [notice, setNotice] = useState({ kind: "idle", text: "" }); + const [updateNotice, setUpdateNotice] = useState({ kind: "idle", text: "" }); + const [updateInfo, setUpdateInfo] = useState(null); + const [channel, setChannel] = useState("stable"); + const [pick, setPick] = useState({ stable: "", beta: "" }); + const actionTicket = useRef(0); + const overrideRef = useRef(""); + const defaultOverrideRef = useRef(""); + const promptSeededRef = useRef(false); + const promptDirtyRef = useRef(false); + const promptAreaRef = useRef(null); + overrideRef.current = override; + defaultOverrideRef.current = defaultOverride; + + const readPromptBox = () => { + const node = promptAreaRef.current; + const live = node && typeof node.value === "string" ? node.value : overrideRef.current; + overrideRef.current = live || ""; + return overrideRef.current; + }; + + const syncPicks = (d) => { + const versions = (d && d.versions) || []; + const ceiling = listedCeiling(versions, d && d.localVersion); + const next = { stable: "", beta: "" }; + for (const id of ["stable", "beta"]) { + const list = versions.filter((item) => item.channel === id && keepListedVersion(item, ceiling)); + const hit = preferListed(list, d && d.localVersion, d && d.pin); + next[id] = (hit && hit.ref) || ""; + } + setPick(next); + }; + + const updateErrorText = (tr, e) => { + const msg = String((e && e.message) || e || ""); + if (msg === "timeout" || /aborted|abort|超时/i.test(msg)) return tr("update.timeout"); + if (/\b404\b/.test(msg) || /non-json/.test(msg)) return tr("update.needRestart"); + return tr("update.fail", { error: msg }); + }; + + const applyUpdateInfo = useCallback((d, tr, kind) => { + if (d.channel) setChannel(d.channel); + setUpdateInfo((prev) => { + const versions = (d.versions && d.versions.length) ? d.versions : ((prev && prev.versions) || []); + return Object.assign({}, prev || {}, d, { versions }); + }); + if (d.versions && d.versions.length) syncPicks(d); + if (kind === "check") setCanApplyUpdate(Boolean(d.hasUpdate) && !d.error); + else if (kind === "done" || kind === "switched" || kind === "channel") setCanApplyUpdate(false); + const version = d.localVersion || d.localSha || "—"; + const remote = d.remoteVersion || d.remoteSha || "—"; + let text = tr("update.latest", { version }); + if (d.hasUpdate) text = tr("update.available", { remote, local: version }); + if (d.pinned && d.hasUpdate) text = tr("update.pinned", { version, remote }); + if (kind === "switched" || kind === "channel") text = tr("update.switched", { version: d.localVersion || d.remoteVersion || "—" }); + if (kind === "done") text = tr("update.done", { version: d.localVersion || d.remoteVersion || "—" }); + if (d.error) text = d.error; + setUpdateNotice({ kind: d.ok === false || d.error ? "error" : "ok", text }); + }, []); + + const checkUpdate = useCallback((id) => { + setUpdateBusy(true); + setUpdateJob({ id: id || "", kind: "check" }); + const tr = tRef.current; + setUpdateNotice({ kind: "ok", text: tr("update.checking") }); + apiJson("/dsh-purge/update") + .then((d) => { + if (!d || (!d.ok && !d.channel)) throw new Error((d && d.error) || "check failed"); + applyUpdateInfo(d, tr, "check"); + }) + .catch((e) => setUpdateNotice({ kind: "error", text: updateErrorText(tr, e) })) + .finally(() => { + setUpdateBusy(false); + setUpdateJob(null); + }); + }, [applyUpdateInfo]); + + const postUpdate = useCallback((body, kind, id) => { + setUpdateBusy(true); + setUpdateJob({ id: id || "", kind }); + const tr = tRef.current; + setUpdateNotice({ kind: "ok", text: kind === "switched" || kind === "channel" ? tr("update.switching") : tr("update.applying") }); + return apiJson("/dsh-purge/update", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body || {}), + }) + .then((d) => { + if (!d || (d.ok === false && d.error)) throw new Error((d && d.error) || "update failed"); + applyUpdateInfo(d, tr, kind); + if (d.needsFullQuit) { + suppressRestartAsk.current = false; + setAskRestart(true); + setUpdateNotice({ kind: "ok", text: tr("restart.official.body") }); + return d; + } + if (d.restartAfter) { + setUpdateNotice({ kind: "ok", text: tr("update.restarting") }); + return d; + } + if (d.applied && d.reloadClient !== false) { + setUpdateNotice({ kind: "ok", text: tr("update.reloading") }); + setTimeout(() => { + try { window.location.reload(); } catch { /* ignore */ } + }, 280); + } else if (d.needRestart) { + setAskRestart(true); + } + return d; + }) + .catch((e) => { + setUpdateNotice({ kind: "error", text: updateErrorText(tr, e) }); + throw e; + }) + .finally(() => { + setUpdateBusy(false); + setUpdateJob(null); + }); + }, [applyUpdateInfo]); + + const doUpdate = useCallback((id) => { + const lane = id === "beta" ? "beta" : "stable"; + postUpdate({ op: "apply", ref: lane === "beta" ? "beta" : "master", channel: lane }, "done", id).catch(() => {}); + }, [postUpdate]); + + const changeChannel = useCallback((next) => { + if (!next) return; + const tr = tRef.current; + if (!window.confirm(tr("update.confirmChannel", { version: tr("channel." + next) }))) return; + const prevChannel = channel; + const prevInfo = updateInfo; + setChannel(next); + setUpdateInfo((cur) => Object.assign({}, cur || {}, { channel: next })); + postUpdate({ op: "channel", channel: next }, "channel", next).catch(() => { + setChannel(prevChannel); + setUpdateInfo(prevInfo); + }); + }, [channel, postUpdate, updateInfo]); + + const switchSelected = useCallback((id) => { + const tr = tRef.current; + const ref = pick[id]; + const versions = ((updateInfo && updateInfo.versions) || []).filter((item) => item.channel === id); + const hit = versions.find((item) => item.ref === ref); + const label = (hit && (hit.label || hit.version)) || ref; + if (!ref) return; + if (!window.confirm(tr("update.confirmSwitch", { version: label }))) return; + const prevChannel = channel; + const prevInfo = updateInfo; + if (hit && hit.channel) { + setChannel(hit.channel); + setUpdateInfo((cur) => Object.assign({}, cur || {}, { channel: hit.channel })); + } + postUpdate({ op: "switch", ref }, "switched", id).catch(() => { + setChannel(prevChannel); + setUpdateInfo(prevInfo); + }); + }, [channel, pick, postUpdate, updateInfo]); + + const loadAll = useCallback(() => { + const tr = tRef.current; + apiJson("/dsh-purge/status") + .then((d) => { + if (d && d.ok) { + setState(d); + if (d.channel) setChannel(d.channel); + if (d.update && d.update.ok && !d.update.error) setUpdateInfo(d.update); + if (d.boot_full_quit && !suppressRestartAsk.current) setAskRestart(true); + } else { + setState({ ok: false, patches_total: 0, patches_applied: 0, patch_status: {}, shim_cmd: "n/a", shim_ps1: "n/a", shim_bin: "n/a", has_backup: false }); + setNotice({ kind: "error", text: tr("err.status", { error: (d && d.error) || "bad response" }) }); + } + }) + .catch((e) => { + setState({ ok: false, patches_total: 0, patches_applied: 0, patch_status: {}, shim_cmd: "n/a", shim_ps1: "n/a", shim_bin: "n/a", has_backup: false }); + setNotice({ kind: "error", text: tr("err.status", { error: e.message }) }); + }); + apiJson("/dsh-purge/update") + .then((d) => { + if (!d) return; + if (d.channel) setChannel(d.channel); + setUpdateInfo(d); + syncPicks(d); + }) + .catch(() => {}); + apiJson("/dsh-purge/override") + .then((d) => { + if (d && d.ok) { + const packed = typeof d.defaultContent === "string" ? d.defaultContent : ""; + const shown = typeof d.content === "string" ? d.content : packed; + if (packed) setDefaultOverride(packed); + if (!promptSeededRef.current || !promptDirtyRef.current) { + promptSeededRef.current = true; + setOverride(shown || packed); + } + setOverrideLoaded(true); + } + else setNotice({ kind: "error", text: tr("err.override", { error: (d && d.error) || "" }) }); + }) + .catch((e) => setNotice({ kind: "error", text: tr("err.override", { error: e.message }) })); + apiJson("/dsh-purge/own-servers") + .then((d) => { + if (d && d.ok) { + setOwnServers(typeof d.content === "string" ? d.content : ""); + setOwnLoaded(true); + } + }) + .catch(() => {}); + }, []); + + useEffect(() => { + loadAll(); + const timer = setTimeout(() => { loadAll(); }, 5000); + return () => clearTimeout(timer); + }, [loadAll]); + + const rejectNeedPrompt = useCallback((tr) => { + setAskRestart(false); + setNotice({ kind: "error", text: tr("need.prompt") }); + setTimeout(() => window.alert(tr("need.prompt")), 0); + }, []); + + const doAction = useCallback((action, actionKey) => { + const tr = tRef.current; + const label = tr(actionKey); + const ticket = ++actionTicket.current; + const run = () => { + setPatchBusy(true); + setAskRestart(false); + setNotice({ kind: "idle", text: "" }); + const applyContent = action === "apply" ? readPromptBox() : ""; + fetch("/dsh-purge/" + action, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(action === "apply" ? { content: applyContent } : {}), + }) + .then((r) => r.json()) + .then((d) => { + if (ticket !== actionTicket.current) return; + if (responseNeedsPrompt(d)) { + rejectNeedPrompt(tr); + return; + } + if (!d.ok) { + setNotice({ kind: "error", text: tr("err.action", { action: label, error: d.error || "" }) }); + return; + } + if (action === "apply") { + if (typeof d.defaultContent === "string") setDefaultOverride(d.defaultContent); + if (typeof d.override_content === "string") { + setOverride(d.override_content); + setOverrideLoaded(true); + } + } + if (action === "apply" && d.complete === false) { + const parts = []; + if (d.failed > 0) { + const ids = (d.failed_items || []) + .map((x) => x.id) + .filter((id) => id != null) + .join(","); + parts.push( + tr("restart.incomplete.patches", { + failed: String(d.failed || 0), + ids: ids || "?", + }), + ); + } + if (d.cmd_flash && d.cmd_flash.ok === false) { + parts.push( + tr("restart.incomplete.flash", { + entry: String(d.cmd_flash.entry || "fail"), + }), + ); + } + if (parts.length === 0) parts.push(tr("restart.incomplete.flash", { entry: "unknown" })); + setNotice({ + kind: "error", + text: tr("restart.incomplete", { detail: parts.join(";") }), + }); + loadAll(); + if (d.restarting || d.needs_full_quit) { + suppressRestartAsk.current = false; + setAskRestart(true); + } + return; + } + if (action === "apply") { + setNotice({ kind: "ok", text: tr("ok.done") }); + loadAll(); + suppressRestartAsk.current = false; + setAskRestart(true); + return; + } + setNotice({ kind: "ok", text: tr("ok.done") }); + loadAll(); + }) + .catch((e) => { + if (ticket !== actionTicket.current) return; + setNotice({ kind: "error", text: tr("err.action", { action: label, error: e.message }) }); + }) + .finally(() => { + if (ticket === actionTicket.current) setPatchBusy(false); + }); + }; + run(); + }, [loadAll, rejectNeedPrompt, state]); + + const saveOverride = useCallback(() => { + const tr = t; + const ticket = ++actionTicket.current; + const go = () => { + setPatchBusy(true); + setAskRestart(false); + setNotice({ kind: "idle", text: "" }); + const content = readPromptBox(); + fetch("/dsh-purge/override", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: content }), + }) + .then((r) => r.json()) + .then((d) => { + if (ticket !== actionTicket.current) return; + if (!d.ok || responseNeedsPrompt(d)) { + rejectNeedPrompt(tr); + return; + } + if (typeof d.content === "string") { + overrideRef.current = d.content; + setOverride(d.content); + } + promptDirtyRef.current = false; + setNotice({ kind: "ok", text: t("saved.override") }); + }) + .catch((e) => { + if (ticket !== actionTicket.current) return; + setNotice({ kind: "error", text: t("err.save", { error: e.message }) }); + }) + .finally(() => { + if (ticket === actionTicket.current) setPatchBusy(false); + }); + }; + bothInjectEmpty(readPromptBox(), defaultOverrideRef.current).then((empty) => { + if (ticket !== actionTicket.current) return; + if (empty) { + rejectNeedPrompt(tr); + return; + } + go(); + }); + }, [t, rejectNeedPrompt]); + + const restoreOverride = useCallback(() => { + if (!defaultOverride) return; + const tr = t; + const ticket = ++actionTicket.current; + overrideRef.current = defaultOverride; + setOverride(defaultOverride); + setPatchBusy(true); + setNotice({ kind: "idle", text: "" }); + apiJson("/dsh-purge/override", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: defaultOverride }), + }) + .then((d) => { + if (ticket !== actionTicket.current) return; + if (!d || !d.ok) { + setNotice({ kind: "error", text: tr("err.save", { error: (d && d.error) || "" }) }); + return; + } + if (typeof d.content === "string") { + overrideRef.current = d.content; + setOverride(d.content); + } + promptDirtyRef.current = false; + setNotice({ kind: "ok", text: tr("saved.restoreInject") }); + }) + .catch((e) => { + if (ticket !== actionTicket.current) return; + setNotice({ kind: "error", text: tr("err.save", { error: e.message }) }); + }) + .finally(() => { + if (ticket === actionTicket.current) setPatchBusy(false); + }); + }, [defaultOverride, t]); + + const waitHostAfterUninstall = useCallback((tr) => { + const started = Date.now(); + const ping = () => { + fetch("/", { cache: "no-store", credentials: "same-origin" }) + .then((r) => { + if (r.ok) { + try { + window.localStorage.removeItem("dshp-theme"); + window.localStorage.removeItem("dshp-theme-mode"); + } catch { /* ignore */ } + window.location.reload(); + return; + } + retry(); + }) + .catch(retry); + }; + const retry = () => { + if (Date.now() - started > 90000) { + setNotice({ kind: "error", text: tr("restart.timeout") }); + setUninstallBusy(false); + return; + } + setTimeout(ping, 500); + }; + setTimeout(ping, 800); + }, []); + + const doUninstall = useCallback(() => { + setUninstallBusy(true); + setAskUninstall(false); + setAskRestart(false); + const tr = tRef.current; + setNotice({ kind: "ok", text: tr("uninstall.applying") }); + apiJson("/dsh-purge/uninstall", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }) + .then((d) => { + if (!d || !d.ok) throw new Error((d && d.error) || "uninstall failed"); + setNotice({ kind: "ok", text: (d && d.note) || hostText(tr, "uninstall.done", d.surface || hostSurfaceOf(state)) }); + if ((d && d.surface) === "desktop" || (d && d.fullApp) || hostSurfaceOf(state) === "desktop") return; + waitHostAfterUninstall(tr); + }) + .catch((e) => { + const msg = String((e && e.message) || e || ""); + if (/failed to fetch|networkerror|load failed/i.test(msg)) { + setNotice({ kind: "ok", text: hostText(tr, "uninstall.done", hostSurfaceOf(state)) }); + if (hostSurfaceOf(state) === "desktop" || clientGuessSurface() === "desktop") return; + waitHostAfterUninstall(tr); + return; + } + setNotice({ kind: "error", text: tr("uninstall.fail", { error: msg }) }); + setUninstallBusy(false); + }); + }, [state, waitHostAfterUninstall]); + + const s = state; + const located = !!(s && s.ai_base); + const hostSurface = hostSurfaceOf(s); + const desktopLikely = hostSurface === "desktop" || clientGuessSurface() === "desktop"; + const sealedReady = !located && desktopLikely && !!(s && (s.asar_still_sealed || s.desktop_exe || s.desktop_install)); + const patchesLost = !located && !!(s && s.patches_lost); + const tally = located ? tallyPatches(patchRowsOf(s)) : { applied: 0, pending: 0, unmatched: 0, skipped: 0, total: 0 }; + const total = tally.total; + const applied = tally.applied; + const pct = located && total ? Math.round((applied / total) * 100) : 0; + const metricNotes = []; + if (tally.pending) metricNotes.push(t("metric.pendingShort", { n: tally.pending })); + if (tally.unmatched) metricNotes.push(t("metric.unmatchedShort", { n: tally.unmatched })); + if (tally.skipped) metricNotes.push(t("metric.skipShort", { n: tally.skipped })); + + const versions = (updateInfo && updateInfo.versions) || []; + const channelNow = (updateInfo && updateInfo.channel) || channel || "stable"; + const localVer = (s && s.plugin_version) || (updateInfo && updateInfo.localVersion) || ""; + const renderEdition = (id) => { + const lane = (updateInfo && updateInfo.lanes && updateInfo.lanes[id]) || {}; + const ceiling = listedCeiling(versions, localVer || (updateInfo && updateInfo.localVersion)); + const list = versions.filter((item) => item.channel === id && keepListedVersion(item, ceiling)); + const selectedRef = pick[id] || ""; + const hit = list.find((item) => item.ref === selectedRef) || preferListed(list, localVer, updateInfo && updateInfo.pin); + const onLane = channelNow === id; + const noBeta = id === "beta" && Array.isArray(updateInfo && updateInfo.versions) && !list.length; + const pickedOther = Boolean(hit && selectedRef && !hit.current); + const thisJob = updateJob && updateJob.id === id ? updateJob : null; + let actionLabel = t("btn.checkUpdate"); + let actionKind; + let actionClick = () => checkUpdate(id); + let actionDisabled = updateBusy || Boolean(noBeta); + if (noBeta) { + actionLabel = t("update.noBeta"); + actionClick = () => {}; + } else if (pickedOther) { + actionLabel = t("update.switch"); + actionKind = "primary"; + actionClick = () => switchSelected(id); + } else if (!onLane) { + actionLabel = id === "beta" ? t("channel.useBeta") : t("channel.useStable"); + actionKind = "primary"; + actionClick = () => changeChannel(id); + } else if (canApplyUpdate && lane.hasUpdate) { + actionLabel = t("btn.doUpdate"); + actionKind = "primary"; + actionClick = () => doUpdate(id); + } + if (thisJob) { + if (thisJob.kind === "check") actionLabel = t("btn.checkUpdate.busy"); + else if (thisJob.kind === "done") actionLabel = t("btn.doUpdate.busy"); + else actionLabel = t("update.switching"); + } + return h("div", { + className: "dshp-edition" + (onLane ? " is-on" : ""), + "aria-label": t("channel." + id), + }, + h("span", { className: "dshp-edition-name" }, + t("channel." + id), + onLane ? h("em", { className: "dshp-now-mark" }, t("update.current")) : null, + ), + h("select", { + className: "dshp-field dshp-ver", + value: list.length ? ((hit && hit.ref) || "") : "", + disabled: updateBusy || !list.length, + onChange: (e) => setPick((prev) => Object.assign({}, prev, { [id]: e.target.value })), + "aria-label": t("update.pick"), + }, + list.length + ? list.map((item) => h("option", { + key: item.ref + (item.sha || ""), + value: item.ref, + }, versionChoiceLabel(item, t))) + : h("option", { value: "" }, noBeta ? t("update.noBeta") : t("update.pick")), + ), + h(Btn, { tiny: true, kind: actionKind, disabled: actionDisabled, onClick: actionClick }, actionLabel), + ); + }; + return h("section", { className: "dshp-panel", "aria-label": t("purge.title") }, + h("div", { className: "dshp-head" }, + h("h3", { className: "dshp-title" }, t("purge.title")), + h("span", { className: "dshp-pill" }, t("surface." + hostSurface)), + ), + h("div", { className: "dshp-rel" }, + h("div", { className: "dshp-now" }, + h("span", { className: "dshp-now-label" }, t("channel.local")), + h("b", { className: "dshp-now-ver" }, localVer ? "v" + localVer : "—"), + h("em", { className: "dshp-now-mark" }, t("update.current")), + ), + h("div", { className: "dshp-editions" }, + renderEdition("stable"), + renderEdition("beta"), + ), + noticeNode(updateNotice), + ), + s ? h("div", { className: "dshp-metrics" }, + h("div", { className: "dshp-metric" }, + h("b", null, located ? (applied + " / " + total) : (patchesLost ? t("metric.lost.count") : (sealedReady ? t("metric.sealed.count") : t("metric.unlocated.count")))), + h("span", null, !located ? (patchesLost ? t("metric.lost") : (sealedReady ? t("metric.sealed") : t("metric.unlocated"))) : (metricNotes.length ? metricNotes.join(" · ") : t("metric.purged"))), + ), + h("div", { className: "dshp-metric" }, + h("b", { style: { fontSize: 13, fontFamily: "var(--dshp-mono)", fontWeight: 500 } }, + h("span", { className: "dshp-pill is-" + shimKind(s.shim_cmd) }, "cmd"), + " ", + h("span", { className: "dshp-pill is-" + shimKind(s.shim_ps1) }, "ps1"), + " ", + h("span", { className: "dshp-pill is-" + shimKind(s.shim_bin || "missing") }, "unix"), + ), + h("span", null, (s.shim_cmd === "n/a" && s.shim_ps1 === "n/a" && (s.shim_bin === "n/a" || !s.shim_bin)) ? t("metric.shim.na") : t("metric.shim")), + ), + h("div", { className: "dshp-metric" }, + h("b", null, s.has_backup ? t("metric.bak.yes") : t("metric.bak.no")), + h("span", null, t("metric.bak.hint")), + ), + ) : h("div", { className: "dshp-metrics" }, + h("div", { className: "dshp-metric" }, h("div", { className: "dshp-skel" }), h("div", { className: "dshp-skel", style: { width: "40%" } })), + h("div", { className: "dshp-metric" }, h("div", { className: "dshp-skel" }), h("div", { className: "dshp-skel", style: { width: "40%" } })), + h("div", { className: "dshp-metric" }, h("div", { className: "dshp-skel" }), h("div", { className: "dshp-skel", style: { width: "40%" } })), + ), + h("div", { className: "dshp-bar", "aria-hidden": "true" }, h("i", { style: { width: pct + "%" } })), + h(PatchGroups, { state: s }), + h(HooksDenySection, { onSaved: loadAll }), + h("div", { className: "dshp-row", style: { marginTop: 14 } }, + h(Btn, { kind: "primary", disabled: patchBusy || uninstallBusy || !overrideLoaded, onClick: () => doAction("apply", "action.apply") }, patchBusy ? t("btn.apply.busy") : t("btn.apply")), + h(Btn, { kind: "danger", disabled: patchBusy || uninstallBusy, onClick: () => doAction("revert", "action.revert") }, t("btn.revert")), + h(Btn, { kind: "solid-danger", disabled: patchBusy || uninstallBusy, onClick: () => setAskUninstall(true) }, uninstallBusy ? t("btn.uninstall.busy") : t("btn.uninstall")), + noticeNode(notice), + ), + askUninstall ? h("div", { + className: "dshp-modal-bg", + role: "dialog", + "aria-modal": "true", + "aria-labelledby": "dshp-uninstall-title", + onClick: (e) => { if (e.target === e.currentTarget && !uninstallBusy) setAskUninstall(false); }, + }, + h("div", { className: "dshp-modal" }, + h("h4", { id: "dshp-uninstall-title" }, t("uninstall.title")), + h("p", null, t("uninstall.body")), + h("div", { className: "dshp-modal-ops" }, + h(Btn, { disabled: uninstallBusy, onClick: () => setAskUninstall(false) }, t("uninstall.cancel")), + h(Btn, { kind: "solid-danger", disabled: uninstallBusy, onClick: doUninstall }, t("uninstall.confirm")), + ), + ), + ) : null, + h("p", { className: "dshp-hint", style: { margin: "8px 0 0", color: "var(--dshp-mute)", fontSize: 12 } }, t("apply.hint")), + s && !s.ai_base ? h("p", { className: "dshp-hint", style: { margin: "8px 0 0", color: "var(--dshp-danger, #c44)", fontSize: 12 } }, hostText(t, "warn.noRoot", hostSurface)) : null, + s && s.ai_base && s.override_status === "missing" ? h("p", { className: "dshp-hint", style: { margin: "8px 0 0", color: "var(--dshp-mute)", fontSize: 12 } }, t("warn.noInject")) : null, + askRestart ? h(RestartAsk, { + t: t, + theme: detectHostTheme(), + onLater: () => { + suppressRestartAsk.current = true; + setAskRestart(false); + }, + onRestart: () => { + suppressRestartAsk.current = true; + setAskRestart(false); + restartDsh(setNotice, function () {}, t, hostSurface); + }, + }) : null, + // h(ContinueRetrySection, null), + h("div", { className: "dshp-sub" }, + h("h4", null, t("override.title")), + h("div", { className: "dshp-row", style: { margin: 0 } }, + h(Btn, { kind: "primary", tiny: true, disabled: patchBusy || !overrideLoaded, onClick: saveOverride }, t("btn.saveInject")), + h(Btn, { tiny: true, disabled: patchBusy || !overrideLoaded || !defaultOverride, onClick: restoreOverride }, t("btn.restoreInject")), + ), + ), + h("textarea", { + className: "dshp-area", + ref: promptAreaRef, + value: override, + onChange: (e) => { + promptDirtyRef.current = true; + overrideRef.current = e.target.value; + setOverride(e.target.value); + }, + spellCheck: false, + placeholder: "", + }), + promptBoxEmpty(override) ? h("p", { + className: "dshp-hint", + style: { margin: "8px 0 0", color: "var(--dshp-danger, #c44)", fontSize: 12 }, + }, t("need.prompt")) : null, + h("div", { className: "dshp-sub" }, + h("h4", null, t("own.title")), + ), + h("p", { className: "dshp-hint", style: { margin: "8px 0 0", color: "var(--dshp-mute)", fontSize: 12 } }, t("own.hint")), + ); + } + + function ruleLabel(r) { + if (!r) return ""; + if (r.name !== r.id) return r.name + " (" + r.id + ")"; + return r.id; + } + + function RulesSection() { + const t = useT(); + const tRef = useRef(t); + tRef.current = t; + const [st, setSt] = useState(null); + const [editId, setEditId] = useState(null); + const [editName, setEditName] = useState(""); + const [editTarget, setEditTarget] = useState("AGENTS.md"); + const [content, setContent] = useState(""); + const [newId, setNewId] = useState(""); + const [newName, setNewName] = useState(""); + const [newTarget, setNewTarget] = useState("AGENTS.md"); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState({ kind: "idle", text: "" }); + + const loadStatus = useCallback(() => { + const tr = tRef.current; + let cwd = ""; + try { cwd = clientCurrentCwd(); } catch { /* ignore */ } + rulesApi("status", cwd ? { cwd: cwd } : undefined) + .then((d) => { + if (d && d.ok) setSt(d); + else { + setSt({ ok: false, rules: [], active: null }); + setNotice({ kind: "error", text: tr("err.rules", { error: (d && d.error) || "bad response" }) }); + } + }) + .catch((e) => { + setSt({ ok: false, rules: [], active: null }); + setNotice({ kind: "error", text: tr("err.rules", { error: e.message }) }); + }); + }, []); + + useEffect(() => { loadStatus(); }, [loadStatus]); + + const doPost = useCallback((action, payload, after) => { + setBusy(true); + setNotice({ kind: "idle", text: "" }); + rulesApi(action, payload) + .then((d) => { + if (d && d.ok) { + setNotice({ kind: "ok", text: t("ok.done") }); + if (action === "save" || action === "activate") warnNeedPrompt(d, t); + loadStatus(); + if (after) after(); + } else { + warnNeedPrompt(d, t); + setNotice({ kind: "error", text: t("err.action", { error: (d && d.error) || "" }) }); + } + }) + .catch((e) => setNotice({ kind: "error", text: t("err.action", { error: e.message }) })) + .finally(() => setBusy(false)); + }, [loadStatus, t]); + + const openRule = useCallback((id) => { + setBusy(true); + rulesApi("read", { id: id }) + .then((d) => { + if (d.ok) { + setEditId(id); + setEditName(d.name || id); + setEditTarget(d.target || "AGENTS.md"); + setContent(typeof d.content === "string" ? d.content : ""); + } else setNotice({ kind: "error", text: t("err.read", { error: d.error || "" }) }); + }) + .catch((e) => setNotice({ kind: "error", text: t("err.read", { error: e.message }) })) + .finally(() => setBusy(false)); + }, [t]); + + const clearEditor = () => { + setEditId(null); + setEditName(""); + setEditTarget("AGENTS.md"); + setContent(""); + }; + + let list; + if (!st) { + list = h("div", { className: "dshp-skel", style: { height: 80, margin: 12 } }); + } else if (!st.rules || st.rules.length === 0) { + list = h("p", { className: "dshp-empty" }, t("rules.empty")); + } else { + list = st.rules.map((r) => { + const isActive = r.id === st.active; + const isEdit = r.id === editId; + return h("div", { + key: r.id, + className: "dshp-ruleitem" + (isEdit ? " is-edit" : ""), + role: "button", + tabIndex: 0, + onClick: () => openRule(r.id), + onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openRule(r.id); } }, + }, + h("div", { className: "dshp-rule-main" }, + h("span", { className: "dshp-rule-name" }, + ruleLabel(r), + isActive ? h("span", { className: "dshp-pill is-ok" }, t("pill.current")) : null, + ), + h("span", { className: "dshp-rule-meta" }, r.target + " · " + formatSize(r.size)), + ), + h("div", { className: "dshp-rule-ops" }, + h(Btn, { + tiny: true, + kind: isActive ? undefined : "primary", + disabled: busy || isActive, + onClick: (e) => { + e.stopPropagation(); + if (!isActive) doPost("activate", { id: r.id }); + }, + }, isActive ? t("btn.inUse") : t("btn.use")), + h(Btn, { + tiny: true, + kind: "danger", + disabled: busy, + onClick: (e) => { + e.stopPropagation(); + if (!window.confirm(t("confirm.delete", { id: r.id }))) return; + if (editId === r.id) clearEditor(); + doPost("delete", { id: r.id }); + }, + }, t("delete")), + ), + ); + }); + } + + const policyValue = (st && typeof st.workspace_policy === "string") ? st.workspace_policy : "fallback"; + const policyOptions = (st && Array.isArray(st.workspace_policies) && st.workspace_policies.length) ? st.workspace_policies : ["fallback", "strict", "always"]; + const cwdCandidates = (st && Array.isArray(st.cwd_candidates)) ? st.cwd_candidates : []; + const cwdValue = (st && typeof st.cwd === "string") ? st.cwd : ""; + return h("section", { className: "dshp-panel", "aria-label": t("rules.title") }, + h("div", { className: "dshp-head" }, + h("h3", { className: "dshp-title" }, t("rules.title")), + h("div", { className: "dshp-row", style: { margin: 0 } }, + noticeNode(notice), + h(Btn, { + tiny: true, + kind: "danger", + disabled: busy, + onClick: () => { + if (!window.confirm(t("rules.reset.confirm"))) return; + clearEditor(); + doPost("reset", {}); + }, + }, t("rules.reset")), + ), + ), + h("div", { className: "dshp-block" }, + h("div", { className: "dshp-policy-row" }, + h("label", { htmlFor: "dshp-policy-select" }, t("policy.title")), + h("select", { + id: "dshp-policy-select", + className: "dshp-field", + value: policyValue, + disabled: busy || !st, + onChange: (e) => doPost("set-policy", { policy: e.target.value }), + }, policyOptions.map((p) => h("option", { key: p, value: p }, t("policy." + p)))), + ), + h("p", { className: "dshp-block-hint" }, t("policy.hint")), + ), + h("div", { className: "dshp-block" }, + h("div", { className: "dshp-block-head" }, + h("h4", null, t("cwd.title")), + h(Btn, { + tiny: true, + disabled: busy, + onClick: () => { + let cwd = cwdValue; + if (!cwd) { try { cwd = clientCurrentCwd(); } catch { /* ignore */ } } + doPost("scan-cwd", cwd ? { cwd: cwd } : {}); + }, + }, t("cwd.rescan")), + ), + cwdValue ? h("p", { className: "dshp-cwd-path" }, cwdValue) : null, + (!st || !cwdValue) + ? h("p", { className: "dshp-empty" }, t("cwd.no.workspace")) + : (cwdCandidates.length === 0 + ? h("p", { className: "dshp-empty" }, t("cwd.empty")) + : h("div", { className: "dshp-cwd-list" }, cwdCandidates.map((item) => h("label", { + key: item.path, + className: "dshp-cwd-item" + (item.selected ? " is-on" : ""), + }, + h("input", { + type: "checkbox", + checked: Boolean(item.selected), + disabled: busy, + onChange: (e) => doPost("toggle-cwd", { path: item.path, enabled: e.target.checked }), + }), + h("div", { className: "dshp-cwd-main" }, + h("div", { className: "dshp-cwd-name" }, + h("span", null, item.name), + h("span", { className: "dshp-cwd-badge" }, t("cwd.scope." + (item.scope || "cwd"))), + h("span", { className: "dshp-cwd-size" }, formatSize(item.size || 0)), + ), + h("div", { className: "dshp-cwd-sub" }, item.path), + item.preview ? h("div", { className: "dshp-cwd-prev" }, item.preview) : null, + ), + )))), + h("p", { className: "dshp-block-hint" }, t("cwd.hint")), + ), + h("div", { className: "dshp-block" }, + h("div", { className: "dshp-split" }, + h("div", { className: "dshp-rulelist" }, + h("div", { className: "dshp-rulebody" }, list), + h("div", { className: "dshp-create" }, + h("div", { className: "dshp-create-row" }, + h("input", { className: "dshp-field", placeholder: t("ph.id"), value: newId, onChange: (e) => setNewId(e.target.value) }), + h("input", { className: "dshp-field", placeholder: t("ph.alias"), value: newName, onChange: (e) => setNewName(e.target.value) }), + h("select", { className: "dshp-field", style: { minWidth: 120, flex: "0 0 auto" }, value: newTarget, onChange: (e) => setNewTarget(e.target.value) }, + TARGETS.map((x) => h("option", { key: x, value: x }, x))), + h(Btn, { + kind: "primary", + tiny: true, + disabled: busy, + onClick: () => { + const id = newId.trim(); + if (!id) { setNotice({ kind: "error", text: t("need.id") }); return; } + const name = newName.trim() || id; + const target = newTarget; + doPost("save", { id, content: "", name, target }, () => { + setEditId(id); + setEditName(name); + setEditTarget(target); + setContent(""); + }); + setNewId(""); setNewName(""); setNewTarget("AGENTS.md"); + }, + }, t("btn.create")), + ), + ), + ), + h("div", { className: "dshp-editor" }, + editId ? [ + h("div", { key: "meta", className: "dshp-row" }, + h("input", { + className: "dshp-field", placeholder: t("ph.alias.short"), value: editName, + onChange: (e) => setEditName(e.target.value), + }), + h("select", { + className: "dshp-field", style: { minWidth: 120 }, value: editTarget, + onChange: (e) => setEditTarget(e.target.value), + }, TARGETS.map((x) => h("option", { key: x, value: x }, x))), + ), + h("textarea", { + key: "body", + className: "dshp-area", value: content, + onChange: (e) => setContent(e.target.value), + placeholder: "", + }), + h("div", { key: "save", className: "dshp-row" }, + h(Btn, { + kind: "primary", + disabled: busy, + onClick: () => doPost("save", { id: editId, content, name: editName, target: editTarget }), + }, t("btn.saveRule")), + noticeNode(notice), + ), + ] : h("div", { className: "dshp-editor-empty" }, t("rules.pick")), + ), + ), + ), + ); + } + + function skillUiAllowed(d) { + if (!d) return false; + const ver = String(d.plugin_version || d.localVersion || "").replace(/^v/i, ""); + if (/^1\.1\.11(?:-|$)/.test(ver)) return false; + const parts = ver.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!parts) return false; + if (+parts[1] !== 1) return +parts[1] > 1; + if (+parts[2] !== 1) return +parts[2] > 1; + return +parts[3] >= 12; + } + + function splitListedVersion(raw) { + const text = String(raw || "").trim().replace(/^v/i, ""); + const cut = text.split("-"); + const core = String(cut[0] || "").split(".").map((n) => parseInt(n, 10) || 0); + return { core: [core[0] || 0, core[1] || 0, core[2] || 0], pre: cut.slice(1).join("-") }; + } + + function listedVersionNewer(remote, local) { + if (!remote || !local || String(remote) === String(local)) return false; + const a = splitListedVersion(remote); + const b = splitListedVersion(local); + for (let i = 0; i < 3; i += 1) { + if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i]; + } + if (a.pre && !b.pre) return false; + if (!a.pre && b.pre) return true; + return a.pre > b.pre; + } + + function listedCeiling(versions, localVer) { + let best = ""; + const rows = (versions || []).slice(); + const local = String(localVer || "").replace(/^v/i, ""); + if (local && !/(?:^|[-._])(beta|rc|pre|preview|test)(?:\d|$|[-._])/i.test(local)) { + rows.push({ channel: "stable", version: local }); + } + for (const item of rows) { + if (!item || item.channel === "beta") continue; + const ver = String(item.version || "").replace(/^v/i, ""); + if (!/^\d+\.\d+\.\d+/.test(ver) || /(?:^|[-._])(beta|rc|pre|preview|test)(?:\d|$|[-._])/i.test(ver)) continue; + if (!best || listedVersionNewer(ver, best)) best = ver; + } + return best; + } + + function keepListedVersion(item, _ceiling) { + const ver = String((item && (item.version || item.ref || item.id)) || ""); + if (!item) return false; + if (item.ref === "beta" || item.id === "beta") return true; + if (item.channel === "beta") return false; + if (/(?:^|[-._])(beta|rc|pre|preview|test)(?:\d|$|[-._])/i.test(ver)) return false; + return true; + } + + function preferListed(list, localVer, pin) { + const pinned = pin ? (list || []).find((item) => item.ref === pin) : null; + if (pinned) return pinned; + const ver = String(localVer || "").replace(/^v/i, ""); + const same = (list || []).filter((item) => ver && String(item.version || "").replace(/^v/i, "") === ver); + if (same.length) { + return same.find((item) => item.current) + || same.find((item) => item.latest) + || same[0]; + } + return (list || []).find((item) => item.current) + || (list || []).find((item) => item.latest) + || (list || [])[0] + || null; + } + + function versionChoiceLabel(item, tr) { + const ver = String((item && item.version) || "").replace(/^v/i, ""); + const base = ver ? ("v" + ver) : ((item && (item.label || item.ref)) || "—"); + if (item && item.current) return base + " · " + tr("update.current"); + if (item && item.latest) return base + " · " + tr("update.tip"); + return base; + } + + function SkillsSection() { + const t = useT(); + const tRef = useRef(t); + tRef.current = t; + const [onBeta, setOnBeta] = useState(false); + const [st, setSt] = useState(null); + const [editId, setEditId] = useState(null); + const [editDesc, setEditDesc] = useState(""); + const [content, setContent] = useState(""); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState({ kind: "idle", text: "" }); + const zipRef = useRef(null); + const folderRef = useRef(null); + + const loadStatus = useCallback(() => { + const tr = tRef.current; + const fail = (e) => { + setSt({ ok: false, skills: [] }); + setNotice({ + kind: "error", + text: skillsRouteMissing(e) ? tr("err.skills.needRestart") : tr("err.skills", { error: (e && e.message) || e || "bad response" }), + }); + }; + const apply = (d) => { + if (d && d.ok) setSt({ ok: true, skills: d.skills || [] }); + else fail({ message: (d && d.error) || "bad response" }); + }; + apiJson("/dsh-purge/status") + .then((d) => { + const allowed = skillUiAllowed(d); + setOnBeta(allowed); + if (!allowed) { + setSt(null); + return; + } + if (d.skills) { + apply({ ok: true, skills: d.skills }); + return; + } + skillsApi("status").then(apply).catch(fail); + }) + .catch(() => { + setOnBeta(false); + setSt(null); + }); + }, []); + + useEffect(() => { loadStatus(); }, [loadStatus]); + + const doPost = useCallback((action, payload, after) => { + setBusy(true); + setNotice({ kind: "idle", text: "" }); + skillsApi(action, payload) + .then((d) => { + if (d && d.ok) { + setNotice({ kind: "ok", text: t("ok.done") }); + loadStatus(); + if (after) after(d); + } else { + setNotice({ kind: "error", text: t("err.action", { error: (d && d.error) || "" }) }); + } + }) + .catch((e) => setNotice({ + kind: "error", + text: skillsRouteMissing(e) ? t("err.skills.needRestart") : t("err.action", { error: e.message }), + })) + .finally(() => setBusy(false)); + }, [loadStatus, t]); + + const openSkill = useCallback((id) => { + setBusy(true); + skillsApi("read", { id: id }) + .then((d) => { + if (d.ok) { + setEditId(id); + setEditDesc(d.description || ""); + setContent(typeof d.content === "string" ? d.content : ""); + } else setNotice({ kind: "error", text: t("err.read", { error: d.error || "" }) }); + }) + .catch((e) => setNotice({ kind: "error", text: t("err.read", { error: e.message }) })) + .finally(() => setBusy(false)); + }, [t]); + + const clearEditor = () => { + setEditId(null); + setEditDesc(""); + setContent(""); + }; + + const readAsBase64 = (file) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const text = String(reader.result || ""); + const at = text.indexOf(","); + resolve(at >= 0 ? text.slice(at + 1) : text); + }; + reader.onerror = () => reject(new Error(t("need.import"))); + reader.readAsDataURL(file); + }); + + const importZip = (file) => { + if (!file) { + setNotice({ kind: "error", text: t("need.import") }); + return; + } + setBusy(true); + setNotice({ kind: "ok", text: t("skills.importing") }); + readAsBase64(file) + .then((data) => skillsApi("import", { data, name: file.name })) + .then((d) => { + if (!d || !d.ok) throw new Error((d && d.error) || ""); + setNotice({ kind: "ok", text: t("saved.import", { count: (d.imported || []).length || 1 }) }); + loadStatus(); + }) + .catch((e) => setNotice({ + kind: "error", + text: skillsRouteMissing(e) ? t("err.skills.needRestart") : t("err.action", { error: e.message }), + })) + .finally(() => setBusy(false)); + }; + + const keepFolderFile = (file) => { + const rel = String(file.webkitRelativePath || file.name || "").replace(/\\/g, "/"); + if (!rel || rel.includes("..")) return false; + if (/(^|\/)(node_modules|\.git|__MACOSX|\.system)(\/|$)/i.test(rel)) return false; + if (/(^|\/)\._/.test(rel) || /\/\.DS_Store$/i.test(rel)) return false; + return true; + }; + + const importFolder = (list) => { + const files = Array.from(list || []).filter(keepFolderFile); + if (!files.length) { + setNotice({ kind: "error", text: t("err.import.folder") }); + return; + } + setBusy(true); + setNotice({ kind: "ok", text: t("skills.importing") }); + Promise.all(files.map((file) => readAsBase64(file).then((content) => ({ + path: file.webkitRelativePath || file.name, + content, + encoding: "base64", + })))) + .then((payload) => skillsApi("import", { + files: payload, + name: String(payload[0] && payload[0].path || "").split("/")[0], + })) + .then((d) => { + if (!d || !d.ok) throw new Error((d && d.error) || ""); + setNotice({ kind: "ok", text: t("saved.import", { count: (d.imported || []).length || 1 }) }); + loadStatus(); + }) + .catch((e) => setNotice({ + kind: "error", + text: skillsRouteMissing(e) ? t("err.skills.needRestart") : t("err.action", { error: e.message }), + })) + .finally(() => setBusy(false)); + }; + + if (!onBeta) return null; + + let list; + if (!st) { + list = h("div", { className: "dshp-skel", style: { height: 80, margin: 12 } }); + } else if (!st.skills || st.skills.length === 0) { + list = h("p", { className: "dshp-empty" }, t("skills.empty")); + } else { + list = st.skills.map((item) => { + const isEdit = item.id === editId; + return h("div", { + key: item.id, + className: "dshp-ruleitem" + (isEdit ? " is-edit" : ""), + role: "button", + tabIndex: 0, + onClick: () => openSkill(item.id), + onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSkill(item.id); } }, + }, + h("div", { className: "dshp-rule-main" }, + h("span", { className: "dshp-rule-name" }, item.id), + h("span", { className: "dshp-rule-meta" }, + (item.valid ? item.description : t("skills.invalid")) + " · " + formatSize(item.size), + ), + item.valid ? h("span", { className: "dshp-rule-meta" }, + [ + item.userInvocable ? t("skills.call.slash", { cmd: "/" + item.id }) : t("skills.call.userOff"), + item.modelInvocable ? t("skills.call.model") : t("skills.call.modelOff"), + ].join(" · "), + ) : null, + ), + h("div", { className: "dshp-rule-ops" }, + h(Btn, { + tiny: true, + kind: "danger", + disabled: busy, + onClick: (e) => { + e.stopPropagation(); + if (!window.confirm(t("confirm.delete.skill", { id: item.id }))) return; + if (editId === item.id) clearEditor(); + doPost("delete", { id: item.id }); + }, + }, t("delete")), + ), + ); + }); + } + + return h("section", { className: "dshp-panel", "aria-label": t("skills.title") }, + h("div", { className: "dshp-head" }, + h("h3", { className: "dshp-title" }, t("skills.title")), + h("div", { className: "dshp-row", style: { margin: 0 } }, noticeNode(notice)), + ), + h("p", { className: "dshp-hint", style: { margin: "0 0 10px", color: "var(--dshp-mute)", fontSize: 12 } }, hostText(t, "skills.hint", hostSurfaceOf())), + h("div", { className: "dshp-split" }, + h("div", { className: "dshp-rulelist" }, + h("div", { className: "dshp-rulebody" }, list), + h("div", { className: "dshp-create" }, + h("div", { className: "dshp-create-row" }, + h("input", { + ref: zipRef, + type: "file", + accept: ".zip,.tgz,.tar.gz,.tar,.skill", + style: { display: "none" }, + onChange: (e) => { + const file = e.target.files && e.target.files[0]; + e.target.value = ""; + importZip(file); + }, + }), + h("input", { + ref: (el) => { + folderRef.current = el; + if (!el) return; + el.setAttribute("webkitdirectory", ""); + el.setAttribute("directory", ""); + el.multiple = true; + }, + type: "file", + multiple: true, + style: { display: "none" }, + onChange: (e) => { + const files = Array.from((e.target && e.target.files) || []); + e.target.value = ""; + importFolder(files); + }, + }), + h(Btn, { + kind: "primary", + tiny: true, + disabled: busy, + onClick: () => zipRef.current && zipRef.current.click(), + }, t("btn.importZip")), + h(Btn, { + tiny: true, + disabled: busy, + onClick: () => folderRef.current && folderRef.current.click(), + }, t("btn.importFolder")), + ), + ), + ), + h("div", { className: "dshp-editor" }, + editId ? [ + h("div", { key: "meta", className: "dshp-row" }, + h("input", { + className: "dshp-field", + placeholder: t("ph.skill.desc"), + value: editDesc, + onChange: (e) => setEditDesc(e.target.value), + }), + ), + h("textarea", { + key: "body", + className: "dshp-area", + value: content, + onChange: (e) => setContent(e.target.value), + spellCheck: false, + placeholder: "", + }), + h("div", { key: "save", className: "dshp-row" }, + h(Btn, { + kind: "primary", + disabled: busy, + onClick: () => doPost("save", { id: editId, content, description: editDesc }), + }, t("btn.saveSkill")), + noticeNode(notice), + ), + ] : h("div", { className: "dshp-editor-empty" }, t("skills.pick")), + ), + ), + ); + } + + function reopenAfterRestart() { + window.location.reload(); + } + + function waitForRestart(setNotice, setBusy, t) { + const started = Date.now(); + // 必须先看到旧进程掉线,再等新进程就绪;否则同进程立刻 200 会被当成「重启成功」又弹重启。 + let sawDown = false; + const ping = () => { + fetch("/dsh-purge/status", { cache: "no-store", credentials: "same-origin" }) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d) => { + if (!sawDown) { + retry(); + return; + } + if (d && d.ok && d.ready !== false) reopenAfterRestart(); + else retry(); + }) + .catch(() => { + sawDown = true; + retry(); + }); + }; + const retry = () => { + if (Date.now() - started > 90000) { + setNotice({ kind: "error", text: t("restart.timeout") }); + setBusy(false); + return; + } + setTimeout(ping, 500); + }; + setTimeout(ping, 800); + } + + let restartOnce = false; + + function restartDsh(setNotice, setBusy, t, surface) { + if (restartOnce) return; + const surf = surface || clientGuessSurface() || "desktop"; + const desktop = surf === "desktop" || clientGuessSurface() === "desktop"; + restartOnce = true; + setBusy(true); + setNotice({ kind: "ok", text: hostText(t, "restarting", surf) }); + const stop = (error) => { + restartOnce = false; + setNotice({ kind: "error", text: error ? t("restart.fail", { error }) : t("restart.notStarted") }); + setBusy(false); + }; + let sawDrop = false; + const watch = setTimeout(() => { + if (sawDrop) return; + stop(""); + }, 25000); + fetch("/dsh-purge/restart", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }) + .then((r) => r.json()) + .then((d) => { + if (!d.ok) throw new Error(d.error || "restart failed"); + const next = (d && d.surface) || surf; + const full = next === "desktop" || (d && d.fullApp) || desktop; + if (full && d.already) { + setNotice({ kind: "ok", text: hostText(t, "restarting", next) }); + return; + } + if (full && d.restarting === false) { + clearTimeout(watch); + stop(d.error || "not started"); + return; + } + setNotice({ kind: "ok", text: hostText(t, "restarting", next) }); + if (full) return; + clearTimeout(watch); + restartOnce = false; + waitForRestart(setNotice, setBusy, t); + }) + .catch((e) => { + const msg = String((e && e.message) || e || ""); + const dropped = /failed to fetch|networkerror|load failed/i.test(msg) || e.name === "TypeError"; + if (dropped && desktop) { + sawDrop = true; + clearTimeout(watch); + return; + } + clearTimeout(watch); + stop(msg || "restart failed"); + }); + } + + function detectHostTheme() { + try { + if (typeof document !== "undefined") { + const root = document.documentElement; + if (document.body?.hasAttribute("data-ds-dark-theme") || root?.hasAttribute("data-ds-dark-theme")) return "dusk"; + const scheme = (root.style.colorScheme || getComputedStyle(root).colorScheme || getComputedStyle(root).getPropertyValue("color-scheme") || "").toLowerCase(); + if (scheme.includes("dark")) return "dusk"; + if (scheme.includes("light")) return "white"; + } + if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches) { + return "dusk"; + } + } catch { /* ignore */ } + return "white"; + } + + function readTheme() { + return detectHostTheme(); + } + + function SettingsRoot(props) { + const t = typeof props.t === "function" ? props.t : ((key) => key); + const [theme, setTheme] = useState(readTheme); + useEffect(() => { + let cancelled = false; + let timer = 0; + const syncFromHost = () => { + if (!cancelled) setTheme(detectHostTheme()); + }; + const syncSoon = () => { + if (timer) window.clearTimeout(timer); + timer = window.setTimeout(syncFromHost, 50); + }; + syncFromHost(); + let mq; + try { + mq = window.matchMedia?.("(prefers-color-scheme: dark)"); + mq?.addEventListener?.("change", syncFromHost); + } catch { /* ignore */ } + let obs; + try { + obs = new MutationObserver(syncSoon); + if (document.body) obs.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme", "class", "style"] }); + obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-ds-dark-theme", "class", "style"] }); + } catch { /* ignore */ } + return () => { + cancelled = true; + if (timer) window.clearTimeout(timer); + try { mq?.removeEventListener?.("change", syncFromHost); } catch { /* ignore */ } + try { obs?.disconnect(); } catch { /* ignore */ } + }; + }, []); + translate = t; + return h("div", { className: "dshp-root", "data-theme": theme }, + h("style", null, PURGE_CSS), + h(PurgifySection, null), + h(RulesSection, null), + ); + } + + const REWIND_CSS = ` +.dshp-rewind-wrap{position:relative;display:inline-flex} +.dshp-rewind{appearance:none;display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,currentColor);font:12px/1 var(--ds-font-sans,system-ui,sans-serif);cursor:pointer} +.dshp-rewind:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,currentColor)} +.dshp-rewind:disabled{opacity:.4;cursor:default} +.dshp-rewind svg{display:block} +.dshp-rewind-menu{position:fixed;z-index:2147483000;box-sizing:border-box;min-width:220px;padding:4px;border:0;border-radius:12px;background:var(--dsw-specific-menu,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent,0 8px 24px rgba(0,0,0,.16));transform:translateY(-100%)} +body[data-ds-dark-theme] .dshp-rewind-menu{background:var(--dsw-specific-menu,#32312d);color:var(--dsw-alias-label-primary,#e6e2db);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1,#3f3d38);box-shadow:var(--dsw-elevation-prominent,0 10px 28px rgba(0,0,0,.45))} +.dshp-rewind-item{display:flex;flex-direction:column;gap:2px;width:100%;padding:8px 10px;border:0;border-radius:8px;background:transparent;color:inherit;text-align:left;cursor:pointer;font:12px/1.3 var(--ds-font-sans,system-ui,sans-serif)} +.dshp-rewind-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))} +.dshp-rewind-item b{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)} +.dshp-rewind-item span{color:var(--dsw-alias-label-tertiary,#9a958c);font-size:11px} +body[data-ds-dark-theme] .dshp-rewind-item span{color:var(--dsw-alias-label-tertiary,#a8a39a)} +.dshp-continue{appearance:none;display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,currentColor);font:12px/1 var(--ds-font-sans,system-ui,sans-serif);cursor:pointer} +.dshp-continue:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,currentColor)} +.dshp-continue:disabled{opacity:.4;cursor:default} +.dshp-continue.is-ready{color:var(--dsw-alias-label-primary,currentColor)} +.dshp-continue svg{display:block} +`; + const DRAFT_KEY = "dshp-rewind-draft:"; + const ARM_KEY = "dshp-rewind-arm:"; + const FILL_DELAYS = [0, 50, 180, 400, 800]; + const ARM_TTL_MS = 45 * 1000; + let rewindSeenAt = 0; + let rewindSessions = null; + let rewindHost = null; + let rewindWorkspace = null; + let rewindConversation = null; + let pendingComposer = { sessionId: "", text: "", at: 0 }; + let armedFill = { sessionId: "", text: "", at: 0 }; + + function rewindText(t, key, fallback) { + try { + if (typeof t === "function") { + const value = t(key); + if (value && value !== key) return value; + } + } catch { /* ignore */ } + return fallback; + } + + function currentSessionId(sessions) { + try { + const selected = rewindWorkspace?.selection?.getSnapshot?.()?.sessionId; + if (typeof selected === "string" && selected) return selected; + } catch { /* ignore */ } + try { + const snap = sessions?.list?.getSnapshot?.(); + if (typeof snap?.current === "string" && snap.current) return snap.current; + } catch { /* ignore */ } + return ""; + } + + function sessionKnown(sessions, sessionId) { + try { + const snap = sessions?.list?.getSnapshot?.(); + if (!snap || !sessionId) return false; + if (snap.byId && Object.prototype.hasOwnProperty.call(snap.byId, sessionId)) return true; + if (Array.isArray(snap.ids) && snap.ids.includes(sessionId)) return true; + } catch { /* ignore */ } + return false; + } + + function waitMs(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); + } + + function isPluginDraft(text) { + const value = String(text || "").trim(); + if (!value) return false; + return /^\[MNEMON\]/i.test(value) + || /MNEMON RUNTIME MEMORY SNAPSHOT/i.test(value) + || /^MNEMON VIEW TOOLS/im.test(value); + } + + function writeDraft(sessionId, text) { + if (!sessionId) return; + const value = isPluginDraft(text) ? "" : (text || ""); + pendingComposer = { sessionId, text: value, at: Date.now() }; + try { sessionStorage.setItem(DRAFT_KEY + sessionId, value); } catch { /* ignore */ } + } + + function peekDraft(sessionId) { + if (!sessionId) return ""; + if (pendingComposer.sessionId === sessionId && pendingComposer.text) return pendingComposer.text; + try { return sessionStorage.getItem(DRAFT_KEY + sessionId) || ""; } catch { return ""; } + } + + function clearStoredDraft(sessionId) { + if (!sessionId) return; + if (pendingComposer.sessionId === sessionId) pendingComposer = { sessionId: "", text: "", at: 0 }; + try { sessionStorage.removeItem(DRAFT_KEY + sessionId); } catch { /* ignore */ } + try { sessionStorage.removeItem(ARM_KEY + sessionId); } catch { /* ignore */ } + } + + function armComposerFill(sessionId, text) { + if (!sessionId || !text || isPluginDraft(text)) return; + const at = Date.now(); + armedFill = { sessionId, text, at }; + writeDraft(sessionId, text); + try { sessionStorage.setItem(ARM_KEY + sessionId, String(at)); } catch { /* ignore */ } + } + + function takeArmedFill(sessionId) { + if (!sessionId) return ""; + let text = ""; + let at = 0; + if (armedFill.sessionId === sessionId && armedFill.text) { + text = armedFill.text; + at = armedFill.at; + } else { + text = peekDraft(sessionId); + try { at = Number(sessionStorage.getItem(ARM_KEY + sessionId) || 0); } catch { at = 0; } + } + if (!text || !at || Date.now() - at > ARM_TTL_MS) { + if (text && (!at || Date.now() - at > ARM_TTL_MS)) clearStoredDraft(sessionId); + return ""; + } + clearStoredDraft(sessionId); + armedFill = { sessionId: "", text: "", at: 0 }; + return text; + } + + function conversationFace() { + if (rewindConversation) return rewindConversation; + const host = rewindHost; + if (host?.conversation) return host.conversation; + if (typeof host?.get === "function") { + try { return host.get("conversation"); } catch { return undefined; } + } + return undefined; + } + + function conversationInput(sessionId) { + try { + const input = conversationFace()?.input; + if (!input || !sessionId) return null; + if (typeof input.shell === "function") { + try { return input.shell(sessionId); } catch { /* session not retained yet */ } + } + const sessions = rewindHost?.sessions || rewindSessions; + const actx = typeof sessions?.scope === "function" ? sessions.scope(sessionId) : undefined; + if (typeof input.for === "function" && actx) return input.for(actx); + return null; + } catch { + return null; + } + } + + function liveDraft(sessionId, inputActions) { + try { + const fromActions = inputActions?.state?.getSnapshot?.()?.draft; + if (typeof fromActions === "string") return fromActions; + } catch { /* ignore */ } + try { + const draft = conversationInput(sessionId)?.state?.getSnapshot?.()?.draft; + if (typeof draft === "string") return draft; + } catch { /* ignore */ } + return ""; + } + + function hostSetDraft(sessionId, text) { + if (!sessionId) return false; + try { + const input = conversationInput(sessionId); + if (input && typeof input.setDraft === "function") { + input.setDraft(text || ""); + return true; + } + } catch { /* ignore */ } + return false; + } + + function fillComposer(inputActions, text) { + if (inputActions && typeof inputActions.setDraft === "function") { + try { inputActions.setDraft(text || ""); } catch { /* ignore */ } + } + } + + function scheduleComposerFill(sessionId, text, inputActions) { + if (!sessionId || !text || isPluginDraft(text)) return () => {}; + let stopped = false; + let filled = false; + const timers = []; + const tryFill = () => { + if (stopped) return; + const current = liveDraft(sessionId, inputActions); + if (filled && current !== text) { + stopped = true; + return; + } + if (current && current !== text) { + stopped = true; + return; + } + fillComposer(inputActions, text); + hostSetDraft(sessionId, text); + filled = true; + }; + for (const ms of FILL_DELAYS) timers.push(window.setTimeout(tryFill, ms)); + return () => { + stopped = true; + for (const t of timers) { try { window.clearTimeout(t); } catch { /* ignore */ } } + }; + } + + function scheduleEchoClear(sessionId, inputActions) { + if (!sessionId) return () => {}; + let last = ""; + let stopped = false; + const timers = []; + const tryClear = () => { + if (stopped || !last) return; + const current = String(liveDraft(sessionId, inputActions) || "").trim(); + if (!current) { + stopped = true; + return; + } + if (current !== last) return; + fillComposer(inputActions, ""); + hostSetDraft(sessionId, ""); + clearStoredDraft(sessionId); + stopped = true; + }; + apiJson("/dsh-purge/last-user?sessionId=" + encodeURIComponent(sessionId)).then((data) => { + if (stopped) return; + last = String(data?.text || "").trim(); + if (!last || isPluginDraft(last)) return; + tryClear(); + for (const ms of [0, 50, 180, 400]) timers.push(window.setTimeout(tryClear, ms)); + }).catch(() => {}); + return () => { + stopped = true; + for (const t of timers) { try { window.clearTimeout(t); } catch { /* ignore */ } } + }; + } + + async function dropInheritedQueue(sessions, sessionId) { + if (!sessions || !sessionId) return; + const drop = async () => { + try { + const actx = typeof sessions.scope === "function" ? sessions.scope(sessionId) : undefined; + const face = typeof sessions.sessionOf === "function" ? sessions.sessionOf(actx) : undefined; + const queue = face?.getSnapshot?.()?.queue || []; + for (const item of queue) { + const id = item?.id || item?.itemId; + if (!id) continue; + try { await face.updateQueue?.(id, { kind: "remove" }); } catch { /* ignore */ } + } + } catch { /* ignore */ } + }; + await drop(); + window.setTimeout(() => { drop(); }, 80); + } + + async function openRewoundSession(sessions, sessionId) { + if (!sessionId) return; + const workspace = rewindWorkspace; + let lastError = null; + for (let attempt = 0; attempt < 25; attempt += 1) { + if (sessions && typeof sessions.refresh === "function" && !sessionKnown(sessions, sessionId)) { + try { await sessions.refresh(); } catch (error) { lastError = error; } + } + const known = sessionKnown(sessions, sessionId); + if (known || typeof sessions?.open === "function") { + try { + if (workspace && typeof workspace.openSession === "function") { + workspace.openSession(sessionId); + } else if (typeof sessions?.open === "function") { + sessions.open(sessionId); + } else { + throw new Error("Cannot open the rewound session"); + } + const selected = workspace?.selection?.getSnapshot?.()?.sessionId; + if (!workspace || selected === sessionId) { + await dropInheritedQueue(sessions, sessionId); + window.setTimeout(() => { dropInheritedQueue(sessions, sessionId); }, 250); + return; + } + } catch (error) { + lastError = error; + } + } + await waitMs(120); + } + throw new Error((lastError && lastError.message) || "The rewound session did not open"); + } + + class RewindSafe extends Component { + constructor(p) { super(p); this.state = { failed: false }; } + static getDerivedStateFromError() { return { failed: true }; } + componentDidCatch(error) { try { console.error("[dsh-purge] rewind slot:", error); } catch { /* ignore */ } } + render() { + if (this.state.failed) return h(RewindFallback, this.props); + return h(RewindButton, this.props); + } + } + + class ContinueSafe extends Component { + constructor(p) { super(p); this.state = { failed: false }; } + static getDerivedStateFromError() { return { failed: true }; } + componentDidCatch(error) { try { console.error("[dsh-purge] continue slot:", error); } catch { /* ignore */ } } + render() { + if (this.state.failed) { + return h("button", { + type: "button", + className: "dshp-continue", + title: rewindText(translate, "continue.label", "Continue"), + onMouseDown: (e) => e.preventDefault(), + onClick: async () => { + const sessionId = currentSessionId(rewindSessions); + if (!sessionId) return; + try { + const data = await apiJson("/dsh-purge/continue", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId, force: true }), + }); + if (!data || !data.ok) throw new Error((data && data.error) || "continue"); + } catch (e) { + window.alert(rewindText(translate, "continue.fail", "Continue failed: {error}").replace("{error}", String((e && e.message) || e))); + } + }, + }, rewindText(translate, "continue.label", "Continue")); + } + return h(ContinueButton, this.props); + } + } + + function ContinueButton(props) { + const t = props.t || translate; + const sessions = rewindSessions; + const sessionId = props.sessionId || currentSessionId(sessions); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + const [info, setInfo] = useState(null); + + useEffect(() => { + if (!sessionId) { + setInfo(null); + return; + } + let cancelled = false; + const tick = () => { + apiJson("/dsh-purge/continue?sessionId=" + encodeURIComponent(sessionId)) + .then((data) => { if (!cancelled && data && data.ok) setInfo(data); }) + .catch(() => {}); + }; + tick(); + const timer = window.setInterval(tick, 1200); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [sessionId]); + + const canContinue = Boolean(info && info.canContinue); + const left = info ? Math.max(0, (info.continueMax || 0) - (info.continueUsed || 0)) : 0; + const runContinue = async () => { + if (!sessionId || busy || !canContinue) return; + setBusy(true); + setNotice(""); + try { + const data = await apiJson("/dsh-purge/continue", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + if (!data || !data.ok) throw new Error((data && data.error) || "continue"); + setInfo((prev) => prev ? { ...prev, ...data, canContinue: false, continueUsed: data.used } : data); + } catch (e) { + setNotice(rewindText(t, "continue.fail", "Continue failed: {error}").replace("{error}", String((e && e.message) || e))); + } finally { + setBusy(false); + } + }; + + const label = busy + ? rewindText(t, "continue.busy", "Continuing…") + : rewindText(t, "continue.label", "Continue"); + const title = notice + || (canContinue + ? rewindText(t, "continue.ready", "Continue ({left} left)").replace("{left}", String(left)) + : (info && info.reason && left <= 0 + ? rewindText(t, "continue.limit", "Continue limit reached") + : rewindText(t, "continue.aria", "Continue after an abnormal stop"))); + return h("button", { + type: "button", + className: "dshp-continue" + (canContinue ? " is-ready" : ""), + title, + "aria-label": rewindText(t, "continue.aria", "Continue after an abnormal stop"), + disabled: busy || !sessionId || !canContinue, + onMouseDown: (e) => e.preventDefault(), + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + runContinue(); + }, + }, + h("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true }, + h("path", { fill: "currentColor", d: "M4.2 2.8v10.4L13 8 4.2 2.8z" }), + ), + label, + ); + } + + function rewindMenuNode(t, menuPos, menuRef, runRewind) { + const menu = h("div", { + className: "dshp-rewind-menu", + role: "menu", + ref: menuRef, + style: menuPos ? { left: menuPos.left + "px", top: menuPos.top + "px" } : { left: "8px", bottom: "48px", transform: "none" }, + }, + h("button", { + type: "button", + className: "dshp-rewind-item", + onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); }, + onClick: (e) => { e.preventDefault(); e.stopPropagation(); runRewind("once"); }, + }, + h("b", null, rewindText(t, "rewind.once", "Undo last step")), + h("span", null, rewindText(t, "rewind.once.hint", "Withdraw the last step into a new session that keeps earlier context; the line returns to the box")), + ), + h("button", { + type: "button", + className: "dshp-rewind-item", + onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); }, + onClick: (e) => { e.preventDefault(); e.stopPropagation(); runRewind("round"); }, + }, + h("b", null, rewindText(t, "rewind.round", "Undo last turn")), + h("span", null, rewindText(t, "rewind.round.hint", "Withdraw the last turn (and its subagents) into a new session that keeps earlier context; the line returns to the box")), + ), + ); + try { + const rd = require("react-dom"); + if (rd && typeof rd.createPortal === "function" && typeof document !== "undefined") { + return rd.createPortal(menu, document.body); + } + } catch { /* 没有 portal 时菜单仍挂在按钮旁 */ } + return menu; + } + + class RewindFallback extends Component { + constructor(p) { + super(p); + this.state = { menu: false, busy: false, notice: "", menuPos: null }; + this.wrap = null; + this.menuEl = null; + this.onDoc = (event) => { + if (this.wrap && this.wrap.contains(event.target)) return; + if (this.menuEl && this.menuEl.contains(event.target)) return; + this.setState({ menu: false }); + }; + } + componentDidMount() { + document.addEventListener("mousedown", this.onDoc); + } + componentWillUnmount() { + document.removeEventListener("mousedown", this.onDoc); + } + run(mode) { + const sessionId = this.props.sessionId || currentSessionId(rewindSessions); + if (!sessionId || this.state.busy) return; + this.setState({ busy: true, menu: false, notice: "" }); + apiJson("/dsh-purge/rewind", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId, mode }), + }).then(async (data) => { + if (!data || !data.ok || !data.sessionId || data.cleared === false) { + throw new Error((data && data.error) || "The previously sent content was not cleared"); + } + if (data.at) rewindSeenAt = data.at; + const text = isPluginDraft(data.text) ? "" : (data.text || ""); + armComposerFill(data.sessionId, text); + await openRewoundSession(rewindSessions, data.sessionId); + scheduleComposerFill(data.sessionId, text); + this.setState({ busy: false }); + }).catch((e) => { + this.setState({ + busy: false, + notice: "Undo failed: " + String((e && e.message) || e), + }); + }); + } + openMenu() { + const rect = this.wrap && this.wrap.getBoundingClientRect ? this.wrap.getBoundingClientRect() : null; + this.setState({ + menu: !this.state.menu, + notice: "", + menuPos: rect ? { left: Math.max(8, rect.right - 228), top: Math.max(8, rect.top - 8) } : null, + }); + } + render() { + const sessionId = this.props.sessionId || currentSessionId(rewindSessions); + const t = this.props.t || translate; + const label = this.state.busy ? "Undoing…" : (this.state.notice ? this.state.notice.slice(0, 18) : "Undo"); + return h("div", { className: "dshp-rewind-wrap", ref: (node) => { this.wrap = node; } }, + h("style", null, REWIND_CSS), + h("button", { + type: "button", + className: "dshp-rewind", + disabled: this.state.busy || !sessionId, + onMouseDown: (e) => e.preventDefault(), + onClick: (e) => { e.preventDefault(); this.openMenu(); }, + }, label), + this.state.menu ? rewindMenuNode(t, this.state.menuPos, (node) => { this.menuEl = node; }, (mode) => this.run(mode)) : null, + ); + } + } + + function RewindButton(props) { + const t = props.t || translate; + const sessions = rewindSessions; + const inputActions = props.inputActions; + const sessionId = props.sessionId || currentSessionId(sessions); + const wrapRef = useRef(null); + const menuRef = useRef(null); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + const [menu, setMenu] = useState(false); + const [menuPos, setMenuPos] = useState(null); + + useEffect(() => { + if (!sessionId) return; + const armed = takeArmedFill(sessionId); + if (armed) { + scheduleComposerFill(sessionId, armed, inputActions); + return; + } + scheduleEchoClear(sessionId, inputActions); + }, [sessionId, inputActions]); + + useEffect(() => { + if (!menu) return; + const onDoc = (event) => { + if (wrapRef.current && wrapRef.current.contains(event.target)) return; + if (menuRef.current && menuRef.current.contains(event.target)) return; + setMenu(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [menu]); + + const runRewind = async (mode) => { + if (!sessionId || busy) return; + setBusy(true); + setMenu(false); + setNotice(""); + try { + const data = await apiJson("/dsh-purge/rewind", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId, mode }), + }); + if (!data || !data.ok || !data.sessionId || data.cleared === false) { + throw new Error((data && data.error) || "The previously sent content was not cleared"); + } + if (data.at) rewindSeenAt = data.at; + const text = isPluginDraft(data.text) ? "" : (data.text || ""); + armComposerFill(data.sessionId, text); + await openRewoundSession(sessions, data.sessionId); + scheduleComposerFill(data.sessionId, text); + } catch (e) { + const message = rewindText(t, "rewind.fail", "Undo failed: {error}").replace("{error}", String((e && e.message) || e)); + setNotice(message); + } finally { + setBusy(false); + } + }; + + const onClick = (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + if (!sessionId || busy) return; + if (menu) { + setMenu(false); + return; + } + const rect = wrapRef.current?.getBoundingClientRect?.(); + if (rect) { + setMenuPos({ + left: Math.max(8, rect.right - 228), + top: Math.max(8, rect.top - 8), + }); + } + setNotice(""); + setMenu(true); + }; + + const failLabel = notice + ? (notice.length > 18 ? notice.slice(0, 18) + "…" : notice) + : ""; + const label = busy + ? rewindText(t, "rewind.busy", "Undoing…") + : (failLabel || rewindText(t, "rewind.label", "Undo")); + const title = notice || rewindText(t, "rewind.aria", "Undo"); + return h("div", { className: "dshp-rewind-wrap", ref: wrapRef }, + h("style", null, REWIND_CSS), + h("button", { + type: "button", + className: "dshp-rewind", + title, + "aria-label": rewindText(t, "rewind.aria", "Undo"), + "aria-expanded": menu ? "true" : "false", + disabled: busy || !sessionId, + onMouseDown: (e) => e.preventDefault(), + onClick, + }, + h("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true }, + h("path", { + fill: "currentColor", + d: "M7.2 3.2 3.4 7l3.8 3.8V8.6c2.8 0 4.7.7 5.9 2.3-.2-2.8-1.9-5.2-5.9-5.6V3.2z", + }), + ), + label, + ), + menu ? rewindMenuNode(t, menuPos, menuRef, runRewind) : null, + ); + } + + function installRewindWatch(ctx) { + const sessions = ctx.sessions; + if (!sessions) return () => {}; + const tick = async () => { + try { + const data = await apiJson("/dsh-purge/rewind"); + if (!data || !data.sessionId || !data.at || data.at <= rewindSeenAt) return; + rewindSeenAt = data.at; + const current = currentSessionId(sessions); + if (current && current !== data.parentId) return; + const text = isPluginDraft(data.text) ? "" : (data.text || ""); + armComposerFill(data.sessionId, text); + scheduleComposerFill(data.sessionId, text); + await openRewoundSession(sessions, data.sessionId); + scheduleComposerFill(data.sessionId, text); + } catch { /* ignore */ } + }; + const timer = window.setInterval(tick, 1200); + return () => window.clearInterval(timer); + } + + /* __DSH_PURGE_DRILL_BEGIN__ */ + // 内嵌演练台(源码在 lib/redteam,已并进本插件) + const __dshPurgeDrill = (() => { + var module = { exports: {} }; + var exports = module.exports; + + var module = { exports: {} } + var exports = module.exports + Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }) + + const React = require('react') + const h = React.createElement + + /* + * 演练台自己是一块浮动窗:position:fixed,用 left/top/width/height 放。 + * 不给官方 frame 加 padding,不改官方输入框的宽度。嵌在清洗面板里时走 .rt-embedded。 + */ + const CSS = ` +.rt-dock{position:fixed;top:12px;right:12px;z-index:20;display:flex;flex-direction:column;box-sizing:border-box; + background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#1c1c1c) 52%,transparent); + backdrop-filter:blur(20px) saturate(1.3);-webkit-backdrop-filter:blur(20px) saturate(1.3); + border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,#444) 70%,transparent); + border-radius:12px; + box-shadow:0 18px 48px color-mix(in srgb,#000 28%,transparent);pointer-events:auto;color:var(--dsw-alias-label-primary); + font-size:13px;line-height:1.5} +.rt-dock.rt-embedded{position:relative;inset:auto;width:100%;height:100%;max-width:none;border:0;border-radius:0;box-shadow:none;background:transparent!important;backdrop-filter:none;-webkit-backdrop-filter:none} +.rt-grip,.rt-resize-r,.rt-resize-b,.rt-resize-c{position:absolute;z-index:3;touch-action:none} +.rt-grip{left:-3px;top:0;bottom:0;width:8px;cursor:ew-resize;background:transparent} +.rt-resize-r{right:-3px;top:0;bottom:14px;width:8px;cursor:ew-resize} +.rt-resize-b{left:8px;right:14px;bottom:-3px;height:8px;cursor:ns-resize} +.rt-resize-c{right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize} +.rt-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1)} +.rt-title{font-weight:600;font-size:14px;display:flex;align-items:center;gap:6px;white-space:nowrap} +.rt-dot{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-brand-primary)} +.rt-spacer{flex:1} +.rt-btn{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:inherit; + border-radius:6px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:inherit;white-space:nowrap} +.rt-btn:hover{border-color:var(--dsw-alias-border-l2)} +.rt-btn-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:#fff} +.rt-btn-primary:hover{opacity:.9} +.rt-btn:disabled{opacity:.5;cursor:default} +.rt-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 12px 0;border-bottom:1px solid var(--dsw-alias-border-l1)} +.rt-tab{padding:8px 10px 7px;border-radius:0;cursor:pointer;font-size:12.5px;color:var(--dsw-alias-label-secondary); + display:inline-flex;align-items:center;gap:5px;border-bottom:2px solid transparent;margin-bottom:-1px;background:transparent} +.rt-tab:hover{color:var(--dsw-alias-label-primary)} +.rt-tab.on{color:var(--dsw-alias-label-primary);background:transparent;font-weight:600;border-bottom-color:var(--dsw-alias-brand-primary,#6dbf8c)} +/* 未读红点:该页签有新内容(新资产/新漏洞/新得分/新步骤…),点开看过就消失 */ +.rt-tab-dot{width:7px;height:7px;border-radius:50%;background:#ef4444;flex:none; + box-shadow:0 0 0 2px color-mix(in srgb, #ef4444 22%, transparent)} +.rt-body{flex:1;min-height:0;display:flex;flex-direction:column} +.rt-split{flex:1;min-height:0;display:flex} +.rt-side{width:200px;flex:none;border-right:1px solid var(--dsw-alias-border-l1);overflow:auto;padding:8px} +.rt-main{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden} +.rt-seg{padding:7px 8px;border-radius:6px;cursor:pointer;margin-bottom:4px;border:1px solid transparent} +.rt-seg:hover{background:var(--dsw-alias-bg-layer-2)} +.rt-seg.on{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-brand-primary)} +.rt-seg-cidr{font-family:ui-monospace,Menlo,monospace;font-size:12.5px} +.rt-seg-meta{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px} +.rt-toolbar{display:flex;gap:6px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);flex-wrap:wrap;align-items:center} +.rt-input{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:inherit; + border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit;outline:none;min-width:0} +.rt-input:focus{border-color:var(--dsw-alias-brand-primary)} +.rt-input option,.rt-eng-select option{background:var(--dsw-alias-bg-layer-1,#1c1c1c);color:var(--dsw-alias-label-primary,#f2f2f2)} +.rt-eng-select{color:var(--dsw-alias-label-primary,#f2f2f2);background:var(--dsw-alias-bg-layer-2,#2a2a2a)} +.rt-table{flex:1;overflow:auto} +.rt-row{display:grid;grid-template-columns:150px 74px 104px 1.15fr 1fr;gap:8px;padding:6px 10px; + border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;cursor:pointer;font-size:12.5px} +.rt-row:hover{background:var(--dsw-alias-bg-layer-2)} +.rt-row.head{cursor:default;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:600;position:sticky;top:0; + background:var(--dsw-alias-bg-layer-1);z-index:1} +.rt-row.head:hover{background:var(--dsw-alias-bg-layer-1)} +.rt-mono{font-family:ui-monospace,Menlo,monospace} +.rt-tag{display:inline-block;padding:0 5px;border-radius:4px;font-size:11px;margin-right:4px; + border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);white-space:nowrap} +.rt-tag-passive{color:#8b5cf6;border-color:#8b5cf655;background:#8b5cf61a} +.rt-tag-active{color:#f59e0b;border-color:#f59e0b55;background:#f59e0b1a} +.rt-tag-live{color:#10b981;border-color:#10b98155;background:#10b9811a} +.rt-tag-warn{color:#ef4444;border-color:#ef444455;background:#ef44441a} +/* 纪律提示条:不满足交付要求(非冰蝎/哥斯拉马、没有 suo5 隧道)时顶在区块最上方 */ +.rt-hint{border-radius:6px;padding:7px 9px;margin-bottom:8px;font-size:11.5px;line-height:1.6; + border:1px dashed #ef444488;background:#ef44440f;color:var(--dsw-alias-label-primary)} +.rt-hint b{color:#ef4444} +.rt-tag-dead{color:var(--dsw-alias-label-secondary)} +.rt-expand{grid-column:1/-1;padding:8px 4px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)} +.rt-kv{display:flex;gap:8px;margin-bottom:3px;align-items:baseline} +.rt-kv b{color:var(--dsw-alias-label-primary);font-weight:600;min-width:64px;flex:none} +/* 图谱视图已移除(见 AssetsTab:资产关系由「域名维度」与 redteam_attack_path 工具承担) */ +.rt-pane{flex:1;min-height:0;overflow:auto;padding:12px} +.rt-card{border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,#444) 75%,transparent);border-radius:8px;padding:10px;margin-bottom:10px; + background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#2a2a2a) 48%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)} +.rt-card h4{margin:0 0 6px;font-size:13px} +.rt-textarea{width:100%;min-height:260px;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1); + color:inherit;border-radius:6px;padding:8px;font-size:12.5px;font-family:ui-monospace,Menlo,monospace; + line-height:1.6;resize:vertical;outline:none;box-sizing:border-box} +.rt-textarea:focus{border-color:var(--dsw-alias-brand-primary)} +.rt-list{width:210px;flex:none;border-right:1px solid var(--dsw-alias-border-l1);overflow:auto;padding:8px} +.rt-item{padding:7px 8px;border-radius:6px;cursor:pointer;margin-bottom:4px;border:1px solid transparent} +.rt-item:hover{background:var(--dsw-alias-bg-layer-2)} +.rt-item.on{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-brand-primary)} +.rt-item-name{font-weight:600;font-size:12.5px} +.rt-item-desc{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px; + display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.rt-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12.5px} +.rt-err{color:var(--dsw-alias-state-error-primary);font-size:12px;padding:6px 10px} +.rt-foot{padding:8px 12px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px; + color:var(--dsw-alias-label-secondary);display:flex;gap:12px;flex-wrap:wrap;align-items:center} +.rt-icon-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;border:1px solid var(--dsw-alias-border-l1); + background:transparent;color:inherit;border-radius:6px;padding:6px 8px;cursor:pointer;font-family:inherit;font-size:12.5px} +.rt-icon-btn:hover{background:var(--dsw-alias-bg-layer-2)} +.rt-icon-btn.on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)} +.rt-hbtn{border:1px solid var(--dsw-alias-border-l1);background:transparent;color:inherit;border-radius:6px; + padding:2px 8px;font-size:12px;cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:5px} +.rt-hbtn:hover{background:var(--dsw-alias-bg-layer-2)} +.rt-hbtn.on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)} +.rt-test{display:inline-block;padding:0 5px;border-radius:4px;font-size:11px;white-space:nowrap; + border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)} +.rt-test-testing{color:#f59e0b;border-color:#f59e0b55;background:#f59e0b1a} +.rt-test-tested{color:#10b981;border-color:#10b98155;background:#10b9811a} +.rt-test-blocked{color:#fff;background:#ef4444;border-color:#ef4444} +.rt-test-abandoned{color:#94a3b8;border-color:#94a3b855;background:#94a3b81a} +.rt-test-no_surface{color:#6366f1;border-color:#6366f155;background:#6366f11a} +.rt-scope-testing{color:#5b21b6;background:#ddd6fe} +.rt-pri{display:inline-block;padding:0 6px;border-radius:4px;font-size:11px;font-weight:600;white-space:nowrap} +.rt-pri-high{color:#fff;background:#ef4444} +.rt-pri-medium{color:#fff;background:#f59e0b} +.rt-pri-low{color:#fff;background:#94a3b8} +.rt-score-row{display:grid;grid-template-columns:16px 62px minmax(0,1fr) 116px 66px;gap:8px;padding:7px 10px; + border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;font-size:12.5px;cursor:pointer} +.rt-score-row:hover{background:var(--dsw-alias-bg-layer-2)} +.rt-score-row.head{cursor:default;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:600; + position:sticky;top:0;background:var(--dsw-alias-bg-layer-1);z-index:1} +.rt-score-detail{grid-column:1/-1;padding:8px 4px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)} +/* 得分目标按合并版的 8 个类别分组:类别头 + 组内按分值升序 */ +.rt-score-group{display:flex;align-items:center;gap:8px;padding:7px 10px 5px;margin-top:2px; + font-size:12px;font-weight:600;border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)} +.rt-score-group:first-child{border-top:none} +.rt-score-group .rt-sg-sub{font-weight:400;font-size:11px;color:var(--dsw-alias-label-secondary)} +/* ── 会话与入口:每张卡片分「标题行 / 关键事实 / 可用命令 / 备注」四段,避免一行糊在一起 ── */ +.rt-sess-facts{display:flex;flex-direction:column;gap:2px;margin-top:5px} +.rt-sess-fact{display:flex;gap:6px;font-size:11.5px;line-height:1.5} +.rt-sess-fact>b{flex:none;min-width:62px;font-weight:600;color:var(--dsw-alias-label-secondary)} +.rt-sess-fact>span{min-width:0;overflow-wrap:anywhere} +.rt-sess-cmd{margin-top:6px} +.rt-sess-cmd>b{display:block;font-size:11px;color:var(--dsw-alias-label-secondary);margin-bottom:3px;font-weight:600} +.rt-sess-fold{margin-top:6px;font-size:11.5px} +.rt-sess-fold>summary{cursor:pointer;color:var(--dsw-alias-label-secondary);user-select:none} +.rt-sess-fold>summary:hover{color:var(--dsw-alias-label-primary)} +.rt-score-row .rt-scope{font-size:10.5px;color:var(--dsw-alias-label-secondary)} +.rt-score-form{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:6px} +.rt-score-form input,.rt-score-form select{width:100%;box-sizing:border-box} +.rt-vrow{display:grid;grid-template-columns:62px minmax(0,1fr) 132px 108px 74px 52px;gap:8px;padding:6px 10px; + border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;font-size:12.5px;cursor:pointer} +.rt-vrow:hover{background:var(--dsw-alias-bg-layer-2)} +.rt-vrow.head{cursor:default;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:600; + position:sticky;top:0;background:var(--dsw-alias-bg-layer-1);z-index:1} +.rt-vdetail{grid-column:1/-1;padding:8px 4px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)} +.rt-vdetail .rt-kv{margin-bottom:4px} +.rt-actions{display:flex;gap:6px;margin-top:6px} +.rt-section{padding:8px 10px 2px;font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary)} +.rt-link{color:var(--dsw-alias-brand-primary);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rt-link:hover{text-decoration:underline} +.rt-full{position:fixed;inset:0;z-index:60;display:flex;flex-direction:column; + background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5; + border-top:3px solid var(--dsw-alias-brand-primary)} +.rt-full .rt-head{padding:12px 18px} +.rt-full .rt-tabs{padding:10px 18px 0;gap:6px;flex-wrap:wrap} +.rt-full .rt-side{width:260px} +.rt-full .rt-list{width:280px} +.rt-full .rt-row{grid-template-columns:190px 90px 130px 1.4fr 1.2fr} +.rt-full .rt-vrow{grid-template-columns:80px minmax(0,1.6fr) 200px 150px 90px 64px} +.rt-full .rt-pane{padding:18px} +.rt-full .rt-textarea{min-height:60vh} +.rt-full .rt-foot{padding:10px 18px;font-size:12px} +.rt-full .rt-body{max-width:1400px;width:100%;margin:0 auto;flex:1;min-height:0;display:flex;flex-direction:column} +.rt-chain{flex:1;overflow:auto;padding:10px 12px} +.rt-step{display:flex;gap:10px;padding:8px 6px;border-left:2px solid var(--dsw-alias-border-l1);margin-left:6px} +.rt-step:last-child{border-left-color:transparent} +.rt-step-dot{width:22px;height:22px;flex:none;border-radius:50%;display:flex;align-items:center;justify-content:center; + font-size:11px;font-weight:700;color:#fff;background:#64748b;margin-left:-13px} +.rt-step-body{min-width:0} +.rt-step-title{font-weight:600;font-size:13px} +.rt-step-meta{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:2px;word-break:break-word} +.rt-stage-recon{background:#6366f1} +.rt-stage-vuln{background:#f59e0b} +.rt-stage-exploit{background:#ef4444} +.rt-stage-access{background:#10b981} +.rt-stage-pivot{background:#8b5cf6} +.rt-stage-data{background:#0ea5e9} +.rt-stage-other{background:#64748b} +.rt-step-head{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap} +.rt-step-time{font-size:11px;color:var(--dsw-alias-label-secondary);margin-left:auto;white-space:nowrap} +.rt-step-detail{font-size:12px;margin-top:5px;white-space:pre-wrap;word-break:break-word; + border-left:2px solid var(--dsw-alias-border-l1);padding:2px 0 2px 9px;line-height:1.6} +.rt-chip{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;padding:1px 7px;border-radius:5px; + border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);margin:4px 5px 0 0;max-width:100%} +.rt-chip>i{font-style:normal;color:var(--dsw-alias-label-secondary);font-size:10.5px} +.rt-chip>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:420px} +.rt-stage-tag{display:inline-block;padding:0 6px;border-radius:4px;font-size:11px;color:#fff;font-weight:600} +.rt-st-recon{background:#6366f1}.rt-st-vuln{background:#f59e0b}.rt-st-exploit{background:#ef4444} +.rt-st-access{background:#10b981}.rt-st-pivot{background:#8b5cf6}.rt-st-data{background:#0ea5e9}.rt-st-other{background:#64748b} +.rt-sev{display:inline-block;padding:0 6px;border-radius:4px;font-size:11px;font-weight:600;border:1px solid transparent} +.rt-sev-critical{color:#fff;background:#b91c1c}.rt-sev-high{color:#fff;background:#ef4444} +.rt-sev-medium{color:#7c2d12;background:#fdba74}.rt-sev-low{color:#1e3a8a;background:#bfdbfe} +.rt-sev-info{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l1)} +.rt-dot-on{display:inline-block;width:7px;height:7px;border-radius:50%;background:#10b981;box-shadow:0 0 0 3px #10b98133} +.rt-dot-off{display:inline-block;width:7px;height:7px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 3px #ef444433} +.rt-dot-unk{display:inline-block;width:7px;height:7px;border-radius:50%;background:#94a3b8;box-shadow:0 0 0 3px #94a3b833} +.rt-sess-grid{display:grid;grid-template-columns:1fr;gap:8px;padding:10px 12px} +.rt-sess{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:9px 10px;background:var(--dsw-alias-bg-layer-2)} +.rt-sess-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap} +.rt-sess-title{font-weight:600;font-size:12.5px;font-family:ui-monospace,Menlo,monospace;word-break:break-all} +.rt-sess-sub{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:3px;word-break:break-word} +.rt-code{font-family:ui-monospace,Menlo,monospace;font-size:11px;background:var(--dsw-alias-bg-base); + border:1px solid var(--dsw-alias-border-l1);border-radius:4px;padding:1px 5px;cursor:pointer;word-break:break-all} +.rt-code:hover{border-color:var(--dsw-alias-brand-primary)} +.rt-evi{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;margin-top:8px} +.rt-evi-head{display:flex;align-items:center;gap:8px;padding:5px 9px;background:var(--dsw-alias-bg-layer-2); + font-size:11.5px;font-weight:600;border-bottom:1px solid var(--dsw-alias-border-l1)} +.rt-evi-body{margin:0;padding:9px 11px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.6; + white-space:pre-wrap;word-break:break-word;max-height:340px;overflow:auto;background:var(--dsw-alias-bg-base)} +.rt-evi-body.req{max-height:220px} +.rt-hl-req{color:#10b981;font-weight:600} +.rt-hl-res{color:#0ea5e9;font-weight:600} +.rt-gain{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600;padding:2px 8px;border-radius:12px; + color:#065f46;background:#a7f3d0;border:1px solid #10b98155} +.rt-total{font-size:20px;font-weight:700;font-family:ui-monospace,Menlo,monospace} +.rt-sidehead{padding:7px 8px 2px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary); + display:flex;align-items:center;gap:5px} +.rt-sidehead-btn{cursor:pointer;outline:none;padding:6px 6px 5px;border-radius:5px;user-select:none} +.rt-sidehead-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)} +.rt-sidehead-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.rt-scope{display:inline-block;padding:0 4px;border-radius:3px;font-size:10px;font-weight:700;line-height:15px;flex:none} +.rt-scope-internal{color:#0e7490;background:#a5f3fc} +.rt-scope-external{color:#9a3412;background:#fed7aa} +.rt-hits{display:flex;flex-direction:column;gap:6px;margin-top:7px} +.rt-hit{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:6px; + padding:7px 9px;background:var(--dsw-alias-bg-layer-2)} +.rt-hit-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap} +.rt-hit-idx{width:16px;height:16px;border-radius:50%;background:#10b981;color:#fff;font-size:10.5px;font-weight:700; + display:inline-flex;align-items:center;justify-content:center;flex:none} +.rt-hit-target{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:12px;word-break:break-all} +.rt-hit-time{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap} +.rt-hit-evi{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.65;white-space:pre-wrap;word-break:break-word; + background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:5px;padding:6px 8px;margin-top:5px} +.rt-hit-note{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:4px} +.rt-cred{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #f59e0b;border-radius:6px; + padding:8px 10px;background:var(--dsw-alias-bg-layer-2);margin-bottom:7px} +.rt-cred-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap} +.rt-cred-host{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:12.5px;word-break:break-all} +.rt-secret{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;line-height:1.6;background:#fef3c7;color:#78350f; + border:1px solid #f59e0b66;border-radius:5px;padding:6px 9px;margin-top:6px;white-space:pre-wrap;word-break:break-all; + user-select:all;cursor:text} +.rt-secret-none{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;color:var(--dsw-alias-state-error-primary); + border:1px dashed var(--dsw-alias-state-error-primary);border-radius:5px;padding:5px 9px;margin-top:6px} +.rt-cred-meta{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:5px;word-break:break-word} +.rt-stage-score{background:#10b981} +.rt-counted{font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700;color:#065f46;background:#a7f3d0;border:1px solid #10b98155;border-radius:9px;padding:0 7px} +.rt-scorepts{font-size:11.5px;font-weight:700;color:#065f46;background:#a7f3d0;border:1px solid #10b98155; + border-radius:10px;padding:0 7px;white-space:nowrap} +.rt-livebar{display:flex;align-items:center;gap:7px;padding:7px 12px;border-bottom:1px solid var(--dsw-alias-border-l1); + background:var(--dsw-alias-bg-layer-2);flex-wrap:wrap} +.rt-live-dot{width:8px;height:8px;border-radius:50%;background:#10b981;flex:none;animation:rt-pulse 1.6s ease-in-out infinite} +.rt-live-dot.idle{background:#94a3b8;animation:none} +@keyframes rt-pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 #10b98166}50%{opacity:.5;box-shadow:0 0 0 5px #10b98100}} +.rt-live-body{padding:8px 12px 2px;max-height:44vh;overflow:auto} +.rt-atest{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:6px; + padding:7px 10px;background:var(--dsw-alias-bg-layer-2);margin-bottom:6px} +.rt-atest.past{border-left-color:#94a3b8;opacity:.85} +.rt-atest-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap} +.rt-atest-ip{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:12.5px;word-break:break-all} +.rt-atest-meta{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:3px;word-break:break-word} +.rt-atest-notes{font-family:ui-monospace,Menlo,monospace;font-size:11px;line-height:1.6;white-space:pre-wrap; + word-break:break-word;background:var(--dsw-alias-bg-base);border-radius:4px;padding:5px 7px;margin-top:4px;max-height:76px;overflow:auto} +.rt-concl{display:flex;align-items:center;gap:4px;padding:6px 12px;border-bottom:1px solid var(--dsw-alias-border-l1); + background:var(--dsw-alias-bg-layer-2);flex-wrap:wrap} +.rt-concl-i{display:inline-flex;align-items:baseline;gap:4px;padding:2px 8px;border-radius:6px;cursor:pointer; + border:1px solid transparent} +.rt-concl-i:hover{border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1)} +.rt-concl-i.on{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-1)} +.rt-concl-i b{font-family:ui-monospace,Menlo,monospace;font-size:14px;font-weight:700} +.rt-concl-i>span{color:var(--dsw-alias-label-secondary);font-size:11.5px} +.rt-more{color:var(--dsw-alias-brand-primary);font-size:11.5px;cursor:pointer;user-select:none;margin-top:4px;display:inline-block} +.rt-more:hover{text-decoration:underline} +.rt-subtabs{display:flex;gap:4px;padding:6px 10px 0;border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center} +.rt-subtab{padding:4px 10px;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;color:var(--dsw-alias-label-secondary)} +.rt-subtab.on{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);font-weight:600} +.rt-sevbar{width:3px;border-radius:2px;align-self:stretch;flex:none;margin-right:2px} +/* ── 折叠层次体系 ───────────────────────────────────────────────────────────── + L1 折叠头 .rt-sec :通栏、无圆角无边框、左色条、深底 —— 永远是"扁"的 + L2 内容卡 .rt-atest/.rt-hit/.rt-cred/.rt-evi:内缩、有边框、圆角 —— 立起来 + L3 详情/长文本 .rt-clip-body / .rt-evi-body :无边框、最浅、等宽 + L4 子项容器 .rt-sec-body:左缩进 + 竖引导线,表明"属于上面那个头" + ──────────────────────────────────────────────────────────────────────────── */ +.rt-sec-wrap{margin:0} +.rt-sec{display:flex;align-items:center;gap:8px;padding:7px 12px 7px 9px;cursor:pointer; + background:var(--dsw-alias-bg-layer-2);border-left:3px solid var(--dsw-alias-border-l2); + border-top:1px solid var(--dsw-alias-border-l1);user-select:none;outline:none} +.rt-sec:hover{background:var(--dsw-alias-bg-layer-1)} +.rt-sec:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.rt-sec.flat{cursor:default} +.rt-sec.flat:hover{background:var(--dsw-alias-bg-layer-2)} +.rt-sec-caret{flex:none;width:11px;font-size:10px;color:var(--dsw-alias-label-secondary);text-align:center} +.rt-sec-title{font-weight:600;font-size:13px;white-space:nowrap} +.rt-sec-count{font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap} +.rt-sec-sub{font-size:11.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rt-sec-right{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap;flex:none} +.rt-sec.t-stage{border-left-color:#8b5cf6} +.rt-sec.t-target{border-left-color:#0ea5e9} +.rt-sec.t-folder{border-left-color:#64748b} +.rt-sec.t-test{border-left-color:#10b981} +.rt-sec.t-queue{border-left-color:#f59e0b} +.rt-sec.t-past{border-left-color:#94a3b8} +.rt-sec-body{margin-left:12px;border-left:1px solid var(--dsw-alias-border-l1);padding:7px 0 3px 10px} +.rt-sec-body>.rt-atest:last-child,.rt-sec-body>.rt-hit:last-child{margin-bottom:2px} +/* 长文本折叠(L3):默认预览 2 行并渐隐,展开后限高滚动 */ +.rt-clip{margin-top:6px} +.rt-clip-head{display:flex;align-items:center;gap:6px} +.rt-clip-label{font-size:11px;color:var(--dsw-alias-label-secondary);font-weight:600} +.rt-clip-body{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.65;white-space:pre-wrap; + word-break:break-word;background:var(--dsw-alias-bg-base);border-radius:5px;padding:6px 8px;margin-top:3px} +.rt-clip:not(.open) .rt-clip-body{max-height:46px;overflow:hidden; + -webkit-mask-image:linear-gradient(180deg,#000 55%,transparent);mask-image:linear-gradient(180deg,#000 55%,transparent)} +.rt-clip.open .rt-clip-body{max-height:340px;overflow:auto} +.rt-flow{padding:10px 12px 16px} +.rt-flow-start,.rt-flow-end{font-size:11.5px;color:var(--dsw-alias-label-secondary);padding:4px 0} +.rt-flow-end{font-weight:600;color:var(--dsw-alias-label-primary)} +.rt-flow-link{display:flex;align-items:center;gap:8px;padding:3px 0 3px 10px} +.rt-flow-arrow{color:var(--dsw-alias-border-l2);font-size:11px;flex:none} +.rt-flow-action{font-size:11.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rt-flow-action.inferred{opacity:.7;font-style:italic} +.rt-flow-node{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:8px; + padding:8px 11px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;outline:none} +.rt-flow-node:hover{border-color:var(--dsw-alias-border-l2)} +.rt-flow-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.rt-flow-node.overflow{border-left-color:#94a3b8;opacity:.75} +.rt-flow-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.rt-flow-idx{width:18px;height:18px;border-radius:50%;background:#10b981;color:#fff;font-size:11px;font-weight:700; + display:inline-flex;align-items:center;justify-content:center;flex:none} +.rt-flow-node.overflow .rt-flow-idx{background:#94a3b8} +.rt-flow-name{font-weight:600;font-size:13px} +.rt-flow-pts{margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:12.5px;font-weight:700;color:#065f46; + background:#a7f3d0;border:1px solid #10b98155;border-radius:10px;padding:0 8px;white-space:nowrap} +.rt-flow-pts.off{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)} +.rt-flow-times{font-size:11px;color:var(--dsw-alias-label-secondary)} +.rt-flow-target{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;margin-top:4px;word-break:break-all; + color:var(--dsw-alias-label-secondary)} +.rt-flow-gain{font-size:12px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rt-flow-detail{margin-top:7px;padding-top:7px;border-top:1px dashed var(--dsw-alias-border-l1)} +.rt-hflow-wrap{flex:1;min-height:0;display:flex;flex-direction:column;overflow:auto} +.rt-hflow{display:flex;align-items:center;gap:0;padding:16px 12px;overflow-x:auto;flex-wrap:nowrap} +.rt-hflow-item{display:flex;align-items:center;flex:none} +.rt-hflow-start,.rt-hflow-end{font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap;padding:0 4px} +.rt-hflow-end{font-weight:600;color:var(--dsw-alias-label-primary)} +.rt-hflow-arrow{color:var(--dsw-alias-border-l2);padding:0 5px;font-size:13px;flex:none} +.rt-hflow-node{display:flex;align-items:center;gap:5px;border:1px solid var(--dsw-alias-border-l1); + border-left:3px solid #10b981;border-radius:7px;padding:5px 8px;background:var(--dsw-alias-bg-layer-2); + cursor:pointer;white-space:nowrap;outline:none} +.rt-hflow-node:hover{border-color:var(--dsw-alias-border-l2)} +.rt-hflow-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.rt-hflow-node.overflow{border-left-color:#94a3b8;opacity:.72} +.rt-hflow-node.open{border-color:var(--dsw-alias-brand-primary)} +.rt-hflow-pts{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700;color:#065f46;background:#a7f3d0; + border-radius:8px;padding:0 6px} +.rt-hflow-node.overflow .rt-hflow-pts{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1)} +.rt-hflow-name{font-size:11.5px} +.rt-hflow-n{font-size:10.5px;color:var(--dsw-alias-label-secondary)} +.rt-rep-list{padding:10px 12px 14px} +.rt-rep-tools{display:flex;gap:6px;justify-content:flex-end;margin-bottom:8px} +.rt-rep{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:8px; + padding:9px 11px;margin-bottom:10px;background:var(--dsw-alias-bg-layer-2)} +.rt-rep-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.rt-rep-idx{font-size:15px;color:#10b981;font-weight:700;flex:none} +.rt-rep-name{font-weight:600;font-size:13px} +.rt-rep-meta{font-size:12px;margin-top:4px;color:var(--dsw-alias-label-secondary);word-break:break-word} +.rt-rep-meta b{color:var(--dsw-alias-label-primary);font-weight:600;margin-right:2px} +.rt-rep-missing{font-size:11.5px;color:var(--dsw-alias-state-error-primary);margin-top:6px; + border:1px dashed var(--dsw-alias-state-error-primary);border-radius:5px;padding:5px 8px} +.rt-rep-req{margin-top:7px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;overflow:hidden} +.rt-rep-req-head{display:flex;align-items:center;gap:7px;padding:4px 8px;font-size:11.5px;font-weight:600; + background:var(--dsw-alias-bg-layer-1);border-bottom:1px solid var(--dsw-alias-border-l1)} +.rt-rep-http{margin:0;padding:8px 10px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto;background:var(--dsw-alias-bg-base)} +/* ── 报告里的「这一步怎么来的」:动作步骤 / 命令 / 凭据 / 隧道 ───────────── */ +.rt-rep-trace{margin-top:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:8px 10px; + background:var(--dsw-alias-bg-base)} +.rt-rep-trace-head{display:flex;align-items:center;gap:7px;margin-bottom:5px} +.rt-rep-trace-title{font-weight:600;font-size:12.5px} +.rt-rep-trace-how{font-size:12px;color:var(--dsw-alias-label-secondary);margin-bottom:6px} +.rt-rep-step{border-left:2px solid var(--dsw-alias-border-l1);padding:2px 0 6px 9px;margin-bottom:6px} +.rt-rep-step-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap} +.rt-rep-step-no{width:17px;height:17px;border-radius:50%;background:var(--dsw-alias-brand-primary);color:#fff; + font-size:10.5px;display:flex;align-items:center;justify-content:center;flex:none} +.rt-rep-step-title{font-weight:600;font-size:12.5px} +.rt-rep-step-detail{font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:2px} +.rt-rep-step-cmd{font-size:11.5px;margin-top:4px;display:flex;gap:6px;align-items:flex-start;flex-wrap:wrap} +.rt-rep-step-cmd .rt-mono{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1); + border-radius:5px;padding:2px 6px;word-break:break-all} +.rt-rep-step-result{font-size:11.5px;margin-top:3px;word-break:break-word} +.rt-rep-src{margin-top:6px;padding-top:6px;border-top:1px dashed var(--dsw-alias-border-l1)} +.rt-rep-src-title{font-weight:600;font-size:12px;margin-bottom:3px} +/* ── 全链路攻击路径图 ─────────────────────────────────────────────── */ +.rt-ap{padding:10px 12px 18px;overflow:auto} +.rt-ap-stage{margin-bottom:2px} +.rt-ap-head{display:flex;align-items:center;gap:8px;padding:7px 10px;background:var(--dsw-alias-bg-layer-2); + border-left:4px solid #64748b;border-top:1px solid var(--dsw-alias-border-l1);border-radius:6px 6px 0 0} +.rt-ap-no{width:20px;height:20px;border-radius:5px;color:#fff;font-size:11px;font-weight:700;flex:none; + display:inline-flex;align-items:center;justify-content:center} +.rt-ap-name{font-weight:700;font-size:13.5px} +.rt-ap-en{font-size:10px;color:var(--dsw-alias-label-secondary);letter-spacing:.3px} +.rt-ap-phase{font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap} +.rt-ap-goal{display:flex;align-items:baseline;gap:8px;padding:6px 10px 6px 9px;font-size:12px; + border-left:4px solid #64748b;background:var(--dsw-alias-bg-layer-1)} +.rt-ap-goal-tag{font-size:10.5px;font-weight:700;border:1px solid;border-radius:4px;padding:0 5px;white-space:nowrap;flex:none} +.rt-ap-result{padding:7px 10px 3px;border-left:4px solid transparent} +.rt-ap-result-head{display:flex;align-items:center;gap:8px;margin-bottom:5px;flex-wrap:wrap} +.rt-ap-pts{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700;color:#065f46;background:#a7f3d0; + border:1px solid #10b98155;border-radius:10px;padding:0 7px;white-space:nowrap} +.rt-ap-pts.off{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)} +/* 按服务封顶/自建而不计分的命中:分值标灰(+0),避免看着像又加了分 */ +.rt-ap-pts.uncounted{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)} +.rt-ap-nth{font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap} +.rt-ap-hit{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:6px; + padding:6px 9px;margin-bottom:5px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;outline:none} +.rt-ap-hit:hover{border-color:var(--dsw-alias-border-l2)} +.rt-ap-hit:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.rt-ap-hit.overflow{border-left-color:#94a3b8;opacity:.75} +.rt-ap-hit-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap} +.rt-ap-hit-name{font-weight:600;font-size:12.5px} +.rt-ap-hit-target{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--dsw-alias-label-secondary); + margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%} +.rt-ap-act{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:3px} +.rt-ap-act.inferred{font-style:italic;opacity:.75} +.rt-ap-none{font-size:11.5px;color:var(--dsw-alias-label-secondary);padding:2px 0 4px} +.rt-ap-trans{display:flex;align-items:center;gap:7px;padding:2px 0 2px 14px} +.rt-ap-trans-t{font-size:11px;color:var(--dsw-alias-label-secondary)} +/* 横向路径图 */ +.rt-ap-h{display:flex;align-items:stretch;padding:10px 12px 14px;overflow-x:auto;flex:1;min-height:0} +.rt-ap-col{display:flex;align-items:stretch;flex:none} +.rt-ap-col-arrow{align-self:center;color:var(--dsw-alias-border-l2);padding:0 6px;font-size:12px;flex:none} +.rt-hcol{width:228px;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1); + border-top:3px solid #64748b;border-radius:7px;background:var(--dsw-alias-bg-layer-1);overflow:hidden} +.rt-hcol-goal{font-size:11px;color:var(--dsw-alias-label-secondary);padding:5px 8px;line-height:1.45; + border-bottom:1px solid var(--dsw-alias-border-l1);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.rt-hcol-body{padding:6px 8px;flex:1;min-height:0;overflow:auto} +.rt-hcol-hit{display:flex;align-items:center;gap:5px;font-size:11px;margin-bottom:3px} +.rt-hcol-hit.off{opacity:.6} +.rt-hcol-pts{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;font-weight:700;color:#065f46;background:#a7f3d0; + border-radius:7px;padding:0 5px;flex:none} +.rt-hcol-hit.off .rt-hcol-pts{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)} +.rt-hcol-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rt-hcol-none{font-size:11px;color:var(--dsw-alias-label-secondary)} +.rt-hcol-secs{display:flex;flex-wrap:wrap;gap:3px;margin-top:6px} +.rt-hcol-sec{font-size:10px;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1); + border-radius:4px;padding:0 4px} +.rt-hcol-attck{font-family:ui-monospace,Menlo,monospace;font-size:9.5px;color:var(--dsw-alias-label-secondary); + padding:4px 8px;border-top:1px solid var(--dsw-alias-border-l1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rt-ap-cum{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap} +.rt-ap-sub{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);margin:6px 0 3px} +.rt-ap-assets,.rt-ap-tunnels{display:flex;flex-direction:column;gap:3px} +.rt-ap-asset{display:flex;align-items:center;gap:6px;font-size:11.5px;padding:3px 6px;border-radius:5px; + background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1)} +.rt-ap-tunnel{display:flex;align-items:center;gap:6px;font-size:11.5px;padding:4px 7px;border-radius:5px; + background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #f59e0b} +.rt-hcol-sub{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-bottom:2px; + overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:flex;gap:5px;align-items:center} +.rt-hcol-sub.off{opacity:.6} +.rt-hit-row{display:flex;flex-wrap:wrap;align-items:center;gap:2px 7px;font-size:11.5px;padding:4px 7px;border-radius:5px; + background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);margin-bottom:3px} +/* 资产与内容都自适应换行:长域名/长口令/长结果一律换行显示,不截断成省略号 */ +.rt-hit-asset{font-family:ui-monospace,Menlo,monospace;font-weight:600;flex:0 1 auto;max-width:100%; + overflow-wrap:anywhere;word-break:break-word} +.rt-hit-txt{flex:1 1 100%;color:var(--dsw-alias-label-primary);line-height:1.55; + white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word} +.rt-hit-txt.none{color:var(--dsw-alias-state-error-primary)} +.rt-hit-row .rt-hit-time{flex:none;margin-left:auto} +/* 自己注册/自建的账号:留痕但不计分,整行压暗 */ +.rt-hit-row.self-created{opacity:.72;border-left:3px solid #ef444488} +/* 同一资产同一端口的重复账号/库权限:服务已拿满,不计分(只作留痕) */ +.rt-hit-row.service-capped{opacity:.72;border-left:3px solid #f59e0b88} +.rt-hit-row.service-capped .rt-hit-idx{background:#f59e0b} +.rt-rep-group{margin-bottom:14px} +.rt-rep-stage{display:flex;align-items:center;gap:8px;padding:7px 10px;margin-bottom:7px; + background:var(--dsw-alias-bg-layer-2);border-left:4px solid #64748b;border-radius:6px; + cursor:pointer;outline:none;user-select:none} +.rt-rep-stage:hover{background:var(--dsw-alias-bg-layer-1)} +.rt-rep-stage:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.rt-rep-no{width:20px;height:20px;border-radius:5px;color:#fff;font-size:11px;font-weight:700;flex:none; + display:inline-flex;align-items:center;justify-content:center} +.rt-rep-stage-name{font-weight:700;font-size:13px} +.rt-rep-stage-n{font-size:11px;color:var(--dsw-alias-label-secondary)} +/* 折叠后仍要能一眼看到"这一阶段拿了多少分",所以分数留在头上 */ +.rt-rep-pts{font-size:11px;font-weight:600;padding:1px 6px;border-radius:5px;border:1px solid transparent;flex:none} +.rt-rep-body{padding-left:6px} +/* ── 知识库(POC/EXP) ──────────────────────────────────────────── */ +.rt-kb-filter{display:flex;align-items:center;gap:7px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1)} +/* 知识库归类总览:一行标签,点一下按该类筛选 */ +.rt-kb-cats{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1)} +/* 知识库分组标题(按归类分组时每组一条) */ +.rt-kb-cat{display:flex;align-items:center;gap:7px;padding:7px 10px;background:var(--dsw-alias-bg-layer-2); + border-bottom:1px solid var(--dsw-alias-border-l1);position:sticky;top:0;z-index:1} +.rt-kb-cat-name{font-weight:600;font-size:12.5px} +/* 技能可用性徽章(技能库页签) */ +.rt-avail{display:inline-block;padding:0 5px;border-radius:4px;font-size:10.5px;white-space:nowrap; + border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)} +.rt-avail-available{color:#10b981;border-color:#10b98155;background:#10b9811a} +.rt-avail-broken{color:#ef4444;border-color:#ef444455;background:#ef44441a} +.rt-avail-unknown{color:#94a3b8;border-color:#94a3b855;background:#94a3b81a} +/* 版本 / 更新弹窗 */ +.rt-modal{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center} +.rt-modal-box{width:min(560px,92vw);max-height:80vh;overflow:auto;background:var(--dsw-alias-bg-layer-1); + border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:14px 16px;font-size:12.5px;line-height:1.7; + box-shadow:0 18px 48px rgba(0,0,0,.35);color:var(--dsw-alias-label-primary)} +.rt-modal-box .rt-kv b{min-width:76px} +.rt-kb-check{display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap;cursor:pointer} +.rt-kb{border:1px solid var(--dsw-alias-border-l1);border-radius:7px;margin:0 0 8px;overflow:hidden; + background:var(--dsw-alias-bg-layer-2)} +.rt-kb.open{border-color:var(--dsw-alias-brand-primary)} +.rt-kb-head{display:flex;align-items:center;gap:7px;padding:7px 9px;cursor:pointer;outline:none;flex-wrap:wrap} +.rt-kb-head:hover{background:var(--dsw-alias-bg-layer-1)} +.rt-kb-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.rt-kb-title{font-weight:600;font-size:12.5px;overflow-wrap:anywhere} +.rt-kb-kind{font-size:10px;font-weight:700;letter-spacing:.4px;padding:1px 5px;border-radius:4px; + color:#fff;background:#64748b;flex:none} +.rt-kb-kind.k-exp{background:#ef4444} +.rt-kb-kind.k-poc{background:#f59e0b} +.rt-kb-kind.k-template{background:#8b5cf6} +.rt-kb-kind.k-script{background:#0ea5e9} +.rt-kb-kind.k-payload{background:#10b981} +.rt-kb-sub{font-size:11px;color:var(--dsw-alias-label-secondary);padding:0 9px 7px;overflow-wrap:anywhere} +.rt-kb-body{padding:0 9px 9px} +.rt-kb-actions{display:flex;gap:6px;margin:7px 0} +.rt-kb-body .rt-kv span{overflow-wrap:anywhere;word-break:break-word} +/* 本机 nuclei 模板命中:路径要能完整看到(复制成命令直接跑) */ +.rt-kb-tpl{margin-top:12px;border-top:1px dashed var(--dsw-alias-border-l1);padding-top:8px} +.rt-kb-tpl-row{display:flex;align-items:center;gap:7px;font-size:11.5px;padding:3px 7px;border-radius:5px; + background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);margin-bottom:3px} +.rt-kb-tpl-path{flex:0 1 auto;font-weight:600;overflow-wrap:anywhere} +.rt-kb-tpl-name{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere} +.rt-md{flex:1;overflow:auto;margin:0;padding:14px 16px;font-family:ui-monospace,Menlo,monospace;font-size:12.5px; + line-height:1.65;white-space:pre-wrap;word-break:break-word;background:var(--dsw-alias-bg-base)} +.rt-weblink{display:block;font-size:11.5px;margin-top:1px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rt-shots{margin:8px 0 4px;padding:7px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px; + background:var(--dsw-alias-bg-layer-1)} +.rt-shots-head{display:flex;align-items:baseline;gap:8px;font-size:11.5px;margin-bottom:6px} +.rt-shots-n{color:var(--dsw-alias-label-secondary)} +.rt-shot-row{display:flex;gap:8px;flex-wrap:wrap} +.rt-shot{display:flex;flex-direction:column;gap:3px;max-width:220px} +.rt-shot-thumb{border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:#0b1020; + object-fit:contain;display:block;width:auto;max-width:216px} +.rt-shot-loading{display:flex;align-items:center;justify-content:center;width:150px;font-size:11px; + color:var(--dsw-alias-label-secondary)} +.rt-shot-cap{display:flex;align-items:center;gap:5px;font-size:10.5px;overflow-wrap:anywhere} +.rt-shot-label{color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:170px} +.rt-shot-zoom{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.82);display:flex;flex-direction:column; + align-items:center;justify-content:center;gap:10px;cursor:zoom-out;padding:18px} +.rt-shot-zoom img{max-width:96vw;max-height:86vh;border-radius:6px;box-shadow:0 18px 48px rgba(0,0,0,.5)} +.rt-shot-zoom-cap{color:#e5e7eb;font-size:12px;text-align:center;max-width:90vw;overflow-wrap:anywhere}` /* ---------------------------------------------------------- 桥接与状态 */ + /** 是否在「全面浏览」独立窗口里(URL hash 标记,复用同一套界面代码)。 */ + const isFullWindow = () => { + try { return String(window.location.hash || '') === '#redteam-full' } catch { return false } + } + + const api = (req) => fetch('/redteam/api', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(req), + }).then((res) => res.json()) + + let ui = { open: true, tab: 'assets' } + const subs = new Set() + const setUI = (patch) => { + ui = Object.assign({}, ui, patch) + const snap = Object.assign({}, ui) + for (const f of Array.from(subs)) { + try { f(snap) } catch { /* ignore subscriber errors */ } + } + } + /* 保留导出。面板尺寸只写在自己的元素上,不再改官方 frame 的宽度。 */ + let dockWidthTag = null + const setDockWidth = () => {} + const useUI = () => { + const [st, setSt] = React.useState(() => Object.assign({}, ui)) + React.useEffect(() => { + const f = (snap) => setSt(Object.assign({}, snap || ui)) + subs.add(f) + setSt(Object.assign({}, ui)) + return () => { subs.delete(f) } + }, []) + return st + } + + /* 知识库归类(与 core.js 的 POC_CATEGORIES 一致;面板按它分组) */ + const POC_CAT_NAME = { + rce: "RCE", deserialization: "Deserialization", 'file-upload': "File upload getshell", + sqli: "SQL injection", unauthorized: "Unauthorized access", 'auth-bypass': "Auth bypass / privesc", + 'weak-password': "Weak credentials / brute force", ssrf: 'SSRF', xxe: 'XXE', + 'path-traversal': "Path traversal / arbitrary file read", 'info-leak': "Information disclosure", + privesc: "Privilege escalation / lateral movement", tunnel: "Tunnel / proxy", other: "Other", + } + const POC_CAT_ORDER = Object.keys(POC_CAT_NAME) + /* 角色 code → 中文(报告/知识库都要标"谁发现的") */ + const ROLE_LABEL = { + plan: "Main session (command)", recon: "Recon", assess: "Asset inventory", + 'vuln-scan': "Vulnerability discovery", exploit: "Exploitation", internal: "Internal pentest", + } + + const fmt = (s) => (s ? String(s).replace('T', ' ').slice(0, 16) : '—') + const shotCache = new Map() + const shotLoading = new Map() + const shotSrc = (eng, id) => shotCache.get(eng + ':' + id) || null + const loadShot = (eng, id) => { + const key = eng + ':' + id + if (shotCache.has(key) || shotLoading.has(key)) return + const p = api({ op: 'shotData', engagement: eng, id: id }).then((r) => { + shotCache.set(key, r && r.base64 ? 'data:' + (r.mime || 'image/png') + ';base64,' + r.base64 : '') + shotLoading.delete(key) + const snap = Object.assign({}, ui) + for (const f of Array.from(subs)) { + try { f(snap) } catch { /* ignore subscriber errors */ } + } + }).catch(() => { shotLoading.delete(key) }) + shotLoading.set(key, p) + } + const SHOT_KIND = { screenshot: "Live screenshot", card: "Evidence excerpt from records" } + const ShotImage = ({ eng, shot, height }) => { + const [zoom, setZoom] = React.useState(false) + const src = shotSrc(eng, shot.id) + if (src === null) { loadShot(eng, shot.id); } + const card = shot.kind === 'card' + return h('div', { className: 'rt-shot' }, + src === null + ? h('div', { className: 'rt-shot-thumb rt-shot-loading', style: { height: (height || 96) + 'px' } }, "Loading…") + : h('img', { + className: 'rt-shot-thumb', src: src, alt: shot.label || ("Evidence " + shot.id), + style: { height: (height || 96) + 'px', cursor: 'zoom-in' }, + onClick: () => setZoom(true), + }), + h('div', { className: 'rt-shot-cap' }, + h('span', { className: 'rt-tag ' + (card ? 'rt-tag-warn' : 'rt-tag-live'), title: card + ? "Evidence excerpt generated from that day's records, not a live screenshot — the report notes this too" + : "Live screenshot" }, SHOT_KIND[shot.kind] || shot.kind || "Evidence"), + h('span', { className: 'rt-shot-label', title: shot.label || shot.note || '' }, + shot.label || shot.target || ('#' + shot.id))), + zoom + ? h('div', { className: 'rt-shot-zoom', onClick: () => setZoom(false) }, + h('img', { src: src, alt: shot.label || '' }), + h('div', { className: 'rt-shot-zoom-cap' }, (shot.label || shot.target || '') + + (card ? " · Generated from records (not a live screenshot)" : '') + + (shot.captured_at ? " · " + fmt(shot.captured_at) : '') + + " · Click anywhere to close")) + : null) + } + const ShotStrip = ({ eng, shots, total, title }) => { + const list = shots || [] + if (list.length === 0) return null + return h('div', { className: 'rt-shots' }, + h('div', { className: 'rt-shots-head' }, + h('b', null, title || "Evidence screenshots"), + h('span', { className: 'rt-shots-n' }, "Showing " + list.length + " images" + + (total && total > list.length ? " (" + total + " total for this scoring item; representative image shown)" : ''))), + h('div', { className: 'rt-shot-row' }, list.map((sh) => h(ShotImage, { key: 'sh' + sh.id, eng: eng, shot: sh })))) + } + /** 发现时间在列表里只留「月-日 时:分」:整行宽度紧张,完整时间进悬浮提示 */ + const fmtShort = (s) => { + const t = String(s || '').replace('T', ' ') + return t.length >= 16 ? t.slice(5, 16) : (t || '—') + } + const provLabel = (p) => (p === 'passive' ? "Passive" : p === 'active' ? "Active" : "Unknown") + + /** + * 给「可点击但不是