From 050402f2b75336ea9ee54516f908294010565e79 Mon Sep 17 00:00:00 2001 From: 0nikod Date: Sat, 17 Jan 2026 18:47:07 +0800 Subject: [PATCH] npm run build --- .agent/rules/llms.md | 8 + .editorconfig | 21 + .gitattributes | 1 + .github/workflows/publish_action.yml | 20 + .github/workflows/vite-build.yml | 35 + .gitignore | 121 +++ .pre-commit-config.yaml | 10 + GEMINI.md | 0 LICENSE | 22 + README.md | 83 ++ README_ARCH.md | 70 ++ __init__.py | 35 + data/default_category.json | 37 + data/default_presets.json | 1 + data/default_tags.parquet | Bin 0 -> 957 bytes doc/DESIGN_DOC_EN.md | 97 +++ doc/DESIGN_DOC_ZH.md | 97 +++ doc/preview.png | Bin 0 -> 189731 bytes index.html | 17 + locales/en/main.json | 140 ++++ locales/zh/main.json | 140 ++++ pyproject.toml | 79 ++ readme_zh.md | 82 ++ reproduce_parser.ts | 135 +++ reproduce_parser_v2.ts | 37 + requirements.txt | 1 + scripts/check_schema.py | 21 + scripts/create_sample_data.py | 74 ++ scripts/reset_default_tags.py | 25 + scripts/test_backend.py | 96 +++ scripts/test_manager_logic.py | 59 ++ simple_prompt/__init__.py | 4 + simple_prompt/api/__init__.py | 2 + simple_prompt/api/handlers.py | 463 +++++++++++ simple_prompt/comfyui/__init__.py | 2 + simple_prompt/comfyui/nodes.py | 67 ++ simple_prompt/comfyui/server.py | 307 +++++++ simple_prompt/core/__init__.py | 30 + simple_prompt/core/categories.py | 106 +++ simple_prompt/core/config.py | 55 ++ simple_prompt/core/database.py | 167 ++++ simple_prompt/core/presets.py | 74 ++ simple_prompt/core/tags.py | 400 +++++++++ simple_prompt/core/utils.py | 68 ++ src/App.dev.vue | 89 ++ src/App.vue | 108 +++ src/components/AutocompleteList.vue | 173 ++++ src/components/CustomTagModal.vue | 433 ++++++++++ src/components/ModalWrapper.vue | 274 +++++++ src/components/OtherFunctions.vue | 330 ++++++++ src/components/SettingsModal.vue | 1128 ++++++++++++++++++++++++++ src/components/TagItem.vue | 236 ++++++ src/components/TagManager.vue | 588 ++++++++++++++ src/components/TagSearchModal.vue | 687 ++++++++++++++++ src/components/TextEditor.vue | 849 +++++++++++++++++++ src/components/ToolBar.vue | 149 ++++ src/components/VisualTagArea.vue | 194 +++++ src/dev.ts | 92 +++ src/i18n.ts | 42 + src/main.ts | 97 +++ src/utils/caret.ts | 82 ++ src/utils/categoryService.ts | 154 ++++ src/utils/duckdbService.ts | 93 +++ src/utils/metaService.ts | 83 ++ src/utils/promptParser.ts | 513 ++++++++++++ src/utils/settings.ts | 60 ++ src/utils/types.ts | 42 + tests/__init__.py | 0 tests/test_search.py | 174 ++++ tsconfig.json | 22 + vite.config.mts | 58 ++ vite.debug.config.mts | 22 + 72 files changed, 10081 insertions(+) create mode 100644 .agent/rules/llms.md create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/publish_action.yml create mode 100644 .github/workflows/vite-build.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 GEMINI.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README_ARCH.md create mode 100644 __init__.py create mode 100644 data/default_category.json create mode 100644 data/default_presets.json create mode 100644 data/default_tags.parquet create mode 100644 doc/DESIGN_DOC_EN.md create mode 100644 doc/DESIGN_DOC_ZH.md create mode 100644 doc/preview.png create mode 100644 index.html create mode 100644 locales/en/main.json create mode 100644 locales/zh/main.json create mode 100644 pyproject.toml create mode 100644 readme_zh.md create mode 100644 reproduce_parser.ts create mode 100644 reproduce_parser_v2.ts create mode 100644 requirements.txt create mode 100644 scripts/check_schema.py create mode 100644 scripts/create_sample_data.py create mode 100644 scripts/reset_default_tags.py create mode 100644 scripts/test_backend.py create mode 100644 scripts/test_manager_logic.py create mode 100644 simple_prompt/__init__.py create mode 100644 simple_prompt/api/__init__.py create mode 100644 simple_prompt/api/handlers.py create mode 100644 simple_prompt/comfyui/__init__.py create mode 100644 simple_prompt/comfyui/nodes.py create mode 100644 simple_prompt/comfyui/server.py create mode 100644 simple_prompt/core/__init__.py create mode 100644 simple_prompt/core/categories.py create mode 100644 simple_prompt/core/config.py create mode 100644 simple_prompt/core/database.py create mode 100644 simple_prompt/core/presets.py create mode 100644 simple_prompt/core/tags.py create mode 100644 simple_prompt/core/utils.py create mode 100644 src/App.dev.vue create mode 100644 src/App.vue create mode 100644 src/components/AutocompleteList.vue create mode 100644 src/components/CustomTagModal.vue create mode 100644 src/components/ModalWrapper.vue create mode 100644 src/components/OtherFunctions.vue create mode 100644 src/components/SettingsModal.vue create mode 100644 src/components/TagItem.vue create mode 100644 src/components/TagManager.vue create mode 100644 src/components/TagSearchModal.vue create mode 100644 src/components/TextEditor.vue create mode 100644 src/components/ToolBar.vue create mode 100644 src/components/VisualTagArea.vue create mode 100644 src/dev.ts create mode 100644 src/i18n.ts create mode 100644 src/main.ts create mode 100644 src/utils/caret.ts create mode 100644 src/utils/categoryService.ts create mode 100644 src/utils/duckdbService.ts create mode 100644 src/utils/metaService.ts create mode 100644 src/utils/promptParser.ts create mode 100644 src/utils/settings.ts create mode 100644 src/utils/types.ts create mode 100644 tests/__init__.py create mode 100644 tests/test_search.py create mode 100644 tsconfig.json create mode 100644 vite.config.mts create mode 100644 vite.debug.config.mts diff --git a/.agent/rules/llms.md b/.agent/rules/llms.md new file mode 100644 index 0000000..6f8a556 --- /dev/null +++ b/.agent/rules/llms.md @@ -0,0 +1,8 @@ +--- +trigger: always_on +--- + +Always read official documents before perform tasks. + +- +- \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d4a2c44 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +end_of_line = lf + +[*.bat] +indent_style = tab +end_of_line = crlf + +[LICENSE] +insert_final_newline = false + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..eb5d0d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.gitignore merge=ours \ No newline at end of file diff --git a/.github/workflows/publish_action.yml b/.github/workflows/publish_action.yml new file mode 100644 index 0000000..122a1ae --- /dev/null +++ b/.github/workflows/publish_action.yml @@ -0,0 +1,20 @@ +name: Publish to Comfy registry +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "pyproject.toml" + +jobs: + publish-node: + name: Publish Custom Node to registry + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Publish Custom Node + uses: Comfy-Org/publish-node-action@main + with: + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here. \ No newline at end of file diff --git a/.github/workflows/vite-build.yml b/.github/workflows/vite-build.yml new file mode 100644 index 0000000..ff7c299 --- /dev/null +++ b/.github/workflows/vite-build.yml @@ -0,0 +1,35 @@ +name: NodeJS with vite build + Publish to Comfy registry + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'jtydhr88' }} + + strategy: + matrix: + node-version: [18.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Build + run: | + npm install + npm run build + + - name: Publish Custom Node + uses: Comfy-Org/publish-node-action@main + with: + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} + skip_checkout: 'true' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6732b3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,121 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# OSX useful to ignore +*.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +venv/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# IntelliJ Idea +.idea +*.iml +*.ipr +*.iws + +# PyBuilder +target/ + +# Cookiecutter +output/ +python_boilerplate/ +cookiecutter-pypackage-env/ + +# vscode settings +.history/ +*.code-workspace + +# Frontend extension +node_modules/ +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +node.zip +.vscode/ +.claude/ +.GEMINI.md + +# duckdb +data/* +!data/default* + +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2539924 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.4.9 + hooks: + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..46db539 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026, ComfyUI-Simple-Prompt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..44f677e --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# ComfyUI Simple Prompt + +[中文](readme_zh.md) | [English](README.md) + + +A powerful, visual prompt editing node for ComfyUI that blends the best of text-based and visual tag management. + +![Simple Prompt Interface](./doc/preview.png) + +## ✨ Key Features + +* **⚡ High-Performance Backend**: Built with **DuckDB** to handle 70k+ Danbooru tags with millisecond-level search and autocomplete latency. +* **🎨 Visual & Text Modes**: + * **Visual Mode**: Drag-and-drop tags, double-click to adjust weights, color-coded categories (Character, Artist, etc.). + * **Text Mode**: Classic text editing with syntax highlighting and smart autocomplete. +* **🔍 Advanced Search**: + * Search by tag name or **alias** (e.g., "miku" finds "hatsune_miku"). + * Filter by categories (Artist, Style, Character, etc.). + * Sort by post count to find the most popular tags. +* **❤️ Personalization**: + * **Like Tags**: Star your favorite tags for quick access. + * **Custom Tags**: Add your own tags that aren't in the database. + * **Data Updates**: One-click update of tag data from the cloud. +* **🛠️ Deep Integration**: Fully integrated into the ComfyUI node graph workflow. + +## 🚀 Installation + +### Method 1: ComfyUI Manager (Recommended) +1. Open ComfyUI Manager. +2. Search for `Simple Prompt`. +3. Click **Install**. + +### Method 2: Manual Installation +1. Navigate to your ComfyUI `custom_nodes` directory: + ```bash + cd ComfyUI/custom_nodes/ + ``` +2. Clone this repository: + ```bash + git clone https://github.com/0nikod/ComfyUI-Simple-Prompt.git + cd Comfyui-Simple-Prompt + ``` +3. Install dependencies (DuckDB is required): + ```bash + pip install -r requirements.txt + # OR directly + pip install duckdb + ``` +4. Restart ComfyUI. + +## 📖 Usage + +### Basic Controls +* **Adding Tags**: Type in the text box or use the Search button to find tags. +* **Autocomplete**: Press `Ctrl + Space` or simply type to see suggestions. +* **Weight Adjustment**: + * **Visual**: Double-click a tag capsule to edit weight. + * **Shortcuts**: Select a tag and press `Ctrl + Up` / `Ctrl + Down` to increase/decrease weight. + +### Data Management +* **Updating Tags**: Go to Settings -> Data Management -> Click **Update Now** to get the latest Danbooru tag dataset. +* **User Tags**: Add custom tags via the Search Modal -> "Add Custom Tag". + +## ⚙️ Settings + +Click the **Settings** (gear icon) to configure: +* **Smart Backspace**: Automatically delete associated commas/spaces when deleting a tag. +* **Underscore Conversion**: Auto-convert `_` to space when adding tags. +* **Language**: Switch between English and Chinese. + +## 🏗️ Architecture + +* **Frontend**: Vue 3 + Vite + PrimeVue +* **Backend**: Python + DuckDB + Parquet +* **Communication**: ComfyUI API Routes + +## 🤝 Contributing + +Contributions are welcome! Please check the [Design Document (EN)](./doc/DESIGN_DOC_EN.md) / [设计文档 (ZH)](./doc/DESIGN_DOC_ZH.md) for architectural details. + +## 📄 License + +MIT License diff --git a/README_ARCH.md b/README_ARCH.md new file mode 100644 index 0000000..3cb8496 --- /dev/null +++ b/README_ARCH.md @@ -0,0 +1,70 @@ +# ComfyUI-Simple-Prompt Architecture Documentation + +This document describes the architectural design of `ComfyUI-Simple-Prompt` after the v0.1.0 refactor. The main goal of this refactor was to decouple the core business logic from the ComfyUI framework and support hot reloading during development. + +## Directory Structure + +``` +ComfyUI-Simple-Prompt/ +├── simple_prompt/ +│ ├── __init__.py # Package initialization +│ ├── core/ # Core business logic (platform-independent) +│ │ ├── config.py # Path configurations +│ │ ├── database.py # DuckDB connection and initialization +│ │ ├── tags.py # Tag search and management logic +│ │ ├── categories.py # Category management logic +│ │ ├── presets.py # Preset management logic +│ │ └── utils.py # General utility functions +│ ├── api/ # API abstraction layer +│ │ └── handlers.py # Pure functional request handlers +│ └── comfyui/ # ComfyUI adapter layer +│ ├── nodes.py # Node definitions (SimplePrompt) +│ └── server.py # PromptServer route registration & hot reload logic +├── tests/ # Unit tests +├── js/ # Frontend build artifacts +├── src/ # Frontend source code +├── __init__.py # ComfyUI entry point +└── pyproject.toml # Python project configuration +``` + +## Core Design + +### 1. Layered Architecture +- **Core Layer (`simple_prompt/core`)**: Contains pure Python logic, independent of `aiohttp` or `ComfyUI`. All data operations (DuckDB, JSON read/write) are handled in this layer. +- **API Layer (`simple_prompt/api`)**: `handlers.py` acts as an intermediate layer, receiving parameters, calling Core layer functions, and returning standard Python dictionaries/lists. +- **Adapter Layer (`simple_prompt/comfyui`)**: Responsible for forwarding `PromptServer` requests to the API layer and wrapping the results in `web.json_response`. + +### 2. Data Management +- Uses **DuckDB** as the primary query engine, merging multiple Parquet data sources (Main repository, User custom, Liked, Default) through Views. +- Configuration files (categories, presets) are stored as JSON. +- All data paths are centrally managed in `simple_prompt/core/config.py`. + +## Development Guide + +### Hot Reload + +To improve development efficiency, the backend supports modifying code **without restarting ComfyUI**. + +**How to enable:** +Set the environment variable `SIMPLE_PROMPT_DEV=1` when starting ComfyUI. + +**Windows (PowerShell):** +```powershell +$env:SIMPLE_PROMPT_DEV="1" +.\run_nvidia_gpu.bat # Or your startup script +``` + +**How it works:** +- `simple_prompt/comfyui/server.py` contains a `_maybe_reload()` function. +- Every API request (e.g., tag search) triggers this function. If in development mode, it uses `importlib.reload` to reload all modules under `simple_prompt.core` and `simple_prompt.api`. +- **Note**: After modifying `nodes.py` (node definitions) or `server.py` (route registration), you **still need to restart** ComfyUI as these are registered at startup. + +### Running Tests + +Use `pytest` to run unit tests: + +```bash +uv run pytest tests/ -v +``` + +The test environment is independent and does not rely on the ComfyUI runtime. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..758dfdc --- /dev/null +++ b/__init__.py @@ -0,0 +1,35 @@ +""" +Simple Prompt - ComfyUI Custom Node +一个带有高级可视化标签编辑器的提示词构建节点 + +架构说明: +- simple_prompt/core/ - 核心业务逻辑(与平台无关) +- simple_prompt/api/ - API 处理器层 +- simple_prompt/comfyui/ - ComfyUI 特定适配 + +热重载:设置环境变量 SIMPLE_PROMPT_DEV=1 启用开发模式 +""" + +import os +import sys + +# 将当前目录添加到 sys.path,以便能导入 simple_prompt 包 +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.append(current_dir) + +# 初始化核心模块 +from simple_prompt.core.database import init_duckdb + +init_duckdb() + +# 注册 API 路由(副作用导入) +from simple_prompt.comfyui import server # noqa: F401 + +# 导出节点定义 +from simple_prompt.comfyui.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +# 前端扩展目录 +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/data/default_category.json b/data/default_category.json new file mode 100644 index 0000000..de5010a --- /dev/null +++ b/data/default_category.json @@ -0,0 +1,37 @@ +[ + { + "id": 0, + "name": "General", + "color": "#0075db" + }, + { + "id": 1, + "name": "Artist", + "color": "#c00004" + }, + { + "id": 3, + "name": "Copyright", + "color": "#c000c0" + }, + { + "id": 4, + "name": "Character", + "color": "#00aa00" + }, + { + "id": 5, + "name": "Meta", + "color": "#fd9200" + }, + { + "id": 6, + "name": "Special", + "color": "#ffd700" + }, + { + "id": 7, + "name": "Rating", + "color": "#ff69b4" + } +] \ No newline at end of file diff --git a/data/default_presets.json b/data/default_presets.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/data/default_presets.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/data/default_tags.parquet b/data/default_tags.parquet new file mode 100644 index 0000000000000000000000000000000000000000..ffabcf9eab1fdb61a3b6a69f24345b30d9d5644a GIT binary patch literal 957 zcmZvb&ubGw6vy9eCdsCzxD{vEzzU0OmsCn^Y6GzpNu>vo9*W?#WwSd;1~;3yKiUR8 zNb%@Nig@sK@^76bOP#J-ePnS*A9 zRRI2wO$`7XLh+k;eL-|1O8Un&VoXGo$W$H)9bMvN5=vjDWwcuZ8HeOvz115}$(@f! zBA(jbv3CPqBQHrsJdwf|b?o*;lG-mbFO=z&JWr>>i|vi|jZIt_B5{n~Dq2H17{($Y zcO9>nWO0D@%|)|m<9{4xsT@aT+rrqPNTp8}9dY7|FceX`L|z#pklASKKG-vOn^CsT z*7mSIWSg`KHL|T1R86I?8kT=XSW^i>3n89%3LP{Zc0Qk^cn2-9@7*_xAK|A z#MmBiU`)V5Howhd;@T*VvzYSg5F``55>r1IFzz(0#-0lnt2Y=l ziba^l9sak(zm;5l+pJw~j7imr;n2K3w6uizD+q1ibCp~UI@S0YaX(S_SIIJUbzW36 z_1RW(;FbT4Bh-CYVF&Z%xhiw^0q?sX%ID^-&Ua6+{OLbeQe{!N$_ynN3FHZwFh>ym&q`&gI3^oI{k<3 N^%eMm=V2c&?ms*xr@a6G literal 0 HcmV?d00001 diff --git a/doc/DESIGN_DOC_EN.md b/doc/DESIGN_DOC_EN.md new file mode 100644 index 0000000..e8ec210 --- /dev/null +++ b/doc/DESIGN_DOC_EN.md @@ -0,0 +1,97 @@ +# ComfyUI Simple Prompt Design Document + +## 1. Project Overview + +**ComfyUI Simple Prompt** is an advanced prompt editing node designed specifically for ComfyUI. It aims to solve the pain points of traditional text input boxes when dealing with complex prompts, providing a WebUI-like visual editing experience while maintaining ComfyUI's fluidity. + +Core Goals: +* **Visual Editing**: Provides capsule-style tag displays that are easy to drag, delete, and adjust weights. +* **High-Performance Data Retrieval**: Utilizes **DuckDB** and **Parquet** files to handle massive Danbooru tag data, achieving millisecond-level search and completion. +* **Seamless Integration**: Functions as a custom ComfyUI node, integrating perfectly into existing workflows. +* **Customization & Expansion**: Supports user-defined tags, favorite tags, and allows for data updates via the network. + +## 2. System Architecture + +The project adopts a decoupled frontend-backend architecture: + +* **Backend (Python)**: Responsible for data storage, retrieval APIs, and node logic. +* **Frontend (Vue 3 + TypeScript)**: Responsible for UI interaction, state management, and communicating with the backend via API. + +### 2.1 Backend Architecture (`simple_prompt/`) + +The backend core has been refactored into the `simple_prompt` package, separating core business logic from the ComfyUI adaptation layer. + +* **Data Storage (DuckDB)**: + Uses DuckDB's In-Memory database to query Parquet files directly from disk via `read_parquet`. + * **Layered Data Sources** (Priority from high to low): + 1. `liked_tags.parquet` (Custom Data): User-bookmarked/liked tags. + 2. `user_tags.parquet` (Custom Data): User-added custom tags. + 3. `default_tags.parquet`: Pre-configured default tags. + 4. `tags.parquet`: Main dataset containing 70k+ Danbooru tags. + +* **Key APIs** (Defined in `simple_prompt/comfyui/server.py` & `simple_prompt/api/handlers.py`): + * `GET /simple-prompt/tags/list`: Get tag list (supports pagination and search). + * `GET /simple-prompt/search-tags`: High-performance search (supports alias matching and category filtering). + * `POST /simple-prompt/add-custom-tag`: Add or modify a tag. + * `POST /simple-prompt/toggle-like-tag`: Favorite or unfavorite a tag. + * `POST /simple-prompt/update-tags`: Update data files from GitHub Releases. + +### 2.2 Frontend Architecture (`src/`) + +The frontend is built with **Vue 3** (Composition API) and bundled using **Vite**. + +* **Component Structure**: + * `App.vue`: Root component, mounted to the ComfyUI node's DOM element. + * `VisualTagArea.vue`: Visual tag display area, supporting drag-and-drop sorting and double-click weight adjustment. + * `TextEditor.vue`: Plain text editing area, supporting syntax highlighting and smart autocomplete. + * `TagSearchModal.vue`: Advanced search modal, providing filtering by category (Artist, Character, etc.). + * `SettingsModal.vue`: Settings panel for managing interface language, editing habits, etc. + * `TagManager.vue`: Data management panel for managing custom and bookmarked tags. + +* **Internationalization (i18n)**: + Uses `vue-i18n`, supporting switching between English and Chinese (`locales/en/main.json`, `locales/zh/main.json`). + +## 3. Core Feature Design + +### 3.1 Data Retrieval and Completion +* **Backend Implementation**: Leverages DuckDB's SQL engine to perform fuzzy matching via `ILIKE`. +* **Alias System**: Supports Danbooru alias searching (e.g., searching "miku" can match "hatsune_miku"). This is implemented in SQL by expanding the `alias` array using `unnest`. +* **Performance Optimization**: Parquet files are pre-sorted by `post_count`, and DuckDB queries prioritize returning high-frequency tags first. + +### 3.2 Autocomplete +* **Trigger Mechanism**: Triggered in real-time as you type in the text box, or explicitly using `Ctrl+Space`. +* **Logic**: The frontend captures the word at the cursor position and sends it to the backend's `/simple-prompt/search-tags` endpoint. The backend returns a list of matches. Configurable to include or exclude aliases. + +### 3.3 Tag Weight Adjustment +* **From UI**: Double-click a visual tag capsule or use shortcuts (`Ctrl+Up/Down`) to adjust weights. +* **Format**: Standard A1111 format `(tag:1.1)` or `(tag:0.9)`. +* **Smart Parsing**: Automatically parses weight parentheses in the text into visual numeric values. + +## 4. Data Flow Diagram + +```mermaid +graph TD + User[User Operation] -->|Input/Search| Frontend[Vue Frontend] + Frontend -->|API Request| Backend[Python Backend] + Backend -->|SQL Query| DuckDB[(DuckDB In-Memory)] + DuckDB -->|Read| Parquet1[tags.parquet] + DuckDB -->|Read| Parquet2[user_tags.parquet] + DuckDB -->|Read| Parquet3[liked_tags.parquet] + Backend -->|JSON Response| Frontend + Frontend -->|Update Prompt| ComfyUI[ComfyUI Node] +``` + +## 5. Directory Structure Explanation + +* `simple_prompt/`: Main Python backend source code. + * `core/`: Core business logic (Tags, Database). + * `api/`: API processing logic. + * `comfyui/`: ComfyUI node and Server adaptation. +* `js/`: Compiled frontend code (generated by Vite). +* `src/`: Frontend source code. +* `data/`: Directory for Parquet data files. +* `locales/`: Localization files. + +## 6. Future Roadmap +* Support for more prompt formats (e.g., NovelAI format). +* Integration of richer image previews (when hovering over a tag). diff --git a/doc/DESIGN_DOC_ZH.md b/doc/DESIGN_DOC_ZH.md new file mode 100644 index 0000000..65ff325 --- /dev/null +++ b/doc/DESIGN_DOC_ZH.md @@ -0,0 +1,97 @@ +# ComfyUI Simple Prompt 设计文档 + +## 1. 项目概述 + +**ComfyUI Simple Prompt** 是一个专为 ComfyUI 设计的高级提示词编辑节点。它旨在解决传统文本输入框在处理复杂 Prompt 时的痛点,提供类似 WebUI 的可视化编辑体验,同时保持 ComfyUI 的流畅性。 + +核心目标: +* **可视化编辑**:提供胶囊式的标签展示,易于拖拽、删除和权重调整。 +* **高性能数据检索**:利用 **DuckDB** 和 **Parquet** 文件处理海量 Danbooru 标签数据,实现毫秒级搜索和补全。 +* **无缝集成**:作为 ComfyUI 的自定义节点,完美融入现有工作流。 +* **自定义与扩展**:支持用户自定义标签、收藏标签,并允许通过网络更新数据。 + +## 2. 系统架构 + +项目采用前后端分离的架构: + +* **后端 (Python)**: 负责数据存储、检索 API 和节点逻辑。 +* **前端 (Vue 3 + TypeScript)**: 负责 UI 交互、状态管理和通过 API 与后端通信。 + +### 2.1 后端架构 (`simple_prompt/`) + +后端核心已被重构为 `simple_prompt` 包,核心业务逻辑与 ComfyUI 适配层分离。 + +* **数据存储 (DuckDB)**: + 使用 DuckDB 的 In-Memory 数据库,通过 `read_parquet` 直接查询磁盘上的 Parquet 文件。 + * **分层数据源**(优先级从高到低): + 1. `liked_tags.parquet` (Custom Data): 用户收藏/点赞的标签。 + 2. `user_tags.parquet` (Custom Data): 用户添加的自定义标签。 + 3. `default_tags.parquet`: 预置的默认标签。 + 4. `tags.parquet`: 包含 70k+ Danbooru 标签的主数据集。 + +* **关键 API** (定义于 `simple_prompt/comfyui/server.py` & `simple_prompt/api/handlers.py`): + * `GET /simple-prompt/tags/list`: 获取标签列表(支持分页、搜索)。 + * `GET /simple-prompt/search-tags`: 高性能搜索(支持别名匹配、分类筛选)。 + * `POST /simple-prompt/add-custom-tag`: 添加/修改 Tag。 + * `POST /simple-prompt/toggle-like-tag`: 收藏/取消收藏 Tag。 + * `POST /simple-prompt/update-tags`: 从 GitHub Release 更新数据文件。 + +### 2.2 前端架构 (`src/`) + +前端使用 **Vue 3** (Composition API) 构建,利用 **Vite** 进行打包。 + +* **组件结构**: + * `App.vue`: 根组件,挂载到 ComfyUI 节点的 DOM 元素上。 + * `VisualTagArea.vue`: 可视化标签展示区,支持标签的拖拽排序、双击修改权重。 + * `TextEditor.vue`: 纯文本编辑区,支持语法高亮、自动补全 (Autocomplete)。 + * `TagSearchModal.vue`: 高级搜索模态框,提供按分类 (Artist, Character 等) 筛选的功能。 + * `SettingsModal.vue`: 设置面板,管理界面语言、编辑习惯等。 + * `TagManager.vue`: 数据管理面板,用于管理自定义和收藏的标签。 + +* **国际化 (i18n)**: + 使用 `vue-i18n`,支持中英文切换 (`locales/zh/main.json`, `locales/en/main.json`)。 + +## 3. 核心功能设计 + +### 3.1 数据检索与补全 +* **后端实现**: 利用 DuckDB 的 SQL 引擎,通过 `ILIKE` 进行模糊匹配。 +* **别名系统**: 支持 Danbooru 别名搜索(例如搜 "hatsune miku" 可以匹配到 "hatsune_miku")。在 SQL 中通过 `unnest(alias)` 展开数组进行匹配。 +* **性能优化**: Parquet 文件按 `post_count` 预排序,DuckDB 查询时优先返回高频标签。 + +### 3.2 自动补全 (Autocomplete) +* **触发机制**: 在文本框输入时实时触发,或通过 `Ctrl+Space` 唤起。 +* **逻辑**: 前端捕获光标位置的单词,发送给后端 `/simple-prompt/search-tags`,后端返回匹配列表。支持配置是否匹配别名。 + +### 3.3 标签权重调整 +* **从 UI**: 双击可视化标签或使用快捷键 (Ctrl+Up/Down) 调整权重。 +* **格式**: 标准 A1111 格式 `(tag:1.1)` 或 `(tag:0.9)`。 +* **智能解析**: 能够自动将文本中的括号权重解析为可视化数值。 + +## 4. 数据流图 + +```mermaid +graph TD + User[用户操作] -->|输入/搜索| Frontend[Vue 前端] + Frontend -->|API 请求| Backend[Python 后端] + Backend -->|SQL Query| DuckDB[(DuckDB 内存库)] + DuckDB -->|Read| Parquet1[tags.parquet] + DuckDB -->|Read| Parquet2[user_tags.parquet] + DuckDB -->|Read| Parquet3[liked_tags.parquet] + Backend -->|JSON 响应| Frontend + Frontend -->|更新 Prompt| ComfyUI[ComfyUI 节点] +``` + +## 5. 目录结构说明 + +* `simple_prompt/`: Python 后端主要源码。 + * `core/`: 核心业务逻辑 (Tags, Database)。 + * `api/`: API 处理逻辑。 + * `comfyui/`: ComfyUI 节点与 Server 适配。 +* `js/`: 编译后的前端代码 (由 Vite 生成)。 +* `src/`: 前端源码。 +* `data/`: 存放 Parquet 数据文件。 +* `locales/`: 语言文件。 + +## 6. 未来规划 +* 支持更多的 Prompt 格式(如 NovelAI 格式)。 +* 集成更丰富的图像预览功能(当鼠标悬停在标签上时)。 diff --git a/doc/preview.png b/doc/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..98dfe4581d5c7d5a2e6a598d021386b7a50a3d75 GIT binary patch literal 189731 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 + + + + + + Simple Prompt Editor - Dev Mode + + +
+ + + + + + + diff --git a/locales/en/main.json b/locales/en/main.json new file mode 100644 index 0000000..2fd88a7 --- /dev/null +++ b/locales/en/main.json @@ -0,0 +1,140 @@ +{ + "common": { + "cancel": "Cancel", + "save": "Save", + "close": "Close", + "done": "Done", + "confirm": "Confirm", + "update": "Update" + }, + "settings": { + "title": "Settings", + "sections": { + "textFormat": "Text Formatting", + "autocomplete": "Autocomplete & Search", + "editing": "Editing Behavior", + "interface": "Interface", + "data": "Data Management", + "tagManager": "Tag Manager", + "addPlaceholder": "New Tag Name (comma separated for multiple)", + "categoryManager": "Category Management", + "metaManager": "Meta Tag Management" + }, + "items": { + "convertUnderscore": "Convert Underscore to Space", + "convertUnderscoreDesc": "Automatically convert underscores to spaces", + "escapeBrackets": "Escape Brackets", + "escapeBracketsDesc": "Escape parentheses as \\(\\)", + "useAliasSearch": "Use Aliases in Search", + "useAliasSearchDesc": "Match tag aliases when searching", + "useAliasAutocomplete": "Use Aliases in Autocomplete", + "useAliasAutocompleteDesc": "Match aliases in autocomplete (may impact performance)", + "smartBackspace": "Smart Backspace", + "smartBackspaceDesc": "When deleting the last character of a tag, also remove preceding comma and space", + "language": "Language", + "languageDesc": "Interface language", + "allowEditDefaultTags": "Allow Editing Default Tags", + "allowEditDefaultTagsDesc": "Enable editing and deleting of default tags (Use with caution).", + "tagMaxLength": "Tag Max Length", + "tagMaxLengthDesc": "Maximum number of characters to display for a tag (0 to disable)", + "updateTags": "Update Tags Data", + "updateTagsDesc": "Download the latest tags.parquet from GitHub", + "updateLikedTags": "Update Liked Tags", + "updateLikedTagsDesc": "Sync liked tags data with main database.", + "updateUserTags": "Update User Tags", + "updateUserTagsDesc": "Sync custom user tags data with main database.", + "updateTagsStatus": "Update Status", + "updateNow": "Update Now", + "updating": "Updating...", + "updateSuccess": "Update successful!", + "updateError": "Update failed: ", + "currentVersion": "Current Version", + "checkingUpdate": "Checking for updates...", + "upToDate": "Latest version: ", + "updateAvailable": "Update available: ", + "unknown": "Unknown" + }, + "meta": { + "title": "Meta Presets Management", + "newPreset": "New Preset", + "editPreset": "Edit Preset", + "presetName": "Preset Name", + "presetTags": "Tags (comma separated)", + "customPresets": "Custom Presets", + "defaultPresets": "Default Presets (Read-only)", + "noPresets": "No custom presets.", + "searchPlaceholder": "Search...", + "noPresetsFound": "No presets found.", + "editInSettings": "Edit in Settings > Meta", + "presetNamePlaceholder": "Preset Name (e.g. Model A)", + "presetTagsPlaceholder": "Tags (comma separated, e.g. quality:best, rating:safe)", + "customPresetsHeading": "Custom Presets", + "defaultPresetsHeading": "Default Presets (Read-only)", + "noCustomPresets": "No custom presets." + }, + "autoSave": "Settings are saved automatically" + }, + "search": { + "title": "Tag Search", + "placeholder": "Search tags (aliases supported)...", + "filterLabel": "Category Filters:", + "loading": "Searching...", + "noResults": "No matching tags found", + "hint": "Enter at least 2 characters to search", + "categories": { + "general": "General", + "artist": "Artist", + "character": "Character", + "copyright": "Copyright", + "meta": "Meta", + "unknown": "Unknown" + }, + "addBtnTitle": "Add to prompt" + }, + "customTag": { + "title": "Add Custom Tag", + "titleEdit": "Edit Tag", + "addBtnTitle": "Add New Tag", + "nameLabel": "Tag Name", + "namePlaceholder": "e.g., my_awesome_tag", + "categoryLabel": "Category", + "countLabel": "Post Count (Optional)", + "countHint": "Estimated usage frequency", + "aliasLabel": "Aliases (Optional)", + "aliasPlaceholder": "Comma-separated, e.g., tag1, tag2", + "aliasHint": "Alternative names to search for this tag", + "errorNameRequired": "Tag name is required", + "errorGeneric": "Failed to add tag", + "successAdded": "Tag added successfully!", + "successEdited": "Tag updated successfully!" + }, + "category": { + "cannotDeleteDefault": "Cannot delete default category.", + "nameRequired": "Name and tags are required", + "namePlaceholder": "Category Name", + "addButton": "Add" + }, + "editor": { + "copy": "Copy", + "copyTitle": "Copy to Clipboard", + "placeholder": "Enter positive prompt here...", + "subtitle": "Simple Prompt Editor", + "tagCount": "Tags: ", + "autocompleteHint": "Ctrl+Space for autocomplete", + "searchBtn": "Search", + "loading": "Loading...", + "noMatches": "No matching tags", + "decreaseWeight": "Decrease weight", + "increaseWeight": "Increase weight", + "removeTag": "Remove tag", + "format": "Format", + "formatTitle": "Format Text", + "organize": "Organize", + "organizeTitle": "Organize Tags", + "autoMeta": "Auto Meta", + "qualityWords": "Quality Tags", + "selectPreset": "Select Meta Preset", + "presetSearchPlaceholder": "Search presets...", + "editInSettings": "Edit in Settings > Meta" + } +} \ No newline at end of file diff --git a/locales/zh/main.json b/locales/zh/main.json new file mode 100644 index 0000000..b7387ef --- /dev/null +++ b/locales/zh/main.json @@ -0,0 +1,140 @@ +{ + "common": { + "cancel": "取消", + "save": "保存", + "close": "关闭", + "done": "完成", + "confirm": "确认", + "update": "更新" + }, + "settings": { + "title": "设置", + "sections": { + "textFormat": "文本格式", + "autocomplete": "补全与搜索", + "editing": "编辑行为", + "interface": "界面", + "data": "数据管理", + "tagManager": "标签管理", + "addPlaceholder": "输入标签名称 (支持逗号分隔批量添加)", + "categoryManager": "分类管理", + "metaManager": "元数据管理" + }, + "items": { + "convertUnderscore": "下划线转空格", + "convertUnderscoreDesc": "自动将标签中的下划线 \"_\" 转换为空格", + "escapeBrackets": "转义括号", + "escapeBracketsDesc": "将括号 \"()\" 转义为 \"\\(\\)\"", + "useAliasSearch": "在搜索中使用别名", + "useAliasSearchDesc": "搜索时匹配标签别名", + "useAliasAutocomplete": "在自动补全中使用别名", + "useAliasAutocompleteDesc": "自动补全时匹配别名(可能影响性能)", + "smartBackspace": "智能退格", + "smartBackspaceDesc": "删除标签最后一个字符时,连同前面的逗号和空格一起删除", + "language": "语言", + "languageDesc": "界面语言", + "allowEditDefaultTags": "允许编辑默认标签", + "allowEditDefaultTagsDesc": "启用后可编辑和删除默认标签(请谨慎使用)。", + "tagMaxLength": "标签最大长度", + "tagMaxLengthDesc": "标签显示的最大字符数(0 表示不限制)", + "updateTags": "更新标签数据", + "updateTagsDesc": "从 GitHub 下载最新的 tags.parquet", + "updateLikedTags": "更新喜爱标签数据", + "updateLikedTagsDesc": "同步喜爱标签的统计数据与主数据库。", + "updateUserTags": "更新自定义标签数据", + "updateUserTagsDesc": "同步自定义标签的统计数据与主数据库。", + "updateTagsStatus": "更新状态", + "updateNow": "立即更新", + "updating": "更新中...", + "updateSuccess": "更新成功!", + "updateError": "更新失败:", + "currentVersion": "当前版本", + "checkingUpdate": "正在检查更新...", + "upToDate": "已是最新:", + "updateAvailable": "有可用更新:", + "unknown": "未知" + }, + "meta": { + "title": "Meta 预设管理", + "newPreset": "新建预设", + "editPreset": "编辑预设", + "presetName": "预设名称", + "presetTags": "标签 (逗号分隔)", + "customPresets": "自定义预设", + "defaultPresets": "默认预设 (只读)", + "noPresets": "无自定义预设", + "searchPlaceholder": "搜索...", + "noPresetsFound": "未找到预设。", + "editInSettings": "去 设置 > Meta 编辑", + "presetNamePlaceholder": "预设名称 (例如: Model A)", + "presetTagsPlaceholder": "标签 (逗号分隔, 例如: quality:best, rating:safe)", + "customPresetsHeading": "自定义预设", + "defaultPresetsHeading": "默认预设 (只读)", + "noCustomPresets": "无自定义预设" + }, + "autoSave": "设置会自动保存" + }, + "search": { + "title": "标签搜索", + "placeholder": "搜索标签(支持别名)...", + "filterLabel": "分类筛选:", + "loading": "搜索中...", + "noResults": "未找到匹配的标签", + "hint": "输入至少2个字符开始搜索", + "categories": { + "general": "通用", + "artist": "画师", + "character": "角色", + "copyright": "版权", + "meta": "元数据", + "unknown": "未知" + }, + "addBtnTitle": "添加到prompt" + }, + "customTag": { + "title": "添加自定义标签", + "titleEdit": "编辑标签", + "addBtnTitle": "添加新标签", + "nameLabel": "标签名称", + "namePlaceholder": "例如: my_awesome_tag", + "categoryLabel": "分类", + "countLabel": "帖子计数 (可选)", + "countHint": "估计的使用频率", + "aliasLabel": "别名 (可选)", + "aliasPlaceholder": "逗号分隔,例如: tag1, tag2", + "aliasHint": "搜索此标签时可使用的替代名称", + "errorNameRequired": "标签名称必填", + "errorGeneric": "添加标签失败", + "successAdded": "标签添加成功!", + "successEdited": "标签更新成功!" + }, + "category": { + "cannotDeleteDefault": "无法删除默认分类。", + "nameRequired": "名称和标签为必填项", + "namePlaceholder": "分类名称", + "addButton": "添加" + }, + "editor": { + "copy": "复制", + "copyTitle": "复制到剪贴板", + "placeholder": "在此处输入正向提示词...", + "subtitle": "Simple Prompt Editor", + "tagCount": "标签: ", + "autocompleteHint": "Ctrl+Space 触发自动补全", + "searchBtn": "搜索", + "loading": "加载中...", + "noMatches": "无匹配标签", + "decreaseWeight": "减小权重", + "increaseWeight": "增大权重", + "removeTag": "删除标签", + "format": "格式化", + "formatTitle": "格式化文本", + "organize": "整理", + "organizeTitle": "整理标签", + "autoMeta": "自动Meta", + "qualityWords": "质量标签", + "selectPreset": "选择 Meta 预设", + "presetSearchPlaceholder": "搜索预设...", + "editInSettings": "去 设置 > Meta 编辑" + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cbae853 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[build-system] +requires = ["setuptools>=70.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "comfyui_simple_prompt" +version = "0.2.1" +description = "Provide a simple interface to simplify prompt writing. / 提供一个简单的界面简化提示词编写。" +authors = [{ name = "0nikod", email = "dniko0@outlook.com" }] +readme = "README.md" +license = { text = "MIT license" } +requires-python = ">=3.10" +classifiers = [] +dependencies = ["duckdb>=1.4.3"] + + +[project.optional-dependencies] +dev = [ + "bump-my-version", + "coverage", # testing + "mypy", # linting + "pre-commit", # runs linting on commit + "pytest", # testing + "ruff", # linting +] + +[project.urls] +Repository = "https://github.com/0nikod/ComfyUI-Simple-Prompt" +BugTracker = "https://github.com/0nikod/ComfyUI-Simple-Prompt/issues" +Documentation = "https://github.com/0nikod/ComfyUI-Simple-Prompt/wiki" + + +[tool.comfy] +PublisherId = "dniko0" +DisplayName = "ComfyUI Simple Prompt" +Icon = "doc/preview.png" +Tags = ["prompt", "tag", "editor", "danbooru", "autocomplete"] +Repository = "https://github.com/0nikod/ComfyUI-Simple-Prompt" +includes = ["js"] + +[tool.setuptools.package-data] +"*" = ["*.*"] + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] + +[tool.mypy] +files = "." + +# Use strict defaults +strict = true +warn_unreachable = true +warn_no_return = true + +[[tool.mypy.overrides]] +# Don't require test functions to include types +module = "tests.*" +allow_untyped_defs = true +disable_error_code = "attr-defined" + +[tool.ruff] +# extend-exclude = ["static", "ci/templates"] +line-length = 140 +src = ["src", "tests"] +target-version = "py39" + +# Add rules to ban exec/eval +[tool.ruff.lint] +select = [ + "S102", # exec-builtin + "S307", # eval-used + "W293", + "F", # The "F" series in Ruff stands for "Pyflakes" rules, which catch various Python syntax errors and undefined names. + # See all rules here: https://docs.astral.sh/ruff/rules/#pyflakes-f +] + +[tool.ruff.lint.flake8-quotes] +inline-quotes = "double" diff --git a/readme_zh.md b/readme_zh.md new file mode 100644 index 0000000..d302311 --- /dev/null +++ b/readme_zh.md @@ -0,0 +1,82 @@ +# ComfyUI Simple Prompt + +[中文](readme_zh.md) | [English](README.md) + +这是一个功能强大的 ComfyUI 提示词编辑/管理节点,结合了文本编辑和可视化标签管理的优势。 + +![Simple Prompt Interface](./doc/preview.png) + +## ✨ 核心功能 + +* **⚡ 高性能后端**: 基于 **DuckDB** 构建,轻松处理 70k+ Danbooru 标签,实现毫秒级的搜索和自动补全。 +* **🎨 可视化与文本双模式**: + * **可视化模式**: 胶囊式标签展示,支持拖拽排序、双击调整权重,不同类型的标签(角色、画师等)显示不同颜色。 + * **文本模式**: 传统的文本输入体验,支持语法高亮和智能自动补全。 +* **🔍 高级搜索**: + * 支持标签名称和**别名**搜索(例如搜 "miku" 可以找到 "hatsune_miku")。 + * 支持按分类(画师、风格、角色等)筛选。 + * 支持按帖子数量排序,快速找到热门标签。 +* **❤️ 个性化与数据管理**: + * **收藏标签**: 星标你常用的标签,方便快速访问。 + * **自定义标签**: 添加数据库中不存在的新标签。 + * **在线更新**: 一键从云端更新 Danbooru 标签数据库。 +* **🛠️ 深度集成**: 作为标准节点无缝融入 ComfyUI 工作流。 + +## 🚀 安装指南 + +### 方法 1: 使用 ComfyUI Manager (推荐) +1. 打开 ComfyUI Manager。 +2. 搜索 `Simple Prompt`。 +3. 点击 **Install** 安装。 + +### 方法 2: 手动安装 +1. 进入 ComfyUI 的 `custom_nodes` 目录: + ```bash + cd ComfyUI/custom_nodes/ + ``` +2. 克隆本仓库: + ```bash + git clone https://github.com/0nikod/ComfyUI-Simple-Prompt.git + cd Comfyui-Simple-Prompt + ``` +3. 安装依赖 (必须安装 DuckDB): + ```bash + pip install -r requirements.txt + # 或者直接安装 + pip install duckdb + ``` +4. 重启 ComfyUI。 + +## 📖 使用说明 + +### 基本操作 +* **添加标签**: 在文本框输入,或点击“搜索”按钮查找标签。 +* **自动补全**: 在输入时会自动弹出建议,或按 `Ctrl + Space` 手动触发。 +* **调整权重**: + * **可视化调整**: 双击标签胶囊即可修改权重数值。 + * **快捷键**: 选中标签后,按 `Ctrl + Up` / `Ctrl + Down` 增加或减少权重。 + +### 数据管理 +* **更新数据**: 点击设置图标 -> 数据管理 -> 点击 **立即更新** 即可获取最新的标签数据。 +* **自定义标签**: 在搜索弹窗中点击 "添加自定义标签"。 + +## ⚙️ 设置选项 + +点击编辑器右上角的 **设置** (齿轮图标) 进行配置: +* **智能退格**: 删除标签时,自动清理多余的逗号和空格。 +* **下划线转换**: 添加标签时自动将 `_` 转换为 空格。 +* **语言**: 切换界面语言 (中文 / English)。 + +## 🏗️ 技术架构 + +* **前端**: Vue 3 + Vite + PrimeVue +* **后端**: Python + DuckDB + Parquet API +* **通信**: ComfyUI API Routes + +## 🤝 参与贡献 + +欢迎提交 PR 或 Issue!关于详细的架构设计,请参考 [设计文档 (中文)](./doc/DESIGN_DOC_ZH.md)。 + +## 📄 许可证 + +MIT License diff --git a/reproduce_parser.ts b/reproduce_parser.ts new file mode 100644 index 0000000..bb310c0 --- /dev/null +++ b/reproduce_parser.ts @@ -0,0 +1,135 @@ + +// Mock types +enum TagCategory { + GENERAL = 0, + ARTIST = 1, + COPYRIGHT = 2, + CHARACTER = 4, + META = 5, + SPECIAL = 6, + RATING = 7 +} + +interface TagItem { + id: string; + text: string; + weight: number; + enabled: boolean; + category: number; +} + +// Paste original functions here for testing +function textToTags(text: string): TagItem[] { + if (!text.trim()) return []; + + const tags: TagItem[] = []; + let idx = 0; + let i = 0; + let currentTag = ''; + let inWeight = false; + + while (i < text.length) { + const char = text[i]; + + // Handle escape sequences + if (char === '\\' && i + 1 < text.length) { + const nextChar = text[i + 1]; + if (nextChar === '(' || nextChar === ')') { + currentTag += nextChar; + i += 2; + continue; + } else { + currentTag += char; + i++; + continue; + } + } + + if (char === '(' && !inWeight) { + if (currentTag.trim()) { + tags.push({ id: `tag-${idx++}`, text: currentTag.trim(), weight: 1.0, enabled: true, category: TagCategory.GENERAL }); + currentTag = ''; + } + + let depth = 1; + let j = i + 1; + + while (j < text.length && depth > 0) { + if (text[j] === '\\' && j + 1 < text.length) { + j += 2; + continue; + } + if (text[j] === '(') depth++; + if (text[j] === ')') depth--; + j++; + } + + if (depth === 0) { + const content = text.substring(i + 1, j - 1); + const colonIndex = content.indexOf(':'); + + if (colonIndex > 0) { + const tagText = content.substring(0, colonIndex).trim(); + const weight = parseFloat(content.substring(colonIndex + 1).trim()); + if (!isNaN(weight) && tagText) { + tags.push({ id: `tag-${idx++}`, text: tagText, weight: weight, enabled: true, category: TagCategory.GENERAL }); + } + } else { + currentTag += text.substring(i, j); + } + i = j; + continue; + } + } + + if (char === ',' || char === '\n') { + if (currentTag.trim()) { + tags.push({ id: `tag-${idx++}`, text: currentTag.trim(), weight: 1.0, enabled: true, category: TagCategory.GENERAL }); + currentTag = ''; + } + i++; + continue; + } + + currentTag += char; + i++; + } + + if (currentTag.trim()) { + tags.push({ id: `tag-${idx++}`, text: currentTag.trim(), weight: 1.0, enabled: true, category: TagCategory.GENERAL }); + } + + return tags; +} + +function tagsToText(tags: TagItem[]): string { + const enabledTags = tags.filter(tag => tag.enabled); + if (enabledTags.length === 0) return ''; + + return enabledTags + .map(tag => { + const escapedText = tag.text.replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + if (tag.weight !== 1.0) { + return `(${escapedText}:${tag.weight.toFixed(1)})`; + } + return escapedText; + }) + .join(', ') + ','; +} + +// Test Case +const input = "aaa \\(bbb\\)"; +console.log("Input:", input); +const tags = textToTags(input); +console.log("Parsed Tags:", JSON.stringify(tags, null, 2)); + +const output = tagsToText(tags); +console.log("Output:", output); + +if (output.includes("\\\\")) { + console.log("FAIL: Double escaping detected!"); +} else if (output.trim().replace(/,$/, '') === input) { + console.log("PASS: Output matches input."); +} else { + console.log("DIFF: Output different from input."); +} diff --git a/reproduce_parser_v2.ts b/reproduce_parser_v2.ts new file mode 100644 index 0000000..8284400 --- /dev/null +++ b/reproduce_parser_v2.ts @@ -0,0 +1,37 @@ + +import { textToTags, tagsToText } from './src/utils/promptParser'; + +// Test Case +const input = "aaa \\(bbb\\)"; +console.log("Input:", input); +const tags = textToTags(input); +console.log("Parsed Tags:", JSON.stringify(tags, null, 2)); + +const output = tagsToText(tags); +console.log("Output:", output); + +if (output.includes("\\\\(")) { + console.log("FAIL: Double escaping detected!"); +} else if (output.trim().replace(/,$/, '') === input) { + console.log("PASS: Output matches input."); +} else { + // It might differ slightly if we normalized spacing etc, but key is no double escape + console.log("INFO: Output is:", output); + if (!output.includes("\\\\(")) { + console.log("PASS: No double escaping."); + } +} + +// Test Weighted double escape +const inputWeighted = "(aaa \\(bbb\\):1.0)"; +console.log("\nInput Weighted:", inputWeighted); +const tagsW = textToTags(inputWeighted); +console.log("Parsed Tags Weighted:", JSON.stringify(tagsW, null, 2)); +const outputW = tagsToText(tagsW); +console.log("Output Weighted:", outputW); + +if (outputW.includes("\\\\(")) { + console.log("FAIL: Double escaping in weighted tag!"); +} else { + console.log("PASS: No double escaping in weighted tag."); +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3da59df --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +duckdb \ No newline at end of file diff --git a/scripts/check_schema.py b/scripts/check_schema.py new file mode 100644 index 0000000..aa5c13a --- /dev/null +++ b/scripts/check_schema.py @@ -0,0 +1,21 @@ +import duckdb +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TAGS_PATH = os.path.join(BASE_DIR, "data", "tags.parquet") + +try: + conn = duckdb.connect(":memory:") + print(f"Inspecting {TAGS_PATH}") + if os.path.exists(TAGS_PATH): + print(conn.execute(f"DESCRIBE SELECT * FROM read_parquet('{TAGS_PATH.replace(os.sep, '/')}')").fetchall()) + else: + print("tags.parquet not found") + + USER_TAGS_PATH = os.path.join(BASE_DIR, "data", "custom", "user_tags.parquet") + print(f"Inspecting {USER_TAGS_PATH}") + if os.path.exists(USER_TAGS_PATH): + print(conn.execute(f"DESCRIBE SELECT * FROM read_parquet('{USER_TAGS_PATH.replace(os.sep, '/')}')").fetchall()) + +except Exception as e: + print(e) diff --git a/scripts/create_sample_data.py b/scripts/create_sample_data.py new file mode 100644 index 0000000..20e078c --- /dev/null +++ b/scripts/create_sample_data.py @@ -0,0 +1,74 @@ +import duckdb +import os + +# Define paths +base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +data_dir = os.path.join(base_dir, "data") +custom_dir = os.path.join(data_dir, "custom") + +os.makedirs(custom_dir, exist_ok=True) + +# Connect to in-memory db +con = duckdb.connect() + +# Create scheme for user tags +# name: VARCHAR, category: BIGINT, post_count: BIGINT, alias: VARCHAR[] + +# 1. Create user_tags.parquet +# Contains a custom tag "my_custom_tag" +con.execute(""" + CREATE TABLE user_tags ( + name VARCHAR, + category BIGINT, + post_count BIGINT, + alias VARCHAR[] + ) +""") +con.execute(""" + INSERT INTO user_tags VALUES + ('my_custom_tag', 0, 100, ['custom_alias']), + ('1girl', 4, 9999999, ['override_girl']) +""") +user_tags_path = os.path.join(custom_dir, "user_tags.parquet") +con.execute(f"COPY user_tags TO '{user_tags_path}' (FORMAT PARQUET)") +print(f"Created {user_tags_path}") + +# 2. Create liked_tags.parquet +# Contains "masterpiece" as a liked tag (will override others if priority logic works) +con.execute(""" + CREATE TABLE liked_tags ( + name VARCHAR, + category BIGINT, + post_count BIGINT, + alias VARCHAR[] + ) +""") +con.execute(""" + INSERT INTO liked_tags VALUES + ('masterpiece', 5, 5000000, ['best quality']), + ('simple background', 0, 200000, []) +""") +liked_tags_path = os.path.join(custom_dir, "liked_tags.parquet") +con.execute(f"COPY liked_tags TO '{liked_tags_path}' (FORMAT PARQUET)") +print(f"Created {liked_tags_path}") + +# 3. Create default_tags.parquet (optional but good for testing priority) +# Contains "1girl" with lower count than user_tags to test override if user_tags < default_tags (but plan says User > Default) +con.execute(""" + CREATE TABLE default_tags ( + name VARCHAR, + category BIGINT, + post_count BIGINT, + alias VARCHAR[] + ) +""") +con.execute(""" + INSERT INTO default_tags VALUES + ('1girl', 4, 500, ['girl']), + ('solo', 0, 300, []) +""") +default_tags_path = os.path.join(data_dir, "default_tags.parquet") +con.execute(f"COPY default_tags TO '{default_tags_path}' (FORMAT PARQUET)") +print(f"Created {default_tags_path}") + +print("Done creating sample data.") diff --git a/scripts/reset_default_tags.py b/scripts/reset_default_tags.py new file mode 100644 index 0000000..8f24ce3 --- /dev/null +++ b/scripts/reset_default_tags.py @@ -0,0 +1,25 @@ +import duckdb +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_DIR = os.path.join(BASE_DIR, "data") +DEFAULT_TAGS_PATH = os.path.join(DATA_DIR, "default_tags.parquet") + +os.makedirs(DATA_DIR, exist_ok=True) + +print(f"Creating empty default_tags.parquet at {DEFAULT_TAGS_PATH}") + +try: + conn = duckdb.connect(":memory:") + + # Schema matching SimplePrompt.py TAGS_SCHEMA + # name VARCHAR, category BIGINT, post_count BIGINT, alias VARCHAR[] + conn.execute("CREATE TABLE tags (name VARCHAR, category BIGINT, post_count BIGINT, alias VARCHAR[])") + + # Write to parquet (OVERWRITE) + save_path = DEFAULT_TAGS_PATH.replace(os.sep, "/") + conn.execute(f"COPY tags TO '{save_path}' (FORMAT PARQUET)") + print("Successfully created empty default_tags.parquet") + +except Exception as e: + print(f"Error: {e}") diff --git a/scripts/test_backend.py b/scripts/test_backend.py new file mode 100644 index 0000000..599e10c --- /dev/null +++ b/scripts/test_backend.py @@ -0,0 +1,96 @@ +import os +import duckdb +import logging + +# Setup basic logging to see output +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("SimplePrompt") + +# Mocking the layout +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_DIR = os.path.join(BASE_DIR, "data") +CUSTOM_DATA_DIR = os.path.join(DATA_DIR, "custom") +TAGS_PARQUET_PATH = os.path.join(DATA_DIR, "tags.parquet") +DEFAULT_TAGS_PATH = os.path.join(DATA_DIR, "default_tags.parquet") +USER_TAGS_PATH = os.path.join(CUSTOM_DATA_DIR, "user_tags.parquet") +LIKED_TAGS_PATH = os.path.join(CUSTOM_DATA_DIR, "liked_tags.parquet") + + +def init_duckdb(): + try: + conn = duckdb.connect(":memory:") + + def get_sql_path(path): + if os.path.exists(path): + return path.replace("\\", "/") + return None + + sources = [] + + path = get_sql_path(LIKED_TAGS_PATH) + if path: + sources.append(f"SELECT name, category, post_count, alias, 1 as priority FROM read_parquet('{path}')") + + path = get_sql_path(USER_TAGS_PATH) + if path: + sources.append(f"SELECT name, category, post_count, alias, 2 as priority FROM read_parquet('{path}')") + + path = get_sql_path(DEFAULT_TAGS_PATH) + if path: + sources.append(f"SELECT name, category, post_count, alias, 3 as priority FROM read_parquet('{path}')") + + path = get_sql_path(TAGS_PARQUET_PATH) + if path: + sources.append(f"SELECT name, category, post_count, alias, 4 as priority FROM read_parquet('{path}')") + + if not sources: + print("No sources found!") + return None + + union_query = " UNION ALL ".join(sources) + print("Creating view...") + conn.execute(f"CREATE OR REPLACE VIEW all_tags_raw AS {union_query}") + + print("Deduplicating...") + conn.execute(""" + CREATE OR REPLACE VIEW tags AS + SELECT * EXCLUDE (rn) FROM ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY name ORDER BY priority ASC) as rn + FROM all_tags_raw + ) WHERE rn = 1 + """) + + print("DuckDB Initialized successfully.") + return conn + + except Exception as e: + print(f"Failed: {e}") + return None + + +def test_search(conn): + if not conn: + return + + print("\nTesting Search 'girl'...") + try: + # Simple search + res = conn.execute( + "SELECT name, priority, post_count FROM tags WHERE name ILIKE '%girl%' ORDER BY priority ASC, post_count DESC" + ).fetchall() + for r in res: + print(r) + + # Check custom tag + print("\nTesting Custom Tag 'my_custom_tag'...") + res = conn.execute("SELECT * FROM tags WHERE name = 'my_custom_tag'").fetchall() + print(res) + + except Exception as e: + print(f"Search failed: {e}") + + +conn = init_duckdb() +if conn: + test_search(conn) diff --git a/scripts/test_manager_logic.py b/scripts/test_manager_logic.py new file mode 100644 index 0000000..a6a64d8 --- /dev/null +++ b/scripts/test_manager_logic.py @@ -0,0 +1,59 @@ +import duckdb +import os +import shutil + +# Setup paths +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_DIR = os.path.join(BASE_DIR, "data") +CUSTOM_DATA_DIR = os.path.join(DATA_DIR, "custom") +TEST_USER_TAGS = os.path.join(CUSTOM_DATA_DIR, "test_user_tags.parquet") + +os.makedirs(CUSTOM_DATA_DIR, exist_ok=True) + +conn = duckdb.connect(":memory:") + + +def setup_data(): + # Create sample parquet + conn.execute("CREATE OR REPLACE TABLE tags (name VARCHAR, category BIGINT, post_count BIGINT, alias VARCHAR[])") + conn.execute("INSERT INTO tags VALUES ('tag1', 0, 10, []), ('tag2', 1, 20, ['alias2']), ('tag3', 0, 5, [])") + conn.execute(f"COPY tags TO '{TEST_USER_TAGS.replace(os.sep, '/')}' (FORMAT PARQUET)") + print("Test data created.") + + +def test_list(): + print("\n--- Testing LIST ---") + path_sql = TEST_USER_TAGS.replace(os.sep, "/") + res = conn.execute(f"SELECT * FROM read_parquet('{path_sql}') ORDER BY post_count DESC, name ASC").fetchall() + print("All tags:", res) + assert len(res) == 3 + assert res[0][0] == "tag2" # highest post_count + + +def test_delete(): + print("\n--- Testing DELETE 'tag2' ---") + path_sql = TEST_USER_TAGS.replace(os.sep, "/") + + conn.execute("DROP TABLE IF EXISTS temp_del_tags") + conn.execute(f"CREATE TABLE temp_del_tags AS SELECT * FROM read_parquet('{path_sql}')") + conn.execute("DELETE FROM temp_del_tags WHERE name = 'tag2'") + conn.execute(f"COPY temp_del_tags TO '{path_sql}' (FORMAT PARQUET)") + + # Verify + res = conn.execute(f"SELECT * FROM read_parquet('{path_sql}')").fetchall() + print("Remaining tags:", res) + assert len(res) == 2 + names = [r[0] for r in res] + assert "tag2" not in names + assert "tag1" in names + + +try: + setup_data() + test_list() + test_delete() + print("\nSUCCESS: All backend logic tests passed.") +finally: + # Cleanup + if os.path.exists(TEST_USER_TAGS): + os.remove(TEST_USER_TAGS) diff --git a/simple_prompt/__init__.py b/simple_prompt/__init__.py new file mode 100644 index 0000000..a332a24 --- /dev/null +++ b/simple_prompt/__init__.py @@ -0,0 +1,4 @@ +# simple_prompt package +# 核心业务逻辑包,与 ComfyUI 解耦 + +__version__ = "0.1.0" diff --git a/simple_prompt/api/__init__.py b/simple_prompt/api/__init__.py new file mode 100644 index 0000000..968c356 --- /dev/null +++ b/simple_prompt/api/__init__.py @@ -0,0 +1,2 @@ +# simple_prompt.api package +# API 处理器层(平台无关的抽象) diff --git a/simple_prompt/api/handlers.py b/simple_prompt/api/handlers.py new file mode 100644 index 0000000..562bdb3 --- /dev/null +++ b/simple_prompt/api/handlers.py @@ -0,0 +1,463 @@ +""" +API 处理器层 +提供平台无关的请求处理函数 +这些函数接收和返回纯 Python 数据结构,不依赖任何 Web 框架 +""" + +import hashlib +import logging +import os +from typing import Any, Dict, List, Optional + +from simple_prompt.core import config +from simple_prompt.core.database import get_db_connection, reinit_duckdb +from simple_prompt.core import tags as tags_module +from simple_prompt.core import categories as categories_module +from simple_prompt.core import presets as presets_module + +logger = logging.getLogger("SimplePrompt") + + +# -------------------------------------------------------------------------------- +# Tags Handlers +# -------------------------------------------------------------------------------- + + +async def handle_search_tags( + query: str, limit: int = 50, use_aliases: bool = False, categories: Optional[List[int]] = None +) -> List[Dict[str, Any]]: + """ + 处理标签搜索请求 + + Args: + query: 搜索关键词 + limit: 返回结果数量限制 + use_aliases: 是否搜索别名 + categories: 限制搜索的分类列表 + + Returns: + 匹配的标签列表 + + Raises: + RuntimeError: 数据库未初始化 + """ + conn = get_db_connection() + if not conn: + raise RuntimeError("DuckDB not initialized") + return tags_module.search_tags(conn, query, limit, use_aliases, categories) + + +async def handle_get_tags_details(names: List[str], fast: bool = False) -> Dict[str, int]: + """ + 处理获取标签详情请求 + + Args: + names: 标签名称列表 + fast: 是否使用快速模式 + + Returns: + 标签名到分类 ID 的映射 + + Raises: + RuntimeError: 数据库未初始化 + """ + conn = get_db_connection() + if not conn: + raise RuntimeError("DuckDB not initialized") + return tags_module.get_tags_details(conn, names, fast) + + +async def handle_list_tags(source: str, limit: int = 50, offset: int = 0, query: str = "") -> Dict[str, Any]: + """ + 处理列出标签请求 + + Args: + source: 数据来源 + limit: 返回数量限制 + offset: 偏移量 + query: 搜索关键词 + + Returns: + 包含 data 和 total 的字典 + + Raises: + RuntimeError: 数据库未初始化 + """ + conn = get_db_connection() + if not conn: + raise RuntimeError("DuckDB not initialized") + return tags_module.list_tags(conn, source, limit, offset, query) + + +async def handle_delete_tag(name: str, source: str) -> Dict[str, str]: + """ + 处理删除标签请求 + + Args: + name: 标签名称 + source: 数据来源 + + Returns: + 操作结果 + + Raises: + RuntimeError: 数据库未初始化 + ValueError: 参数无效 + """ + if not name or not source: + raise ValueError("Name and source are required") + + conn = get_db_connection() + if not conn: + raise RuntimeError("Database unavailable") + + path = tags_module.get_source_path_by_name(source) + if not path: + raise ValueError("Invalid source") + + success = tags_module.delete_tag(conn, name, source) + if success: + return {"status": "success", "message": f"Tag '{name}' deleted from {source}."} + else: + raise RuntimeError("Delete operation failed") + + +async def handle_add_custom_tag(data: Dict[str, Any]) -> Dict[str, str]: + """ + 处理添加自定义标签请求 + + Args: + data: 包含标签信息的字典,支持单个或批量 + + Returns: + 操作结果 + + Raises: + RuntimeError: 数据库未初始化 + ValueError: 参数无效 + """ + conn = get_db_connection() + if not conn: + raise RuntimeError("Database unavailable") + + # Detect Batch or Single + if "tags" in data and isinstance(data["tags"], list): + tags_to_process = data["tags"] + else: + tags_to_process = [data] + + if not tags_to_process: + raise ValueError("No tag data provided") + + # Get source from first item + source = tags_to_process[0].get("source", "user") + + count = tags_module.add_tags(conn, tags_to_process, source) + return {"status": "success", "message": f"{count} tag(s) added/updated."} + + +async def handle_toggle_like_tag(data: Dict[str, Any]) -> Dict[str, Any]: + """ + 处理切换点赞标签请求 + + Args: + data: 包含标签信息的字典 + + Returns: + 操作结果 + + Raises: + RuntimeError: 数据库未初始化 + ValueError: 参数无效 + """ + conn = get_db_connection() + if not conn: + raise RuntimeError("Database unavailable") + + name = data.get("name") + if not name: + raise ValueError("Name is required") + + should_like = data.get("is_liked", True) + category = data.get("category") + post_count = data.get("post_count") + alias = data.get("alias", []) + + success = tags_module.toggle_like_tag(conn, name, should_like, category, post_count, alias) + + if success: + msg = f"Tag '{name}' {'liked' if should_like else 'unliked'}." + return {"status": "success", "message": msg, "is_liked": should_like} + else: + raise RuntimeError("Toggle like operation failed") + + +# -------------------------------------------------------------------------------- +# Categories Handlers +# -------------------------------------------------------------------------------- + + +async def handle_list_categories() -> List[Dict[str, Any]]: + """ + 处理列出分类请求 + + Returns: + 分类列表 + """ + return categories_module.get_categories() + + +async def handle_save_categories(categories: List[Dict[str, Any]]) -> Dict[str, str]: + """ + 处理保存分类请求 + + Args: + categories: 分类列表 + + Returns: + 操作结果 + """ + categories_module.save_custom_categories(categories) + return {"status": "success"} + + +# -------------------------------------------------------------------------------- +# Presets Handlers +# -------------------------------------------------------------------------------- + + +async def handle_list_presets() -> Dict[str, List[Dict[str, Any]]]: + """ + 处理列出预设请求 + + Returns: + 包含 defaults 和 customs 的字典 + """ + return presets_module.get_presets() + + +async def handle_save_presets(presets: List[Dict[str, Any]]) -> Dict[str, str]: + """ + 处理保存预设请求 + + Args: + presets: 预设列表 + + Returns: + 操作结果 + """ + presets_module.save_custom_presets(presets) + return {"status": "success"} + + +# -------------------------------------------------------------------------------- +# Health & Update Handlers +# -------------------------------------------------------------------------------- + + +async def handle_health_check() -> Dict[str, Any]: + """ + 处理健康检查请求 + + Returns: + 系统状态信息 + """ + conn = get_db_connection() + return { + "status": "ok", + "duckdb": "loaded" if conn else "failed", + "sources": { + "main": os.path.exists(config.TAGS_PARQUET_PATH), + "user": os.path.exists(config.USER_TAGS_PATH), + "liked": os.path.exists(config.LIKED_TAGS_PATH), + }, + } + + +async def handle_check_update() -> Dict[str, Any]: + """ + 处理检查更新请求 + + Returns: + 更新信息 + """ + import aiohttp + + release_url = "https://api.github.com/repos/0nikod/danbooru_tag_process/releases/latest" + + local_sha256 = "" + if os.path.exists(config.TAGS_PARQUET_PATH): + try: + sha256_hash = hashlib.sha256() + with open(config.TAGS_PARQUET_PATH, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + local_sha256 = sha256_hash.hexdigest() + except Exception as e: + logger.error(f"Error calculating local SHA256: {e}") + + async with aiohttp.ClientSession() as session: + headers = {"User-Agent": "ComfyUI-Simple-Prompt"} + async with session.get(release_url, headers=headers) as resp: + if resp.status != 200: + raise RuntimeError(f"Failed to fetch release info: {resp.status}") + release_info = await resp.json() + + target_asset = None + asset_names = ["tags_processed.parquet", "tags.parquet"] + for name in asset_names: + for asset in release_info.get("assets", []): + if asset.get("name") == name: + target_asset = asset + break + if target_asset: + break + + if not target_asset: + raise RuntimeError("Tag data file not found") + + remote_digest = target_asset.get("digest", "") + if remote_digest.startswith("sha256:"): + remote_sha256 = remote_digest[7:] + else: + remote_sha256 = remote_digest + + update_available = local_sha256 != remote_sha256 + + return { + "update_available": update_available, + "local_sha256": local_sha256, + "remote_sha256": remote_sha256, + "version": release_info.get("tag_name", "unknown"), + "name": release_info.get("name", ""), + } + + +async def handle_update_tags(url: Optional[str] = None) -> Dict[str, str]: + """ + 处理更新标签数据请求 + + Args: + url: 可选的自定义下载 URL + + Returns: + 操作结果 + """ + import aiohttp + + release_url = url or "https://api.github.com/repos/0nikod/danbooru_tag_process/releases/latest" + + async with aiohttp.ClientSession() as session: + headers = {"User-Agent": "ComfyUI-Simple-Prompt"} + async with session.get(release_url, headers=headers) as resp: + if resp.status != 200: + raise RuntimeError(f"Failed to fetch release info: {resp.status}") + release_info = await resp.json() + + download_url = None + asset_names = ["tags_processed.parquet", "tags.parquet"] + for name in asset_names: + for asset in release_info.get("assets", []): + if asset.get("name") == name: + download_url = asset.get("browser_download_url") + break + if download_url: + break + + if not download_url: + raise RuntimeError("Tag data file not found") + + async with session.get(download_url) as resp: + if resp.status != 200: + raise RuntimeError(f"Failed to download: {resp.status}") + + content = await resp.read() + os.makedirs(config.DATA_DIR, exist_ok=True) + temp_path = config.TAGS_PARQUET_PATH + ".tmp" + with open(temp_path, "wb") as f: + f.write(content) + + if os.path.exists(config.TAGS_PARQUET_PATH): + os.remove(config.TAGS_PARQUET_PATH) + os.rename(temp_path, config.TAGS_PARQUET_PATH) + + reinit_duckdb() + return {"status": "success", "message": "Tags updated successfully"} + + +async def handle_update_data(action: str) -> Dict[str, str]: + """ + 处理批量更新数据请求 + + Args: + action: 操作类型 ("update_liked", "update_user") + + Returns: + 操作结果 + """ + conn = get_db_connection() + if not conn: + raise RuntimeError("Database unavailable") + + if action == "update_liked": + target_path = config.LIKED_TAGS_PATH + elif action == "update_user": + target_path = config.USER_TAGS_PATH + else: + raise ValueError("Unknown action") + + from simple_prompt.core.database import ensure_parquet_exists + + ensure_parquet_exists(target_path, config.TAGS_SCHEMA) + target_path_sql = target_path.replace("\\", "/") + + conn.execute("DROP TABLE IF EXISTS temp_target") + conn.execute(f"CREATE TABLE temp_target AS SELECT * FROM read_parquet('{target_path_sql}')") + + # Build source views + other_sources = [] + if action == "update_liked": + if os.path.exists(config.USER_TAGS_PATH): + other_sources.append( + f"SELECT name, category, post_count, alias, 2 as p FROM read_parquet('{config.USER_TAGS_PATH.replace(os.sep, '/')}')" + ) + if os.path.exists(config.DEFAULT_TAGS_PATH): + other_sources.append( + f"SELECT name, category, post_count, alias, 3 as p FROM read_parquet('{config.DEFAULT_TAGS_PATH.replace(os.sep, '/')}')" + ) + if os.path.exists(config.TAGS_PARQUET_PATH): + other_sources.append( + f"SELECT name, category, post_count, alias, 4 as p FROM read_parquet('{config.TAGS_PARQUET_PATH.replace(os.sep, '/')}')" + ) + + if other_sources: + union_q = " UNION ALL ".join(other_sources) + conn.execute(f"CREATE OR REPLACE VIEW others_raw AS {union_q}") + conn.execute(""" + CREATE OR REPLACE VIEW others_best AS + SELECT * EXCLUDE (rn) FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY name ORDER BY p ASC) as rn FROM others_raw + ) WHERE rn = 1 + """) + + conn.execute(""" + UPDATE temp_target + SET category = others_best.category, + post_count = others_best.post_count, + alias = others_best.alias + FROM others_best + WHERE temp_target.name = others_best.name + """) + + conn.execute("DROP VIEW others_best") + conn.execute("DROP VIEW others_raw") + + conn.execute(f"COPY temp_target TO '{target_path_sql}' (FORMAT PARQUET)") + conn.execute("DROP TABLE temp_target") + + reinit_duckdb() + + if action == "update_liked": + return {"status": "success", "message": "Liked tags data updated from main DB."} + else: + return {"status": "success", "message": "User tags data updated."} diff --git a/simple_prompt/comfyui/__init__.py b/simple_prompt/comfyui/__init__.py new file mode 100644 index 0000000..927fb82 --- /dev/null +++ b/simple_prompt/comfyui/__init__.py @@ -0,0 +1,2 @@ +# simple_prompt.comfyui package +# ComfyUI 特定适配层 diff --git a/simple_prompt/comfyui/nodes.py b/simple_prompt/comfyui/nodes.py new file mode 100644 index 0000000..d2a7bd3 --- /dev/null +++ b/simple_prompt/comfyui/nodes.py @@ -0,0 +1,67 @@ +""" +ComfyUI 节点定义 +参考: https://docs.comfy.org/custom-nodes/backend/server_overview +""" + + +class SimplePrompt: + """ + A simple prompt node with an advanced visual tag editor. + 提供一个带有高级可视化标签编辑器的简单提示词节点。 + """ + + DESCRIPTION = "A prompt builder node with an advanced visual editor for tag management, weight adjustment, and autocomplete." + + # 节点分类(在 ComfyUI 菜单中的位置) + CATEGORY = "SimplePrompt" + + # 执行函数名称 + FUNCTION = "run" + + # 返回类型 + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("prompt",) + + # 是否为输出节点(会在执行时被调用) + OUTPUT_NODE = False + + @classmethod + def INPUT_TYPES(cls): + """ + 定义节点输入 + """ + return { + "required": { + "prompt_text": ("STRING", {"multiline": True, "dynamicPrompts": True}), + }, + } + + def run(self, prompt_text: str) -> tuple[str]: + """ + 节点执行函数 + + Args: + prompt_text: 提示词文本 + + Returns: + 包含提示词的元组 + """ + return (prompt_text,) + + @classmethod + def IS_CHANGED(cls, prompt_text: str) -> float: + """ + 判断节点是否需要重新执行 + 返回 NaN 表示每次都执行 + """ + return float("NaN") + + +# -------------------------------------------------------------------------------- +# 节点注册(符合 ComfyUI Lifecycle 规范) +# https://docs.comfy.org/custom-nodes/backend/lifecycle +# -------------------------------------------------------------------------------- + +NODE_CLASS_MAPPINGS = {"SimplePrompt": SimplePrompt} + +NODE_DISPLAY_NAME_MAPPINGS = {"SimplePrompt": "Simple Prompt"} diff --git a/simple_prompt/comfyui/server.py b/simple_prompt/comfyui/server.py new file mode 100644 index 0000000..7637d03 --- /dev/null +++ b/simple_prompt/comfyui/server.py @@ -0,0 +1,307 @@ +""" +ComfyUI PromptServer 适配层 +所有 ComfyUI 特定的 API 路由注册都在这里 + +支持热重载:设置环境变量 SIMPLE_PROMPT_DEV=1 启用开发模式 +""" + +import logging +import os + +from aiohttp import web + +logger = logging.getLogger("SimplePrompt") + +# 检测开发模式 +DEV_MODE = os.environ.get("SIMPLE_PROMPT_DEV", "").lower() in ("1", "true", "yes") + +if DEV_MODE: + logger.info("[SimplePrompt] Development mode enabled - hot reload active") + + +def _maybe_reload(): + """ + 开发模式下重新加载核心模块 + 这使得修改代码后不需要重启 ComfyUI + """ + if not DEV_MODE: + return + + import importlib + + try: + # 重新加载核心模块 + from simple_prompt.core import config, utils, database, tags, categories, presets + from simple_prompt import core + from simple_prompt.api import handlers + from simple_prompt import api + + importlib.reload(config) + importlib.reload(utils) + importlib.reload(database) + importlib.reload(tags) + importlib.reload(categories) + importlib.reload(presets) + importlib.reload(core) + importlib.reload(handlers) + importlib.reload(api) + + logger.debug("[DEV] Core modules reloaded") + except Exception as e: + logger.error(f"[DEV] Failed to reload modules: {e}") + + +def _get_handlers(): + """ + 获取处理器模块(开发模式下会先重载) + """ + _maybe_reload() + from simple_prompt.api import handlers + + return handlers + + +# -------------------------------------------------------------------------------- +# 路由注册 +# -------------------------------------------------------------------------------- + +try: + from server import PromptServer + + # ------------------------- + # Tags API + # ------------------------- + + @PromptServer.instance.routes.get("/simple-prompt/search-tags") + async def search_tags_api(request): + """搜索标签""" + handlers = _get_handlers() + + query = request.query.get("q", "") + limit = int(request.query.get("limit", 50)) + use_aliases = request.query.get("use_aliases", "false").lower() == "true" + categories_str = request.query.get("categories", "") + + categories = [] + if categories_str: + try: + categories = [int(c) for c in categories_str.split(",") if c.strip()] + except ValueError: + pass + + try: + results = await handlers.handle_search_tags(query, limit, use_aliases, categories) + return web.json_response(results) + except Exception as e: + logger.error(f"Search API error: {e}") + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.post("/simple-prompt/get-tags-details") + async def get_tags_details_api(request): + """获取标签详情""" + handlers = _get_handlers() + + try: + data = await request.json() + names = data.get("names", []) + fast = data.get("fast", False) + + if not names: + return web.json_response({}) + + results = await handlers.handle_get_tags_details(names, fast) + return web.json_response(results) + except Exception as e: + logger.error(f"Get tags details API error: {e}") + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.get("/simple-prompt/tags/list") + async def tags_list_api(request): + """列出标签""" + handlers = _get_handlers() + + try: + source = request.query.get("source", "user") + limit = int(request.query.get("limit", 50)) + offset = int(request.query.get("offset", 0)) + query = request.query.get("q", "") + + results = await handlers.handle_list_tags(source, limit, offset, query) + return web.json_response(results) + except Exception as e: + logger.error(f"Tags list error: {e}") + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.delete("/simple-prompt/tags/delete") + async def tags_delete_api(request): + """删除标签""" + handlers = _get_handlers() + + try: + data = await request.json() + name = data.get("name") + source = data.get("source") + + result = await handlers.handle_delete_tag(name, source) + return web.json_response(result) + except ValueError as e: + return web.json_response({"error": str(e)}, status=400) + except Exception as e: + logger.error(f"Delete tag failed: {e}") + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.post("/simple-prompt/add-custom-tag") + async def add_custom_tag_api(request): + """添加自定义标签""" + handlers = _get_handlers() + + try: + data = await request.json() + result = await handlers.handle_add_custom_tag(data) + return web.json_response(result) + except ValueError as e: + return web.json_response({"error": str(e)}, status=400) + except Exception as e: + logger.error(f"Add custom tag failed: {e}") + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.post("/simple-prompt/toggle-like-tag") + async def toggle_like_tag_api(request): + """切换点赞标签""" + handlers = _get_handlers() + + try: + data = await request.json() + result = await handlers.handle_toggle_like_tag(data) + return web.json_response(result) + except ValueError as e: + return web.json_response({"error": str(e)}, status=400) + except Exception as e: + logger.error(f"Toggle like failed: {e}") + return web.json_response({"error": str(e)}, status=500) + + # ------------------------- + # Categories API + # ------------------------- + + @PromptServer.instance.routes.get("/simple-prompt/categories/list") + async def list_categories_api(request): + """列出分类""" + handlers = _get_handlers() + + try: + result = await handlers.handle_list_categories() + return web.json_response(result) + except Exception as e: + logger.error(f"List categories error: {e}") + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.post("/simple-prompt/categories/save") + async def save_categories_api(request): + """保存分类""" + handlers = _get_handlers() + + try: + data = await request.json() + categories = data.get("categories", []) + result = await handlers.handle_save_categories(categories) + return web.json_response(result) + except Exception as e: + logger.error(f"Save categories error: {e}") + return web.json_response({"error": str(e)}, status=500) + + # ------------------------- + # Presets API + # ------------------------- + + @PromptServer.instance.routes.get("/simple-prompt/presets/list") + async def list_presets_api(request): + """列出预设""" + handlers = _get_handlers() + + try: + result = await handlers.handle_list_presets() + return web.json_response(result) + except Exception as e: + logger.error(f"List presets error: {e}") + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.post("/simple-prompt/presets/save") + async def save_presets_api(request): + """保存预设""" + handlers = _get_handlers() + + try: + data = await request.json() + presets = data.get("presets", []) + result = await handlers.handle_save_presets(presets) + return web.json_response(result) + except Exception as e: + logger.error(f"Save presets error: {e}") + return web.json_response({"error": str(e)}, status=500) + + # ------------------------- + # Health & Update API + # ------------------------- + + @PromptServer.instance.routes.get("/simple-prompt/health") + async def health_check_api(request): + """健康检查""" + handlers = _get_handlers() + + try: + result = await handlers.handle_health_check() + return web.json_response(result) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.get("/simple-prompt/check-update") + async def check_update_api(request): + """检查更新""" + handlers = _get_handlers() + + try: + result = await handlers.handle_check_update() + return web.json_response(result) + except Exception as e: + logger.error(f"Check update error: {e}") + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.post("/simple-prompt/update-tags") + async def update_tags_api(request): + """更新标签数据""" + handlers = _get_handlers() + + try: + try: + data = await request.json() + except Exception: + data = {} + + url = data.get("url") + result = await handlers.handle_update_tags(url) + return web.json_response(result) + except Exception as e: + logger.error(f"Update tags error: {e}") + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.post("/simple-prompt/update-data") + async def update_data_api(request): + """批量更新数据""" + handlers = _get_handlers() + + try: + data = await request.json() + action = data.get("action") + result = await handlers.handle_update_data(action) + return web.json_response(result) + except ValueError as e: + return web.json_response({"error": str(e)}, status=400) + except Exception as e: + logger.error(f"Update data failed: {e}") + return web.json_response({"error": str(e)}, status=500) + + logger.info("[SimplePrompt] API routes registered successfully") + +except ImportError: + logger.warning("[SimplePrompt] PromptServer not available - running outside ComfyUI") diff --git a/simple_prompt/core/__init__.py b/simple_prompt/core/__init__.py new file mode 100644 index 0000000..faeb31e --- /dev/null +++ b/simple_prompt/core/__init__.py @@ -0,0 +1,30 @@ +# simple_prompt.core package +# 核心业务逻辑模块(与平台无关) + +from .config import ( + BASE_DIR, + DATA_DIR, + CUSTOM_DATA_DIR, + TAGS_PARQUET_PATH, + DEFAULT_TAGS_PATH, + USER_TAGS_PATH, + LIKED_TAGS_PATH, + DEFAULT_CATEGORY_PATH, + CUSTOM_CATEGORY_PATH, + DEFAULT_PRESETS_PATH, + CUSTOM_PRESETS_PATH, +) + +__all__ = [ + "BASE_DIR", + "DATA_DIR", + "CUSTOM_DATA_DIR", + "TAGS_PARQUET_PATH", + "DEFAULT_TAGS_PATH", + "USER_TAGS_PATH", + "LIKED_TAGS_PATH", + "DEFAULT_CATEGORY_PATH", + "CUSTOM_CATEGORY_PATH", + "DEFAULT_PRESETS_PATH", + "CUSTOM_PRESETS_PATH", +] diff --git a/simple_prompt/core/categories.py b/simple_prompt/core/categories.py new file mode 100644 index 0000000..9c9cdc6 --- /dev/null +++ b/simple_prompt/core/categories.py @@ -0,0 +1,106 @@ +""" +分类管理模块 +处理标签分类的加载和保存 +""" + +import logging +from typing import Any, Dict, List + +from . import config +from .utils import load_json, save_json + +logger = logging.getLogger("SimplePrompt") + +# 默认分类(硬编码备份) +DEFAULT_CATEGORIES = [ + {"id": 0, "name": "General", "color": "#0075db"}, + {"id": 1, "name": "Artist", "color": "#c00004"}, + {"id": 3, "name": "Copyright", "color": "#c000c0"}, + {"id": 4, "name": "Character", "color": "#00aa00"}, + {"id": 5, "name": "Meta", "color": "#fd9200"}, + {"id": 6, "name": "Special", "color": "#ffd700"}, # Gold + {"id": 7, "name": "Rating", "color": "#ff69b4"}, # HotPink +] + + +def get_categories() -> List[Dict[str, Any]]: + """ + 获取合并后的分类列表(默认 + 自定义) + + Returns: + 分类列表,按 ID 排序 + """ + defaults = load_json(config.DEFAULT_CATEGORY_PATH, []) + customs = load_json(config.CUSTOM_CATEGORY_PATH, []) + + # If default is empty (first run?), create it based on hardcoded defaults + if not defaults: + defaults = DEFAULT_CATEGORIES.copy() + try: + save_json(config.DEFAULT_CATEGORY_PATH, defaults) + except Exception: + pass + + # Merge: Custom overrides Default if same ID + cat_map: Dict[int, Dict[str, Any]] = {c["id"]: c for c in defaults} + for c in customs: + cat_map[c["id"]] = c + + # Return as list, sorted by ID + return sorted(cat_map.values(), key=lambda x: x["id"]) + + +def save_custom_categories(categories: List[Dict[str, Any]]) -> None: + """ + 保存自定义分类 + + Args: + categories: 分类列表 + + Raises: + Exception: 保存失败时抛出异常 + """ + save_json(config.CUSTOM_CATEGORY_PATH, categories) + + +def get_category_by_id(category_id: int) -> Dict[str, Any]: + """ + 根据 ID 获取分类信息 + + Args: + category_id: 分类 ID + + Returns: + 分类信息字典,如果不存在则返回默认值 + """ + categories = get_categories() + for cat in categories: + if cat["id"] == category_id: + return cat + return {"id": category_id, "name": "Unknown", "color": "#888888"} + + +def get_category_color(category_id: int) -> str: + """ + 获取分类颜色 + + Args: + category_id: 分类 ID + + Returns: + 颜色值(十六进制) + """ + return get_category_by_id(category_id).get("color", "#888888") + + +def get_category_name(category_id: int) -> str: + """ + 获取分类名称 + + Args: + category_id: 分类 ID + + Returns: + 分类名称 + """ + return get_category_by_id(category_id).get("name", "Unknown") diff --git a/simple_prompt/core/config.py b/simple_prompt/core/config.py new file mode 100644 index 0000000..a2fa2ba --- /dev/null +++ b/simple_prompt/core/config.py @@ -0,0 +1,55 @@ +""" +路径配置模块 +集中管理所有数据文件路径 +""" + +import os + +# 获取项目根目录(ComfyUI-Simple-Prompt) +# 路径: simple_prompt/core/config.py -> simple_prompt/core -> simple_prompt -> ComfyUI-Simple-Prompt +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# 数据目录 +DATA_DIR = os.path.join(BASE_DIR, "data") +CUSTOM_DATA_DIR = os.path.join(DATA_DIR, "custom") + +# 确保自定义数据目录存在 +os.makedirs(CUSTOM_DATA_DIR, exist_ok=True) + +# -------------------------------------------------------------------------------- +# Primary Data Sources (按优先级排序) +# -------------------------------------------------------------------------------- + +# Priority 1: 用户点赞的标签 +LIKED_TAGS_PATH = os.path.join(CUSTOM_DATA_DIR, "liked_tags.parquet") + +# Priority 2: 用户自定义标签 +USER_TAGS_PATH = os.path.join(CUSTOM_DATA_DIR, "user_tags.parquet") + +# Priority 3: 默认标签 +DEFAULT_TAGS_PATH = os.path.join(DATA_DIR, "default_tags.parquet") + +# Priority 4: 主仓库标签(Danbooru) +TAGS_PARQUET_PATH = os.path.join(DATA_DIR, "tags.parquet") + +# -------------------------------------------------------------------------------- +# Category & Meta Data Paths +# -------------------------------------------------------------------------------- + +# 默认分类配置 +DEFAULT_CATEGORY_PATH = os.path.join(DATA_DIR, "default_category.json") + +# 用户自定义分类配置 +CUSTOM_CATEGORY_PATH = os.path.join(CUSTOM_DATA_DIR, "custom_category.json") + +# 默认预设配置 +DEFAULT_PRESETS_PATH = os.path.join(DATA_DIR, "default_presets.json") + +# 用户自定义预设配置 +CUSTOM_PRESETS_PATH = os.path.join(CUSTOM_DATA_DIR, "custom_presets.json") + +# -------------------------------------------------------------------------------- +# Tags Schema +# -------------------------------------------------------------------------------- + +TAGS_SCHEMA = "name VARCHAR, category BIGINT, post_count BIGINT, alias VARCHAR[]" diff --git a/simple_prompt/core/database.py b/simple_prompt/core/database.py new file mode 100644 index 0000000..fede46d --- /dev/null +++ b/simple_prompt/core/database.py @@ -0,0 +1,167 @@ +""" +DuckDB 数据库管理模块 +处理数据库连接和初始化 +""" + +import logging +import os +from typing import Optional, Any + +from . import config +from .utils import get_sql_path + +logger = logging.getLogger("SimplePrompt") + +# Lazy import duckdb +try: + import duckdb +except ImportError: + logger.error("Error: 'duckdb' package not found in this Python environment.") + logger.error("Please install it using: pip install duckdb") + duckdb = None + +# 全局数据库连接 +_db_conn: Optional[Any] = None + + +def get_db_connection() -> Optional[Any]: + """ + 获取 DuckDB 连接(单例模式) + + Returns: + DuckDB 连接对象,或 None(如果 duckdb 未安装) + """ + global _db_conn + if _db_conn: + return _db_conn + + if not duckdb: + return None + + try: + _db_conn = duckdb.connect(database=":memory:") + return _db_conn + except Exception as e: + logger.error(f"Could not create DuckDB connection: {e}") + return None + + +def reset_db_connection() -> None: + """ + 重置数据库连接(用于热重载或重新初始化) + """ + global _db_conn + if _db_conn: + try: + _db_conn.close() + except Exception: + pass + _db_conn = None + + +def init_duckdb() -> None: + """ + 初始化 DuckDB 数据库 + 创建统一视图来合并多个数据源 + """ + conn = get_db_connection() + if not conn: + return + + logger.info("Initializing SimplePrompt Database...") + + sources = [] + + # Priority 1: Liked Tags + path = get_sql_path(config.LIKED_TAGS_PATH) + if path: + sources.append(f"SELECT name, category, post_count, alias, 1 as priority FROM read_parquet('{path}')") + + # Priority 2: User Tags + path = get_sql_path(config.USER_TAGS_PATH) + if path: + sources.append(f"SELECT name, category, post_count, alias, 2 as priority FROM read_parquet('{path}')") + + # Priority 3: Default Tags + path = get_sql_path(config.DEFAULT_TAGS_PATH) + if path: + sources.append(f"SELECT name, category, post_count, alias, 3 as priority FROM read_parquet('{path}')") + + # Priority 4: Repo Tags (Main) + path = get_sql_path(config.TAGS_PARQUET_PATH) + if path: + sources.append(f"SELECT name, category, post_count, alias, 4 as priority FROM read_parquet('{path}')") + else: + logger.warning(f"Main tags.parquet not found at {config.TAGS_PARQUET_PATH}") + + if not sources: + logger.warning("No tag data sources found.") + return + + # Create Unified View + try: + union_query = " UNION ALL ".join(sources) + + # 1. Create Raw View containing all duplicates + conn.execute(f"CREATE OR REPLACE VIEW all_tags_raw AS {union_query}") + + # 2. Create Deduplicated View (High priority wins) + conn.execute(""" + CREATE OR REPLACE VIEW tags AS + SELECT * EXCLUDE (rn) FROM ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY name ORDER BY priority ASC) as rn + FROM all_tags_raw + ) WHERE rn = 1 + """) + + # 3. Create Fast View (Excluding Repo Tags - Priority 4) + conn.execute(""" + CREATE OR REPLACE VIEW tags_fast AS + SELECT * EXCLUDE (rn) FROM ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY name ORDER BY priority ASC) as rn + FROM all_tags_raw + WHERE priority < 4 + ) WHERE rn = 1 + """) + + logger.info("DuckDB initialized with multi-source views (including fast view).") + + except Exception as e: + logger.error(f"DuckDB initialization failed: {e}") + + +def reinit_duckdb() -> None: + """ + 重新初始化数据库(用于数据更新后刷新视图) + """ + init_duckdb() + + +def ensure_parquet_exists(path: str, schema_sql: str) -> None: + """ + 确保 Parquet 文件存在,如果不存在则创建空文件 + + Args: + path: Parquet 文件路径 + schema_sql: 表结构的 SQL 定义 + """ + if os.path.exists(path): + return + + conn = get_db_connection() + if not conn: + return + + try: + # Create an empty table with the correct schema and write to parquet + temp_name = f"temp_init_{os.path.basename(path).replace('.', '_')}" + conn.execute(f"CREATE TABLE {temp_name} ({schema_sql})") + # Write empty table + save_path = path.replace("\\", "/") + conn.execute(f"COPY {temp_name} TO '{save_path}' (FORMAT PARQUET)") + conn.execute(f"DROP TABLE {temp_name}") + logger.info(f"Created empty parquet file: {path}") + except Exception as e: + logger.error(f"Failed to create parquet {path}: {e}") diff --git a/simple_prompt/core/presets.py b/simple_prompt/core/presets.py new file mode 100644 index 0000000..433a323 --- /dev/null +++ b/simple_prompt/core/presets.py @@ -0,0 +1,74 @@ +""" +预设管理模块 +处理 Meta 预设的加载和保存 +""" + +import logging +import os +from typing import Any, Dict, List + +from . import config +from .utils import load_json, save_json + +logger = logging.getLogger("SimplePrompt") + + +def get_presets() -> Dict[str, List[Dict[str, Any]]]: + """ + 获取预设列表(默认 + 自定义) + + Returns: + 包含 defaults 和 customs 的字典 + """ + defaults = load_json(config.DEFAULT_PRESETS_PATH, []) + customs = load_json(config.CUSTOM_PRESETS_PATH, []) + + # Ensure default file exists + if not os.path.exists(config.DEFAULT_PRESETS_PATH): + defaults = [] + try: + save_json(config.DEFAULT_PRESETS_PATH, defaults) + except Exception: + pass + + return {"defaults": defaults, "customs": customs} + + +def save_custom_presets(presets: List[Dict[str, Any]]) -> None: + """ + 保存自定义预设 + + Args: + presets: 预设列表 + + Raises: + Exception: 保存失败时抛出异常 + """ + save_json(config.CUSTOM_PRESETS_PATH, presets) + + +def get_all_presets() -> List[Dict[str, Any]]: + """ + 获取所有预设(合并默认和自定义) + + Returns: + 预设列表 + """ + data = get_presets() + return data["defaults"] + data["customs"] + + +def get_preset_by_id(preset_id: str) -> Dict[str, Any] | None: + """ + 根据 ID 获取预设 + + Args: + preset_id: 预设 ID + + Returns: + 预设信息,如果不存在则返回 None + """ + for preset in get_all_presets(): + if preset.get("id") == preset_id: + return preset + return None diff --git a/simple_prompt/core/tags.py b/simple_prompt/core/tags.py new file mode 100644 index 0000000..3ce2758 --- /dev/null +++ b/simple_prompt/core/tags.py @@ -0,0 +1,400 @@ +""" +标签管理模块 +处理标签的搜索、查询、添加、删除等操作 +""" + +import logging +import os +from typing import Any, Dict, List, Optional + +from . import config +from .database import ensure_parquet_exists, get_db_connection, reinit_duckdb + +logger = logging.getLogger("SimplePrompt") + + +def search_tags( + conn: Any, query: str, limit: int = 100, use_aliases: bool = False, categories: Optional[List[int]] = None +) -> List[Dict[str, Any]]: + """ + 搜索标签 + + Args: + conn: DuckDB 连接 + query: 搜索关键词 + limit: 返回结果数量限制 + use_aliases: 是否同时搜索别名 + categories: 限制搜索的分类 ID 列表 + + Returns: + 匹配的标签列表 + """ + if not conn: + return [] + + params: List[Any] = [] + search_term = f"%{query}%" + params.append(search_term) + + alias_clause = "" + if use_aliases: + # Searching inside the list of aliases + alias_clause = "OR EXISTS (SELECT 1 FROM unnest(alias) AS a(alias_value) WHERE alias_value ILIKE ?)" + params.append(search_term) + + category_clause = "" + if categories: + placeholders = ",".join(["?"] * len(categories)) + category_clause = f"AND category IN ({placeholders})" + params.extend(categories) + + # Add limit to params + params.append(limit) + + sql = f""" + SELECT * FROM tags + WHERE (name ILIKE ? {alias_clause}) + {category_clause} + ORDER BY priority ASC, post_count DESC + LIMIT ? + """ + + try: + res = conn.execute(sql, params) + cols = [desc[0] for desc in res.description] + results = [] + for row in res.fetchall(): + row_dict = {} + for i, val in enumerate(row): + row_dict[cols[i]] = val + results.append(row_dict) + return results + except Exception as e: + logger.error(f"Search failed: {e}") + return [] + + +def get_tags_details(conn: Any, names: List[str], fast: bool = False) -> Dict[str, int]: + """ + 获取标签详情(主要是分类信息) + + 处理空格和下划线格式: + - 输入 "blue hair" 会匹配数据库中的 "blue_hair" + - 输入 "blue_hair" 也能正常工作 + + Args: + conn: DuckDB 连接 + names: 标签名称列表 + fast: 是否使用快速视图(排除主仓库标签) + + Returns: + 标签名到分类 ID 的映射字典 + """ + if not conn or not names: + return {} + + table_name = "tags_fast" if fast else "tags" + + # Cleanup names + clean_names = [n.strip() for n in names if n.strip()] + if not clean_names: + return {} + + # Create a mapping of normalized names (underscore) to original names + normalized_to_original: Dict[str, str] = {} + all_variants: set[str] = set() + + for name in clean_names: + # Pre-clean: Remove backslashes (e.g. "aaa \(bbb\)" -> "aaa (bbb)") + cleaned_input = name.replace("\\", "") + lower_name = cleaned_input.lower() + + # Add both space and underscore variants + space_variant = lower_name.replace("_", " ") + underscore_variant = lower_name.replace(" ", "_") + + all_variants.add(space_variant) + all_variants.add(underscore_variant) + + # Map both variants to the original name AND the cleaned name + # We need to map back to 'name' (original input) so the caller knows which input matched + normalized_to_original[space_variant] = name + normalized_to_original[underscore_variant] = name + + variant_list = list(all_variants) + placeholders = ",".join(["?"] * len(variant_list)) + + sql = f""" + SELECT name, category + FROM {table_name} + WHERE name COLLATE NOCASE IN ({placeholders}) + """ + + try: + res = conn.execute(sql, variant_list).fetchall() + + result_map: Dict[str, int] = {} + for row in res: + db_name = row[0].lower() + category = row[1] + + # Map back to all original input names that could match this + original_name = normalized_to_original.get(db_name) + if original_name: + result_map[original_name] = category + + # Also add the db name itself for direct lookups + result_map[db_name] = category + + # Add the other variant too (space <-> underscore) + space_variant = db_name.replace("_", " ") + underscore_variant = db_name.replace(" ", "_") + result_map[space_variant] = category + result_map[underscore_variant] = category + + return result_map + except Exception as e: + logger.error(f"Get tags details failed: {e}") + return {} + + +def get_source_path_by_name(source: str) -> Optional[str]: + """ + 根据来源名称获取对应的文件路径 + + Args: + source: 来源名称 ("user", "liked", "default") + + Returns: + 文件路径,如果来源无效则返回 None + """ + if source == "user": + return config.USER_TAGS_PATH + elif source == "liked": + return config.LIKED_TAGS_PATH + elif source == "default": + return config.DEFAULT_TAGS_PATH + return None + + +def list_tags(conn: Any, source: str, limit: int = 50, offset: int = 0, query: str = "") -> Dict[str, Any]: + """ + 列出指定来源的标签 + + Args: + conn: DuckDB 连接 + source: 数据来源 ("user", "liked", "default") + limit: 返回数量限制 + offset: 偏移量(分页) + query: 搜索关键词 + + Returns: + 包含 data 和 total 的字典 + """ + if not conn: + return {"data": [], "total": 0} + + path = get_source_path_by_name(source) + if not path or not os.path.exists(path): + return {"data": [], "total": 0} + + path_sql = path.replace("\\", "/") + + where_clause = "" + params: List[Any] = [] + if query: + where_clause = "WHERE name ILIKE ? OR EXISTS (SELECT 1 FROM unnest(alias) AS a(alias_value) WHERE alias_value ILIKE ?)" + params = [f"%{query}%", f"%{query}%"] + + try: + # Get Total Count + count_sql = f"SELECT COUNT(*) FROM read_parquet('{path_sql}') {where_clause}" + total = conn.execute(count_sql, params).fetchone()[0] + + # Get Data + sql = f""" + SELECT * FROM read_parquet('{path_sql}') + {where_clause} + ORDER BY post_count DESC, name ASC + LIMIT {limit} OFFSET {offset} + """ + + if params: + res = conn.execute(sql, params) + else: + res = conn.execute(sql) + + cols = [desc[0] for desc in res.description] + data = [dict(zip(cols, row)) for row in res.fetchall()] + + return {"data": data, "total": total} + except Exception as e: + logger.error(f"List tags failed: {e}") + return {"data": [], "total": 0} + + +def delete_tag(conn: Any, name: str, source: str) -> bool: + """ + 删除指定来源的标签 + + Args: + conn: DuckDB 连接 + name: 标签名称 + source: 数据来源 + + Returns: + 是否成功 + """ + if not conn: + return False + + path = get_source_path_by_name(source) + if not path: + return False + + ensure_parquet_exists(path, config.TAGS_SCHEMA) + path_sql = path.replace("\\", "/") + + try: + # Delete using temp table + conn.execute("DROP TABLE IF EXISTS temp_del_tags") + conn.execute(f"CREATE TABLE temp_del_tags AS SELECT * FROM read_parquet('{path_sql}')") + conn.execute("DELETE FROM temp_del_tags WHERE name = ?", [name]) + conn.execute(f"COPY temp_del_tags TO '{path_sql}' (FORMAT PARQUET)") + conn.execute("DROP TABLE temp_del_tags") + + reinit_duckdb() # Refresh view + return True + except Exception as e: + logger.error(f"Delete tag failed: {e}") + return False + + +def add_tags(conn: Any, tags_data: List[Dict[str, Any]], source: str = "user") -> int: + """ + 添加或更新标签 + + Args: + conn: DuckDB 连接 + tags_data: 标签数据列表,每项包含 name, category, post_count, alias + source: 目标数据来源 + + Returns: + 成功添加的标签数量 + """ + if not conn or not tags_data: + return 0 + + target_path = get_source_path_by_name(source) + if not target_path: + return 0 + + ensure_parquet_exists(target_path, config.TAGS_SCHEMA) + target_path_sql = target_path.replace("\\", "/") + + try: + # Create temp table from existing parquet + conn.execute("DROP TABLE IF EXISTS temp_edit_tags") + conn.execute(f"CREATE TABLE temp_edit_tags AS SELECT * FROM read_parquet('{target_path_sql}')") + + count_added = 0 + + for item in tags_data: + name = item.get("name") + if not name: + continue + + category = int(item.get("category", 0)) + post_count = int(item.get("post_count", 0)) + alias = item.get("alias", []) + + # Delete existing if any (overwrite logic) + conn.execute("DELETE FROM temp_edit_tags WHERE name = ?", [name]) + + # Insert new tag + conn.execute("INSERT INTO temp_edit_tags VALUES (?, ?, ?, ?)", [name, category, post_count, alias]) + count_added += 1 + + # Write back to parquet + conn.execute(f"COPY temp_edit_tags TO '{target_path_sql}' (FORMAT PARQUET)") + conn.execute("DROP TABLE temp_edit_tags") + + # Refresh Main View + reinit_duckdb() + + return count_added + except Exception as e: + logger.error(f"Add tags failed: {e}") + return 0 + + +def toggle_like_tag( + conn: Any, + name: str, + should_like: bool = True, + category: Optional[int] = None, + post_count: Optional[int] = None, + alias: Optional[List[str]] = None, +) -> bool: + """ + 切换标签的点赞状态 + + Args: + conn: DuckDB 连接 + name: 标签名称 + should_like: True 为点赞,False 为取消点赞 + category: 标签分类(可选,会自动查找) + post_count: 帖子数量(可选) + alias: 别名列表(可选) + + Returns: + 是否成功 + """ + if not conn or not name: + return False + + ensure_parquet_exists(config.LIKED_TAGS_PATH, config.TAGS_SCHEMA) + liked_path_sql = config.LIKED_TAGS_PATH.replace("\\", "/") + + try: + conn.execute("DROP TABLE IF EXISTS temp_liked_tags") + conn.execute(f"CREATE TABLE temp_liked_tags AS SELECT * FROM read_parquet('{liked_path_sql}')") + + # Always remove first to avoid duplicates + conn.execute("DELETE FROM temp_liked_tags WHERE name = ?", [name]) + + if should_like: + # If data is missing, try to find it in other sources + if category is None or post_count is None: + for source_path in [config.USER_TAGS_PATH, config.DEFAULT_TAGS_PATH, config.TAGS_PARQUET_PATH]: + if os.path.exists(source_path): + src_sql = source_path.replace("\\", "/") + res = conn.execute( + f"SELECT category, post_count, alias FROM read_parquet('{src_sql}') WHERE name = ?", [name] + ).fetchone() + if res: + category = res[0] + post_count = res[1] + alias = res[2] + break + + # Default values if still missing + if category is None: + category = 0 + if post_count is None: + post_count = 0 + if alias is None: + alias = [] + + conn.execute("INSERT INTO temp_liked_tags VALUES (?, ?, ?, ?)", [name, category, post_count, alias]) + + # Write back + conn.execute(f"COPY temp_liked_tags TO '{liked_path_sql}' (FORMAT PARQUET)") + conn.execute("DROP TABLE temp_liked_tags") + + # Refresh View + reinit_duckdb() + return True + except Exception as e: + logger.error(f"Toggle like failed: {e}") + return False diff --git a/simple_prompt/core/utils.py b/simple_prompt/core/utils.py new file mode 100644 index 0000000..f8fccbc --- /dev/null +++ b/simple_prompt/core/utils.py @@ -0,0 +1,68 @@ +""" +通用工具模块 +包含 JSON 读写等通用功能 +""" + +import json +import logging +import os +from typing import Any + +logger = logging.getLogger("SimplePrompt") + + +def load_json(path: str, default: Any = None) -> Any: + """ + 加载 JSON 文件 + + Args: + path: 文件路径 + default: 文件不存在或加载失败时返回的默认值 + + Returns: + 解析后的 JSON 数据,或默认值 + """ + if not os.path.exists(path): + return default + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to load JSON {path}: {e}") + return default + + +def save_json(path: str, data: Any) -> None: + """ + 保存数据到 JSON 文件 + + Args: + path: 文件路径 + data: 要保存的数据 + + Raises: + Exception: 保存失败时抛出异常 + """ + try: + # 确保目录存在 + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + except Exception as e: + logger.error(f"Failed to save JSON {path}: {e}") + raise e + + +def get_sql_path(path: str) -> str | None: + """ + 获取用于 SQL 查询的文件路径(将反斜杠转换为正斜杠) + + Args: + path: 文件路径 + + Returns: + 格式化后的路径,如果文件不存在则返回 None + """ + if os.path.exists(path): + return path.replace("\\", "/") + return None diff --git a/src/App.dev.vue b/src/App.dev.vue new file mode 100644 index 0000000..9a24328 --- /dev/null +++ b/src/App.dev.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..c8b13d1 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/components/AutocompleteList.vue b/src/components/AutocompleteList.vue new file mode 100644 index 0000000..4cbd55d --- /dev/null +++ b/src/components/AutocompleteList.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/src/components/CustomTagModal.vue b/src/components/CustomTagModal.vue new file mode 100644 index 0000000..8cef70c --- /dev/null +++ b/src/components/CustomTagModal.vue @@ -0,0 +1,433 @@ + + + + + diff --git a/src/components/ModalWrapper.vue b/src/components/ModalWrapper.vue new file mode 100644 index 0000000..661a1ab --- /dev/null +++ b/src/components/ModalWrapper.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/src/components/OtherFunctions.vue b/src/components/OtherFunctions.vue new file mode 100644 index 0000000..04c5328 --- /dev/null +++ b/src/components/OtherFunctions.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue new file mode 100644 index 0000000..8915eb3 --- /dev/null +++ b/src/components/SettingsModal.vue @@ -0,0 +1,1128 @@ + + + + + diff --git a/src/components/TagItem.vue b/src/components/TagItem.vue new file mode 100644 index 0000000..01afb66 --- /dev/null +++ b/src/components/TagItem.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/src/components/TagManager.vue b/src/components/TagManager.vue new file mode 100644 index 0000000..bd51a86 --- /dev/null +++ b/src/components/TagManager.vue @@ -0,0 +1,588 @@ + + + + + diff --git a/src/components/TagSearchModal.vue b/src/components/TagSearchModal.vue new file mode 100644 index 0000000..69f0855 --- /dev/null +++ b/src/components/TagSearchModal.vue @@ -0,0 +1,687 @@ + + + + + diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue new file mode 100644 index 0000000..026c137 --- /dev/null +++ b/src/components/TextEditor.vue @@ -0,0 +1,849 @@ + + + + + diff --git a/src/components/ToolBar.vue b/src/components/ToolBar.vue new file mode 100644 index 0000000..12d4696 --- /dev/null +++ b/src/components/ToolBar.vue @@ -0,0 +1,149 @@ + + + + + \ No newline at end of file diff --git a/src/components/VisualTagArea.vue b/src/components/VisualTagArea.vue new file mode 100644 index 0000000..cd5f317 --- /dev/null +++ b/src/components/VisualTagArea.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/src/dev.ts b/src/dev.ts new file mode 100644 index 0000000..a0127de --- /dev/null +++ b/src/dev.ts @@ -0,0 +1,92 @@ +/** + * Development Environment Mock + * + * This file mocks the ComfyUI environment (app, api, nodes) to allow + * standalone development of the editor modal. + */ + +import { createApp, h } from 'vue'; +import i18n from './i18n'; +import AppDev from './App.dev.vue'; +// import SimplePromptEditor from './components/SimplePromptEditor.vue'; + +console.log("[Dev] Initializing Standalone Development Environment"); + +// Mock Global Variables commonly found in ComfyUI +(window as any).app = { + ui: { + settings: { + getSettingValue: (key: string, def: any) => { + console.log(`[Dev] getSettingValue: ${key}`, def); + // Return mock settings from localStorage or default + return JSON.parse(localStorage.getItem(key) || JSON.stringify(def)); + }, + setSettingValue: (key: string, val: any) => { + console.log(`[Dev] setSettingValue: ${key}`, val); + localStorage.setItem(key, JSON.stringify(val)); + } + } + } +}; + +(window as any).api = { + apiURL: '/api' // Proxy to backend if needed +}; + +// Mock Node Object +const mockNode = { + id: 1, + type: 'SimplePrompt', + widgets: [ + { + name: 'prompt_text', + value: '1girl, smiling, (long hair:1.2), masterpiece', + callback: (v: string) => { console.log("[Dev] Widget Value Changed:", v); } + } + ], + addWidget: (type: string, name: string, value: any, callback: any) => { + console.log(`[Dev] addWidget: ${type}, ${name}`); + if (type === 'button') { + // Simulate button click to open editor immediately for debugging + const btn = document.createElement('button'); + btn.innerText = name; + btn.style.position = 'fixed'; + btn.style.top = '20px'; + btn.style.left = '20px'; + btn.style.padding = '10px 20px'; + btn.style.zIndex = '9999'; + btn.onclick = callback; + document.body.appendChild(btn); + + // Auto-click for faster dev loop + // callback(); + } + } +}; + +// Simulate Extension Initialization +async function initDev() { + // Temporarily just show a placeholder + const appContainer = document.getElementById('app'); + if (appContainer) { + // appContainer.innerHTML = '

Check console. Click "Edit Prompt" button (top-left) to simulate.

'; + const app = createApp(AppDev); + app.use(i18n); + app.mount('#app'); + } + + // Import main logic to trigger registration (but we need to mock app.registerExtension) + const mockApp = { + registerExtension: (ext: any) => { + console.log(`[Dev] Registered Extension: ${ext.name}`); + // Simulate node creation + ext.nodeCreated(mockNode, mockApp); + } + }; + + // We need to dynamically import main.ts or extract the logic + // For now, let's just expose the mockNode globally so we can test components directly + (window as any).mockNode = mockNode; +} + +initDev(); diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..eabde82 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,42 @@ +import { createI18n } from 'vue-i18n'; +import en from '../locales/en/main.json'; +import zh from '../locales/zh/main.json'; + +// Get initial locale +const getInitialLocale = () => { + // 1. Check localStorage (our app setting) + const stored = localStorage.getItem('simplePrompt.settings'); + if (stored) { + try { + const { language } = JSON.parse(stored); + if (language) return language; + } catch (e) { } + } + + // 2. Check ComfyUI Setting + if ((window as any).app?.ui?.settings) { + const comfyLang = (window as any).app.ui.settings.getSettingValue("Comfy.lang", ""); + if (comfyLang) { + if (comfyLang.startsWith('zh')) return 'zh-CN'; + return 'en'; + } + } + + // 3. Browser language + const browserLang = navigator.language; + if (browserLang.startsWith('zh')) return 'zh-CN'; + + return 'en'; +}; + +const i18n = createI18n({ + legacy: false, + locale: getInitialLocale(), + fallbackLocale: 'en', + messages: { + en: en, + 'zh-CN': zh + } +}); + +export default i18n; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1c8a48e --- /dev/null +++ b/src/main.ts @@ -0,0 +1,97 @@ +import { app } from "../../../scripts/app.js"; +import { api } from "../../../scripts/api.js"; + +/** + * SimplePrompt Frontend Extension + * + * This extension injects the "Edit Prompt" button into the SimplePrompt node. + * It is designed to be lazily loaded and extensible. + */ +const ext = { + name: "SimplePrompt.Editor", + + async init(app) { + console.log("[SimplePrompt] Extension initialized"); + + // Load CSS + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.type = "text/css"; + // Convert to absolute URL based on where the script is running + // import.meta.url points to js/main.js + // css is in js/assets/main.css + // relative path: ./assets/main.css + link.href = new URL("./assets/main.css", import.meta.url).href; + document.head.appendChild(link); + }, + + async nodeCreated(node, app) { + // Only target the SimplePrompt node + if (node.comfyClass === "SimplePrompt") { + try { + this.setupSimplePromptNode(node, app); + } catch (error) { + console.error("[SimplePrompt] Error setting up node:", error); + } + } + }, + + setupSimplePromptNode(node, app) { + // Add "Edit Prompt" button + // We use a custom widget name to identify it easily later + const editWidget = node.addWidget("button", "Edit Prompt", null, () => { + this.openEditorModal(node); + }); + + // Ensure prompt_text widget exists and is accessible + const promptWidget = node.widgets.find(w => w.name === "prompt_text"); + if (!promptWidget) { + console.warn("[SimplePrompt] prompt_text widget not found!"); + } + }, + + openEditorModal(node) { + console.log("[SimplePrompt] Opening editor for node:", node.id); + + // Get current prompt text + const promptWidget = node.widgets.find(w => w.name === "prompt_text"); + const currentPrompt = promptWidget ? promptWidget.value : ""; + + // Create container + const container = document.createElement("div"); + document.body.appendChild(container); + + // Import createApp directly (bundled) + import("vue").then(({ createApp }) => { + // Import App component + import("./App.vue").then((appModule) => { + const App = appModule.default; + + const appInstance = createApp(App, { + initialPrompt: currentPrompt, + onSave: (newPrompt) => { + if (promptWidget) { + promptWidget.value = newPrompt; + } + // Clean up + appInstance.unmount(); + document.body.removeChild(container); + }, + onClose: () => { + appInstance.unmount(); + document.body.removeChild(container); + } + }); + + // Use i18n if needed, or other plugins + import("./i18n").then((i18nModule) => { + const i18n = i18nModule.default; + appInstance.use(i18n); + appInstance.mount(container); + }); + }); + }); + } +}; + +app.registerExtension(ext); diff --git a/src/utils/caret.ts b/src/utils/caret.ts new file mode 100644 index 0000000..386957d --- /dev/null +++ b/src/utils/caret.ts @@ -0,0 +1,82 @@ +/** + * Utility to get caret coordinates in a textarea + * Based on basic mirror div technique + */ +export function getCaretCoordinates(element: HTMLTextAreaElement, position: number) { + const properties = [ + 'direction', + 'boxSizing', + 'width', + 'height', + 'overflowX', + 'overflowY', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'fontStyle', + 'fontVariant', + 'fontWeight', + 'fontStretch', + 'fontSize', + 'fontSizeAdjust', + 'lineHeight', + 'fontFamily', + 'textAlign', + 'textTransform', + 'textIndent', + 'textDecoration', + 'letterSpacing', + 'wordSpacing', + 'tabSize', + 'MozTabSize', + ]; + + const div = document.createElement('div'); + div.id = 'input-textarea-caret-position-mirror-div'; + document.body.appendChild(div); + + const style = div.style; + const computed = window.getComputedStyle(element); + + style.whiteSpace = 'pre-wrap'; + if (element.nodeName !== 'INPUT') + style.wordWrap = 'break-word'; + + style.position = 'absolute'; + style.visibility = 'hidden'; + + properties.forEach((prop: any) => { + style[prop] = computed[prop]; + }); + + if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) { + if (element.scrollHeight > parseInt(computed.height)) + style.overflowY = 'scroll'; + } else { + style.overflow = 'hidden'; + } + + div.textContent = element.value.substring(0, position); + + if (element.nodeName === 'INPUT') + div.textContent = div.textContent.replace(/\s/g, '\u00a0'); + + const span = document.createElement('span'); + span.textContent = element.value.substring(position) || '.'; + div.appendChild(span); + + const coordinates = { + top: span.offsetTop + parseInt(computed['borderTopWidth']), + left: span.offsetLeft + parseInt(computed['borderLeftWidth']), + height: parseInt(computed['lineHeight']) + }; + + document.body.removeChild(div); + + return coordinates; +} diff --git a/src/utils/categoryService.ts b/src/utils/categoryService.ts new file mode 100644 index 0000000..e818238 --- /dev/null +++ b/src/utils/categoryService.ts @@ -0,0 +1,154 @@ +import { TagCategory, CATEGORY_COLORS } from './types'; +import { reactive } from 'vue'; + +export interface CategoryData { + value: number; + label: string; + color: string; +} + +/** + * Category Item used by UI components + */ +export interface CategoryItem { + id: number; + name: string; + color: string; +} + +export class CategoryService { + private static instance: CategoryService; + private _categories = reactive(new Map()); + private isInitialized = false; + + private constructor() { + // Initialize with defaults + this.addDefaultCategories(); + } + + public static getInstance(): CategoryService { + if (!CategoryService.instance) { + CategoryService.instance = new CategoryService(); + } + return CategoryService.instance; + } + + private addDefaultCategories() { + // Add standard Danbooru categories as baseline + this._categories.set(TagCategory.GENERAL, { value: TagCategory.GENERAL, label: 'General', color: CATEGORY_COLORS[TagCategory.GENERAL] }); + this._categories.set(TagCategory.ARTIST, { value: TagCategory.ARTIST, label: 'Artist', color: CATEGORY_COLORS[TagCategory.ARTIST] }); + this._categories.set(TagCategory.COPYRIGHT, { value: TagCategory.COPYRIGHT, label: 'Copyright', color: CATEGORY_COLORS[TagCategory.COPYRIGHT] }); + this._categories.set(TagCategory.CHARACTER, { value: TagCategory.CHARACTER, label: 'Character', color: CATEGORY_COLORS[TagCategory.CHARACTER] }); + this._categories.set(TagCategory.META, { value: TagCategory.META, label: 'Meta', color: CATEGORY_COLORS[TagCategory.META] }); + } + + public async init(): Promise { + if (this.isInitialized) return; + await this.fetchCategories(); + } + + public async fetchCategories(): Promise { + try { + const response = await fetch('/simple-prompt/categories/list'); + if (response.ok) { + const data = await response.json(); + + if (Array.isArray(data)) { + // Clear custom ones if any, but keep defaults if fetch fails next time? + // Better to just update the map. + data.forEach((cat: any) => { + this._categories.set(cat.id, { + value: cat.id, + label: cat.name, + color: cat.color + }); + }); + } + } + } catch (error) { + console.error("[CategoryService] Failed to fetch categories:", error); + } finally { + this.isInitialized = true; + } + } + + public async saveCustomCategories(categories: CategoryItem[]): Promise { + try { + const response = await fetch('/simple-prompt/categories/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ categories }) + }); + + if (response.ok) { + // Refresh local state after save + await this.fetchCategories(); + } else { + console.error("[CategoryService] Failed to save categories:", await response.text()); + throw new Error("Failed to save categories"); + } + } catch (error) { + console.error("[CategoryService] Error saving categories:", error); + throw error; + } + } + + public getCategory(id: number): CategoryData { + return this._categories.get(id) || { + value: id, + label: 'Unknown', + color: '#888888' + }; + } + + public getColor(id: number): string { + return this.getCategory(id).color; + } + + public getName(id: number): string { + return this.getCategory(id).label; + } + + public getAll(): CategoryData[] { + return Array.from(this._categories.values()).sort((a, b) => a.value - b.value); + } + + // Getter for template usage - returns array format expected by CustomTagModal + public get categoriesArray(): CategoryItem[] { + return this.getAll().map(cat => ({ + id: cat.value, + name: cat.label, + color: cat.color + })); + } + + public getCategoryColor(id: number): string { + return this.getColor(id); + } +} + +// Legacy compatibility export for components using `categoryService` instance +export const categoryService = { + get categories() { + return { + get value() { + return CategoryService.getInstance().categoriesArray; + } + }; + }, + getCategoryColor(id: number): string { + return CategoryService.getInstance().getColor(id); + }, + getCategoryName(id: number): string { + return CategoryService.getInstance().getName(id); + }, + async init() { + return CategoryService.getInstance().init(); + }, + async fetchCategories() { + return CategoryService.getInstance().fetchCategories(); + }, + async saveCustomCategories(categories: CategoryItem[]) { + return CategoryService.getInstance().saveCustomCategories(categories); + } +}; diff --git a/src/utils/duckdbService.ts b/src/utils/duckdbService.ts new file mode 100644 index 0000000..c1a6a21 --- /dev/null +++ b/src/utils/duckdbService.ts @@ -0,0 +1,93 @@ +export class DuckDBService { + private static instance: DuckDBService; + private isInitialized = false; + + private constructor() { } + + public static getInstance(): DuckDBService { + if (!DuckDBService.instance) { + DuckDBService.instance = new DuckDBService(); + } + return DuckDBService.instance; + } + + public async init(): Promise { + if (this.isInitialized) return; + console.log("[DuckDB] Backend initialization check (API-based)"); + // We could ping the backend here if needed + this.isInitialized = true; + } + + + + public async searchTags(query: string, limit: number = 20, useAliases: boolean = false, categories: number[] = []): Promise { + const params = new URLSearchParams({ + q: query, + limit: limit.toString(), + use_aliases: useAliases.toString(), + categories: categories.join(',') + }); + + try { + const response = await fetch(`/simple-prompt/search-tags?${params.toString()}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const results = await response.json(); + + // Handle the same alias matching logic as before for UI highlighting + if (useAliases && results.length > 0) { + return results.map((row: any) => { + const nameMatches = row.name && row.name.toLowerCase().includes(query.toLowerCase()); + + // If name matches, it's a direct match + if (nameMatches) { + return row; + } + + // Otherwise, check if an alias matched + const aliasArray = Array.isArray(row.alias) ? row.alias : []; + if (aliasArray.length > 0) { + const matchedAlias = aliasArray.find((a: string) => + a && a.toLowerCase().includes(query.toLowerCase()) + ); + + if (matchedAlias) { + return { ...row, matched_alias: matchedAlias }; + } + } + + return row; + }); + } + + return results; + } catch (error) { + console.error("[DuckDB] Search failed via API:", error); + return []; + } + } + + public async getTagsDetails(names: string[]): Promise> { + if (names.length === 0) return {}; + + try { + const response = await fetch('/simple-prompt/get-tags-details', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ names }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error("[DuckDB] Get tags details failed:", error); + return {}; + } + } +} + diff --git a/src/utils/metaService.ts b/src/utils/metaService.ts new file mode 100644 index 0000000..693a960 --- /dev/null +++ b/src/utils/metaService.ts @@ -0,0 +1,83 @@ +import { ref } from 'vue'; + +class MetaService { + private static instance: MetaService; + // Preset support + public presets = ref([]); // { id, name, tags } + public defaultPresets = ref([]); + public customPresets = ref([]); + public activePresetId = ref(''); + + public loading = ref(false); + + private constructor() { } + + public static getInstance(): MetaService { + if (!MetaService.instance) { + MetaService.instance = new MetaService(); + } + return MetaService.instance; + } + + + // --- Preset API --- + + public async fetchPresets() { + try { + const response = await fetch('/simple-prompt/presets/list'); + if (response.ok) { + const data = await response.json(); + this.defaultPresets.value = data.defaults || []; + this.customPresets.value = data.customs || []; + + // Merge + this.presets.value = [...this.defaultPresets.value, ...this.customPresets.value]; + + // Restore active preset logic or default to first if none + if (!this.activePresetId.value && this.presets.value.length > 0) { + // Check storage? + // For now default to first + this.activePresetId.value = this.presets.value[0].id; + } + } + } catch (e) { + console.error("Error fetching presets:", e); + } + } + + public async saveCustomPresets(customs: any[]) { + try { + const response = await fetch('/simple-prompt/presets/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ presets: customs }) + }); + if (!response.ok) throw new Error("Failed"); + await this.fetchPresets(); + } catch (e) { + console.error("Error saving presets:", e); + throw e; + } + } + + public getActiveTags(): string[] { + // If we have an active preset, use it + if (this.activePresetId.value) { + const preset = this.presets.value.find(p => p.id === this.activePresetId.value); + if (preset && preset.tags) return preset.tags; + } + // Fallback to old behavior (all meta tags)? + // Or if no preset is active, use the 'metaTags' list? + // User wants "Auto Meta" to use "Selected Meta Group". + // Let's assume if Presets exist, we use them. + // If user wants "All", they can make an "All" preset? + // Or fallback to flat list if activePresetId is empty? + return []; + } + + public setActivePreset(id: string) { + this.activePresetId.value = id; + } +} + +export const metaService = MetaService.getInstance(); diff --git a/src/utils/promptParser.ts b/src/utils/promptParser.ts new file mode 100644 index 0000000..7f39446 --- /dev/null +++ b/src/utils/promptParser.ts @@ -0,0 +1,513 @@ +import type { TagItem } from './types'; +import { TagCategory } from './types'; + +/** + * Parse prompt text into TagItem array + * Matches (text:weight) format or plain comma-separated text + * Properly handles escaped parentheses \( and \) + */ +export function textToTags(text: string): TagItem[] { + if (!text.trim()) return []; + + const tags: TagItem[] = []; + let idx = 0; + let i = 0; + let currentTag = ''; + let inWeight = false; + let weightText = ''; + let tagText = ''; + + while (i < text.length) { + const char = text[i]; + + // Handle escape sequences + // matches \ followed by anything. + // We usually only care about \( and \) but let's be safe. + // If we see \, check next char. + if (char === '\\' && i + 1 < text.length) { + const nextChar = text[i + 1]; + if (nextChar === '(' || nextChar === ')') { + // It is an escaped parenthesis. + // We want to treat it as part of the tag name (literal). + // "star \(sky\)" -> tag text: "star (sky)" + currentTag += nextChar; + i += 2; + continue; + } else { + // Backslash followed by other char (e.g. \n or just \a) + // Treat backslash as literal? Or ignore? + // Mostly safe to treat as literal if not special. + // But for safety let's just add the char and skip backslash? + // Or add both? "C:\\path" -> "C:\path" + // Let's stick to strict behavior: Only escape parens. + // If not parens, treat \ as literal char. + currentTag += char; + i++; + continue; + } + } + + // Handle weighted tags: (text:weight) + if (char === '(' && !inWeight) { + // ... (rest is same) + // Start of weighted tag + // Save any accumulated plain text first + if (currentTag.trim()) { + tags.push({ + id: `tag-${idx++}`, + text: currentTag.trim(), + weight: 1.0, + enabled: true, + category: TagCategory.GENERAL + }); + currentTag = ''; + } + + // Find the closing parenthesis + let depth = 1; + let j = i + 1; + let escaped = false; + + while (j < text.length && depth > 0) { + if (text[j] === '\\' && j + 1 < text.length) { + escaped = true; + j += 2; + continue; + } + if (text[j] === '(') depth++; + if (text[j] === ')') depth--; + j++; + } + + if (depth === 0) { + // Found matching closing parenthesis + const content = text.substring(i + 1, j - 1); + const colonIndex = content.indexOf(':'); + + if (colonIndex > 0) { + // Has weight format + tagText = content.substring(0, colonIndex).trim(); + // Unescape parentheses in the tag text + tagText = tagText.replace(/\\([()])/g, '$1'); + + weightText = content.substring(colonIndex + 1).trim(); + const weight = parseFloat(weightText); + + if (!isNaN(weight) && tagText) { + tags.push({ + id: `tag-${idx++}`, + text: tagText, + weight: weight, + enabled: true, + category: TagCategory.GENERAL + }); + } + } else { + // No colon, treat whole thing as plain text with parens + currentTag += text.substring(i, j); + } + + i = j; + continue; + } + } + + // Handle comma or newline separator + if (char === ',' || char === '\n') { + if (currentTag.trim()) { + tags.push({ + id: `tag-${idx++}`, + text: currentTag.trim(), + weight: 1.0, + enabled: true, + category: TagCategory.GENERAL + }); + currentTag = ''; + } + i++; + continue; + } + + // Regular character + currentTag += char; + i++; + } + + // Don't forget the last tag + if (currentTag.trim()) { + tags.push({ + id: `tag-${idx++}`, + text: currentTag.trim(), + weight: 1.0, + enabled: true, + category: TagCategory.GENERAL + }); + } + + return tags; +} + +/** + * Convert TagItem array back to prompt text + */ +export function tagsToText(tags: TagItem[]): string { + const enabledTags = tags.filter(tag => tag.enabled); + if (enabledTags.length === 0) return ''; + + return enabledTags + .map(tag => { + // Escape parens in the text itself to avoid parser confusion, BUT avoid double escaping + const escapedText = tag.text + .replace(/(? (star \(sky\):1.2) + return `(${escapedText}:${tag.weight.toFixed(1)})`; + } + return escapedText; + }) + .join(', ') + ','; +} + +/** + * Get sort order for a category + */ +export function getSortOrder(cat: number): number { + // Row 0: Special(6) + // Row 1: Character(4), Copyright(2,3) + // Row 2: Artist(1) + // Row 3: General(0) + // Row 4: Meta(5), Rating(7) + const rowMap: Record = { + 6: 0, + 4: 1, 2: 1, 3: 1, + 1: 2, + 0: 3, + 5: 4, 7: 4 + }; + return rowMap[cat] !== undefined ? rowMap[cat] : 3; // Default to General +} + +/** + * Format tags into grouped multiline text + */ +export function formatGroupedTags(tagsList: TagItem[]): string { + if (tagsList.length === 0) return ''; + + // Group by row + const rows: Record = { 0: [], 1: [], 2: [], 3: [], 4: [] }; + tagsList.forEach(tag => { + const cat = tag.category !== undefined ? tag.category : 0; + const rowIndex = getSortOrder(cat); + rows[rowIndex].push(tag); + }); + + // Sort logic for Row 1 (Characters & Copyrights) + // Preference: Character(4) > Copyright(3) > Copyright(2) + // Using stable sort by category ID (descending) + rows[1].sort((a, b) => { + const catA = a.category !== undefined ? a.category : 0; + const catB = b.category !== undefined ? b.category : 0; + // Descending order: 4 (Character) > 3 (Copyright) > 2 (Copyright) + return catB - catA; + }); + + // Convert rows to text + const rowTexts = [0, 1, 2, 3, 4].map(idx => { + const rowTags = rows[idx]; + if (rowTags.length === 0) return ''; + return tagsToText(rowTags).replace(/,$/, ''); // Remove trailing comma from tagsToText result if we want (but usually we want comma between groups?) + // tagsToText adds a trailing comma. + // If we join by newline, we might want that trailing comma. + // Let's keep it. + }); + + // Build final output with specific blank line logic + // Users request: + // Row 0 + // Row 1 + // Row 2 + // (blank if General exists) + // Row 3 (General) + // (blank if Meta/Rating exists) + // Row 4 + + let result = ''; + // Rows 0, 1, 2 + const topPart = rowTexts.slice(0, 3).filter(t => t).join('\n'); + if (topPart) result += topPart; + + // Gap before General + if (result && rowTexts[3]) result += '\n\n'; + + if (rowTexts[3]) result += rowTexts[3]; + + // Gap after General + if (result && rowTexts[4]) result += '\n\n'; + + if (rowTexts[4]) result += rowTexts[4]; + + return result; +} + +/** + * Apply Auto Meta tags with minimal changes to the original text. + * + * Logic: + * 1. Find and remove existing occurrences of metaTags from the text + * 2. Find where ratingTags start (if any) + * 3. Insert meta block (in preset order) just before rating section (or at end) + * 4. Preserve all other text including newlines + * + * @param text Original prompt text + * @param metaTags List of meta tag names to ensure are present + * @param ratingTagNames List of known rating tag names (to identify where to insert) + * @returns Modified text + */ +export function applyAutoMeta( + text: string, + metaTags: string[], + ratingTagNames: string[] +): string { + if (!metaTags.length) return text; + + // Normalize for matching (lowercase) + const metaLower = new Set(metaTags.map(t => t.toLowerCase())); + const ratingLower = new Set(ratingTagNames.map(t => t.toLowerCase())); + + // Parse to find tag positions and existing weights + // We need to track: { tagText, startIndex, endIndex, weight } + interface TagMatch { + text: string; + fullMatch: string; + start: number; + end: number; + weight: number; + } + + const foundTags: TagMatch[] = []; + + // Regex patterns: + // 1. Weighted tag: (text:weight) + // 2. Plain tag: text between commas/newlines + // We need to carefully find positions. + + // Split by delimiters but keep track of positions + let i = 0; + let currentStart = 0; + let currentTag = ''; + + const processTag = (tagStr: string, fullMatch: string, start: number, end: number) => { + const trimmed = tagStr.trim(); + if (!trimmed) return; + + // Check for weight syntax: (text:weight) + const weightMatch = trimmed.match(/^\((.+):(\d+\.?\d*)\)$/); + let tagText = trimmed; + let weight = 1.0; + + if (weightMatch) { + tagText = weightMatch[1].trim(); + weight = parseFloat(weightMatch[2]); + } + + // Unescape parentheses for matching + const unescaped = tagText.replace(/\\([()])/g, '$1'); + + foundTags.push({ + text: unescaped, + fullMatch, + start, + end, + weight + }); + }; + + while (i < text.length) { + const char = text[i]; + + // Handle weighted tag start + if (char === '(' && currentTag.trim() === '') { + // Find matching close + let depth = 1; + let j = i + 1; + while (j < text.length && depth > 0) { + if (text[j] === '\\' && j + 1 < text.length) { + j += 2; + continue; + } + if (text[j] === '(') depth++; + if (text[j] === ')') depth--; + j++; + } + + if (depth === 0) { + const fullMatch = text.substring(i, j); + const content = text.substring(i + 1, j - 1); + processTag(fullMatch, fullMatch, i, j); + i = j; + currentStart = j; + currentTag = ''; + continue; + } + } + + // Handle escape + if (char === '\\' && i + 1 < text.length) { + currentTag += char + text[i + 1]; + i += 2; + continue; + } + + // Handle delimiter + if (char === ',' || char === '\n') { + if (currentTag.trim()) { + processTag(currentTag.trim(), currentTag, currentStart, i); + } + i++; + currentStart = i; + currentTag = ''; + continue; + } + + currentTag += char; + i++; + } + + // Last tag + if (currentTag.trim()) { + processTag(currentTag.trim(), currentTag, currentStart, i); + } + + // Identify which tags are meta and which are rating + const metaToRemove: TagMatch[] = []; + const existingMetaWeights = new Map(); + let ratingStartIndex = text.length; + + for (const tag of foundTags) { + const lower = tag.text.toLowerCase(); + + if (metaLower.has(lower)) { + metaToRemove.push(tag); + existingMetaWeights.set(lower, tag.weight); + } + + if (ratingLower.has(lower)) { + if (tag.start < ratingStartIndex) { + ratingStartIndex = tag.start; + } + } + } + + // Build result by removing meta tags + // Sort removals by position descending to avoid index shifting + const removals = [...metaToRemove].sort((a, b) => b.start - a.start); + + let result = text; + for (const removal of removals) { + // Find the actual range to remove (including surrounding comma/space) + let removeStart = removal.start; + let removeEnd = removal.end; + + // Look backward for comma/space + while (removeStart > 0 && /[\s,]/.test(result[removeStart - 1])) { + removeStart--; + } + + // Look forward for comma/space (but not newlines - preserve structure) + while (removeEnd < result.length && result[removeEnd] === ' ') { + removeEnd++; + } + if (removeEnd < result.length && result[removeEnd] === ',') { + removeEnd++; + // Also consume trailing space after comma + while (removeEnd < result.length && result[removeEnd] === ' ') { + removeEnd++; + } + } + + // If we're removing from the start of a line, don't leave orphan comma + // If there's content before and after, we need a comma + const before = result.substring(0, removeStart); + const after = result.substring(removeEnd); + + result = before + after; + + // Update ratingStartIndex if needed + const removedLength = removeEnd - removeStart; + if (ratingStartIndex > removal.start) { + ratingStartIndex -= removedLength; + } + } + + // Clean up any double commas or trailing commas before newlines + result = result.replace(/,\s*,/g, ','); + result = result.replace(/,\s*\n/g, ',\n'); + result = result.replace(/\n\s*,/g, '\n'); + + // Build meta block string + const metaBlock = metaTags.map(m => { + const weight = existingMetaWeights.get(m.toLowerCase()) || 1.0; + // Escape parens + const escaped = m.replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + if (weight !== 1.0) { + return `(${escaped}:${weight.toFixed(1)})`; + } + return escaped; + }).join(', ') + ','; + + // Find insertion point (before rating section, or at end) + // We need to recalculate ratingStartIndex in the modified string + // Simpler approach: find first rating tag in modified text + + const modifiedTags = textToTags(result); + let insertIndex = result.length; + + for (let idx = 0; idx < modifiedTags.length; idx++) { + const lower = modifiedTags[idx].text.toLowerCase(); + if (ratingLower.has(lower)) { + // Find this tag's position in result + // This is complex... let's use a simpler approach + // Find the first occurrence of any rating tag + for (const ratingName of ratingTagNames) { + const escaped = ratingName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(^|[,\\n\\s])${escaped}([,\\n]|$)`, 'i'); + const match = result.match(regex); + if (match && match.index !== undefined) { + const pos = match.index + match[1].length; + if (pos < insertIndex) { + insertIndex = pos; + } + } + } + break; + } + } + + // Find proper insertion point (before rating, after last content) + // If there's content before insert point that doesn't end with newline, add newline + let prefix = result.substring(0, insertIndex).trimEnd(); + let suffix = result.substring(insertIndex); + + // Ensure proper separation + if (prefix && !prefix.endsWith(',') && !prefix.endsWith('\n')) { + prefix += ','; + } + + // Add newlines for separation if needed + if (prefix && !prefix.endsWith('\n\n')) { + if (prefix.endsWith('\n')) { + prefix += '\n'; + } else { + prefix += '\n\n'; + } + } + + if (suffix.trim() && !suffix.startsWith('\n')) { + suffix = '\n\n' + suffix.trimStart(); + } + + return prefix + metaBlock + suffix; +} diff --git a/src/utils/settings.ts b/src/utils/settings.ts new file mode 100644 index 0000000..96929f7 --- /dev/null +++ b/src/utils/settings.ts @@ -0,0 +1,60 @@ +import { reactive, watch } from 'vue'; + +export interface UserSettings { + convertUnderscoreToSpace: boolean; + escapeBrackets: boolean; + useAliasesInSearch: boolean; + useAliasesInAutocomplete: boolean; + smartBackspace: boolean; + language: 'en' | 'zh-CN'; + allowEditDefaultTags: boolean; + autoMetaEnabled: boolean; + tagMaxLength: number; +} + +const STORAGE_KEY = 'simplePrompt.settings'; + +const defaultSettings: UserSettings = { + convertUnderscoreToSpace: true, + escapeBrackets: false, + useAliasesInSearch: true, + useAliasesInAutocomplete: false, // Default false as per new spec + smartBackspace: false, // Default false - user must opt-in + language: 'en', // Default fallback + allowEditDefaultTags: false, + autoMetaEnabled: false, + tagMaxLength: 50, +}; + +export const settings = reactive({ ...defaultSettings }); + +// Load from localStorage +export function loadSettings() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + Object.assign(settings, { ...defaultSettings, ...parsed }); + } else { + // Detect language if not stored + const browserLang = navigator.language; + if (browserLang.startsWith('zh')) { + settings.language = 'zh-CN'; + } + } + } catch (e) { + console.error("Failed to load settings:", e); + } +} + +// Save to localStorage when changed +watch(settings, (newSettings) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings)); + } catch (e) { + console.error("Failed to save settings:", e); + } +}, { deep: true }); + +// Initialize immediately +loadSettings(); diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..a611f0a --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,42 @@ +/** + * Tag Category Enum (Danbooru standard) + */ +export enum TagCategory { + GENERAL = 0, + ARTIST = 1, + COPYRIGHT = 3, + CHARACTER = 4, + META = 5 +} + +/** + * Category Interface + */ +export interface Category { + value: number; + label: string; + color: string; +} + +/** + * Category Colors (Danbooru standard) + * @deprecated Use CategoryService instead + */ +export const CATEGORY_COLORS: Record = { + [TagCategory.GENERAL]: '#0075db', // Blue + [TagCategory.ARTIST]: '#c00004', // Red + [TagCategory.COPYRIGHT]: '#c000c0', // Purple + [TagCategory.CHARACTER]: '#00aa00', // Green + [TagCategory.META]: '#fd9200' // Orange +}; + +/** + * Tag Item Data Structure + */ +export interface TagItem { + id: string; // Unique ID + text: string; // Tag text, e.g. "1girl" + weight: number; // Weight, default 1.0 + enabled: boolean; // Is enabled + category?: number; // Changed from TagCategory to facilitate dynamic IDs +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..f8764b5 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,174 @@ +import unittest +import duckdb +import tempfile +import os +import json +from simple_prompt.core.tags import search_tags, get_tags_details +from simple_prompt.core.utils import load_json, save_json + + +class TestSearchTags(unittest.TestCase): + def setUp(self): + self.conn = duckdb.connect(":memory:") + self.conn.execute(""" + CREATE TABLE tags ( + id INTEGER, + name VARCHAR, + post_count INTEGER, + category INTEGER, + alias VARCHAR[], + priority INTEGER + ) + """) + self.conn.execute(""" + INSERT INTO tags VALUES + (1, 'cat', 100, 0, ['kitty'], 4), + (2, 'dog', 80, 0, ['puppy'], 4), + (3, '1girl', 1000, 4, [], 4) + """) + + def test_search_basic(self): + results = search_tags(self.conn, "cat") + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["name"], "cat") + + def test_search_alias(self): + results = search_tags(self.conn, "kitty", use_aliases=True) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["name"], "cat") + + def test_search_alias_disabled(self): + results = search_tags(self.conn, "kitty", use_aliases=False) + self.assertEqual(len(results), 0) + + def test_search_case_insensitive(self): + results = search_tags(self.conn, "CAT") + self.assertEqual(len(results), 1) + + def test_category_filter(self): + results = search_tags(self.conn, "cat", categories=[0]) + self.assertEqual(len(results), 1) + + results = search_tags(self.conn, "cat", categories=[1]) + self.assertEqual(len(results), 0) + + def test_search_injection_attempt(self): + # This string would cause issues if not parameterized: ' OR '1'='1 + results = search_tags(self.conn, "' OR '1'='1") + self.assertEqual(len(results), 0) + + +class TestGetTagsDetails(unittest.TestCase): + """Tests for get_tags_details function.""" + + def setUp(self): + self.conn = duckdb.connect(":memory:") + # Create tags table matching the expected schema + self.conn.execute(""" + CREATE TABLE tags ( + name VARCHAR, + category INTEGER, + post_count INTEGER, + alias VARCHAR[], + priority INTEGER + ) + """) + self.conn.execute(""" + INSERT INTO tags VALUES + ('blue_hair', 0, 500, [], 4), + ('red_eyes', 0, 300, [], 4), + ('hatsune_miku', 4, 10000, ['miku'], 4) + """) + # Create tags_fast view (same as tags for testing) + self.conn.execute("CREATE VIEW tags_fast AS SELECT * FROM tags") + + def test_basic_lookup(self): + """Test basic tag detail lookup.""" + result = get_tags_details(self.conn, ["blue_hair"]) + self.assertIn("blue_hair", result) + self.assertEqual(result["blue_hair"], 0) + + def test_space_to_underscore_conversion(self): + """Test that 'blue hair' matches 'blue_hair' in database.""" + result = get_tags_details(self.conn, ["blue hair"]) + # Should match and return category + self.assertIn("blue hair", result) + self.assertEqual(result["blue hair"], 0) + + def test_underscore_to_space_conversion(self): + """Test that both formats are returned in result.""" + result = get_tags_details(self.conn, ["blue_hair"]) + # Both variants should be in result + self.assertIn("blue_hair", result) + self.assertIn("blue hair", result) + + def test_empty_input(self): + """Test empty input returns empty dict.""" + result = get_tags_details(self.conn, []) + self.assertEqual(result, {}) + + def test_whitespace_only_input(self): + """Test whitespace-only names are filtered.""" + result = get_tags_details(self.conn, [" ", ""]) + self.assertEqual(result, {}) + + def test_multiple_tags(self): + """Test querying multiple tags at once.""" + result = get_tags_details(self.conn, ["blue_hair", "red_eyes", "hatsune_miku"]) + self.assertIn("blue_hair", result) + self.assertIn("red_eyes", result) + self.assertIn("hatsune_miku", result) + + def test_nonexistent_tag(self): + """Test that nonexistent tags are not in result.""" + result = get_tags_details(self.conn, ["nonexistent_tag_xyz"]) + self.assertNotIn("nonexistent_tag_xyz", result) + + def test_fast_mode(self): + """Test fast=True uses tags_fast view.""" + result = get_tags_details(self.conn, ["blue_hair"], fast=True) + self.assertIn("blue_hair", result) + + def test_none_connection(self): + """Test None connection returns empty dict.""" + result = get_tags_details(None, ["blue_hair"]) + self.assertEqual(result, {}) + + +class TestJsonHelpers(unittest.TestCase): + """Tests for load_json and save_json helper functions.""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + # Cleanup temp files + for f in os.listdir(self.temp_dir): + os.remove(os.path.join(self.temp_dir, f)) + os.rmdir(self.temp_dir) + + def test_save_and_load_json(self): + """Test saving and loading JSON data.""" + test_data = {"key": "value", "number": 42, "list": [1, 2, 3]} + path = os.path.join(self.temp_dir, "test.json") + + save_json(path, test_data) + loaded = load_json(path) + + self.assertEqual(loaded, test_data) + + def test_load_nonexistent_file(self): + """Test loading nonexistent file returns default.""" + path = os.path.join(self.temp_dir, "nonexistent.json") + result = load_json(path, default={"default": True}) + self.assertEqual(result, {"default": True}) + + def test_load_default_none(self): + """Test loading nonexistent file with None default.""" + path = os.path.join(self.temp_dir, "nonexistent.json") + result = load_json(path) + self.assertIsNone(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..44e24ae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + "paths": { + "@/*": [ + "./src/*" + ] + }, + "typeRoots": [ + "src/types", + "node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "dist", + "js" + ] +} \ No newline at end of file diff --git a/vite.config.mts b/vite.config.mts new file mode 100644 index 0000000..12d03b8 --- /dev/null +++ b/vite.config.mts @@ -0,0 +1,58 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +export default defineConfig({ + plugins: [ + vue(), + vueDevTools() + ], + define: { + 'process.env': { + NODE_ENV: JSON.stringify('production') + } + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, + build: { + lib: { + entry: './src/main.ts', + formats: ['es'], + fileName: 'main' + }, + rollupOptions: { + external: [ + '../../../scripts/app.js', + '../../../scripts/api.js', + '../../../scripts/domWidget.js', + '../../../scripts/utils.js', + /^primevue\/?.*/, + /^@primevue\/themes\/?.*/, + ], + output: { + dir: 'js', + assetFileNames: 'assets/[name].[ext]', + entryFileNames: 'main.js' + } + }, + outDir: 'js', + sourcemap: false, + assetsInlineLimit: 0, + cssCodeSplit: false + }, + server: { + fs: { + allow: ['..'] + }, + proxy: { + '/simple-prompt': { + target: 'http://127.0.0.1:8188', + changeOrigin: true, + } + } + } +}) \ No newline at end of file diff --git a/vite.debug.config.mts b/vite.debug.config.mts new file mode 100644 index 0000000..01b1f02 --- /dev/null +++ b/vite.debug.config.mts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + lib: { + entry: './src/main.ts', + formats: ['es'], + fileName: 'main' + }, + rollupOptions: { + external: [ + '../../../scripts/app.js', + '../../../scripts/api.js', + ], + output: { + dir: 'js', + } + }, + outDir: 'js', + emptyOutDir: false + } +})