Revert "Revert "feat: experimental natural-language categorization & search (persistent scope, clustering, retrieval)""

This reverts commit dd5663dd89.
pull/870/head
wuqinchuan 2025-12-28 23:20:29 +08:00 committed by zanllp
parent daa77b9f51
commit 101a30afb4
89 changed files with 2739 additions and 42 deletions

View File

@ -29,15 +29,15 @@ IIB_DB_FILE_BACKUP_MAX=8
# The default value is 'auto', which will be determined based on the command-line parameters used to start sd-webui (such as --server-name, --share, --listen).
IIB_ACCESS_CONTROL=auto
# This variable is used to define a list of allowed paths for the application to access when access control mode is enabled.
# This variable is used to define a list of allowed paths for the application to access when access control mode is enabled.
# It can be set to a comma-separated string of file paths or directory paths, representing the resources that are allowed to be accessed by the application.
# In addition, if sd_webui_config or sd_webui_dir has been configured, or if you're running this repository as an extension of sd-webui,
# In addition, if sd_webui_config or sd_webui_dir has been configured, or if you're running this repository as an extension of sd-webui,
# you can use the following shortcuts (txt2img, img2img, extra, save) as values for the ALLOWED_PATHS variable.
# IIB_ACCESS_CONTROL_ALLOWED_PATHS=save,extra,/output ...etc
# This variable is used to control fine-grained access control for different types of requests, but only if access control mode is enabled.
# It can be set to a string value that represents a specific permission or set of permissions, such as "read-only", "write-only", "read-write", or "no-access".
# This variable can be used to restrict access to certain API endpoints or data sources based on the permissions required by the user.
# This variable is used to control fine-grained access control for different types of requests, but only if access control mode is enabled.
# It can be set to a string value that represents a specific permission or set of permissions, such as "read-only", "write-only", "read-write", or "no-access".
# This variable can be used to restrict access to certain API endpoints or data sources based on the permissions required by the user.
# IIB_ACCESS_CONTROL_PERMISSION=read-write
@ -47,3 +47,41 @@ IIB_ACCESS_CONTROL=auto
# Due to the high performance cost of parsing this type of file, it is disabled by default.
# Set to 'true' to enable it.
IIB_ENABLE_SD_WEBUI_STEALTH_PARSER=false
# ---------------------------- AI / EMBEDDINGS ----------------------------
# OpenAI-compatible API base url.
# - Keep it generic here (do NOT write any provider-specific or personal info into this example file).
# - Typical format: https://your-openai-compatible-host/v1
# - This project uses it for BOTH embeddings (/embeddings) and topic-title chat (/chat/completions).
OPENAI_BASE_URL=
# OpenAI-compatible API key (Bearer token).
# - Put your real key in x.env (or .env) only, never commit it.
OPENAI_API_KEY=
# Embedding model id (OpenAI-compatible).
# - Used for clustering images by prompt semantics.
# - Recommended: start with a small/cheap embedding model; switch to a larger one only if clustering quality is insufficient.
EMBEDDING_MODEL=text-embedding-3-small
# Default chat model id (OpenAI-compatible).
# - Used by the generic /ai_chat endpoint (if you use it) and as a fallback default.
# - If your provider applies content filtering that sometimes returns empty output, try switching to another chat model here.
AI_MODEL=gpt-4o-mini
# Topic title model id (OpenAI-compatible).
# - Used ONLY for generating short cluster titles/keywords (topic naming).
# - If not set, it falls back to AI_MODEL.
# - Tip: if a model frequently violates "JSON only" or gets truncated, switch this model first.
TOPIC_TITLE_MODEL=gpt-4o-mini
# Prompt normalization before embedding/clustering (remove boilerplate but keep distinctiveness).
# - Removes common "quality / camera / resolution" boilerplate from prompts before embedding.
# - Keep enabled for better clustering; disable only for debugging.
IIB_PROMPT_NORMALIZE=1
# Prompt normalization mode:
# - 'balanced' (recommended): remove generic boilerplate but keep some discriminative style words.
# - 'theme_only' (aggressive): focus more on subject/theme nouns; may lose some stylistic distinctiveness.
IIB_PROMPT_NORMALIZE_MODE=balanced

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ plugins/*
!plugins/.gitkeep
test_data/*
.DS_Store
iib_output

View File

@ -159,4 +159,83 @@ https://user-images.githubusercontent.com/25872019/230768207-daab786b-d4ab-489f-
<img width="768" alt="image" src="https://user-images.githubusercontent.com/25872019/230064879-c95866ac-999d-4d4b-87ea-3e38c8479415.png">
## 自然语言分类&搜索(实验性)
这个功能用于把图片按**提示词语义相似度**自动分组(主题),并支持用一句自然语言做**语义检索**(类似 RAG 的召回阶段)。
它是实验性功能:效果强依赖模型与提示词质量,适合快速找回/整理生成图片。
### 使用方式(面向使用者)
1. 打开首页「**自然语言分类&搜索(实验性)**」
2. 点击「范围」选择要处理的文件夹(可多选,来源于 QuickMovePaths
3. **归类**:点「刷新」会在所选范围内生成主题列表(标题会按前端语言输出)
4. **搜索**输入一句话点「搜索」会自动打开结果页TopK 相似图片)
> 选择的范围会持久化到后端 KV`app_fe_setting["topic_search_scope"]`),下次打开会自动恢复并自动刷新一次结果。
### 接口(给高级用户/二次开发)
- **构建/刷新向量**`POST /infinite_image_browsing/db/build_iib_output_embeddings`
- 入参:`folder`, `model`, `force`, `batch_size`, `max_chars`
- **归类(聚类)**`POST /infinite_image_browsing/db/cluster_iib_output`
- 入参:`folder_paths`(必填,数组)、`threshold`, `min_cluster_size`, `force_embed`, `title_model`, `force_title`, `use_title_cache`, `assign_noise_threshold`, `lang`
- **语义检索RAG 召回)**`POST /infinite_image_browsing/db/search_iib_output_by_prompt`
- 入参:`query`, `folder_paths`(必填,数组)、`top_k`, `min_score`, `ensure_embed`, `model`, `max_chars`
### 原理(简单版)
- **1提示词抽取与清洗**
- 从 `image.exif` 中抽取提示词文本(只取 `Negative prompt:` 之前)
- 可选做“语义清洗”:去掉无意义的高频模板词(画质/摄影参数等),更聚焦主题语义(见 `IIB_PROMPT_NORMALIZE*`
- **2向量化Embedding**
- 调用 OpenAI 兼容的 `/embeddings` 得到向量
- 写入 SQLite 表 `image_embedding`(增量更新,避免重复花费)
- **3主题聚类**
- 用“簇向量求和方向”的增量聚类(近似在线聚类),再把高相似簇做一次合并(减少同主题被切碎)
- 可选把小簇成员重新分配到最相近的大簇,降低噪声
- **4主题命名LLM**
- 对每个簇取代表提示词样本,调用 `/chat/completions` 生成短标题与关键词
- 通过 tool/function calling 强制结构化输出JSON并写入 `topic_title_cache`
- **5语义检索**
- 把用户 query 向量化,然后和范围内所有图片向量做余弦相似度排序,返回 TopK
### 缓存与增量更新
#### 1向量缓存`image_embedding`
- **存储位置**SQLite 表 `image_embedding`(以 `image_id` 为主键)
- **增量跳过条件**:满足以下条件则跳过重新向量化:
- `model` 相同
- `text_hash` 相同
- 已存在 `vec`
- **“重新向量化”的缓存键**`text_hash = sha256(f"{normalize_version}:{prompt_text}")`
- `prompt_text`:用于 embedding 的最终文本(抽取 + 可选清洗)
- `normalize_version`:由代码对清洗规则/模式计算出的**指纹**(不允许用户用环境变量手动覆盖)
- **强制刷新**:在 `build_iib_output_embeddings``force=true`,或在 `cluster_iib_output``force_embed=true`
#### 2标题缓存`topic_title_cache`
- **存储位置**SQLite 表 `topic_title_cache`(主键 `cluster_hash`
- **命中条件**`use_title_cache=true` 且 `force_title=false` 时复用历史标题/关键词
- **缓存键 `cluster_hash` 包含**
- 成员图片 id排序后
- embedding `model`、`threshold`、`min_cluster_size`
- `title_model`、输出语言 `lang`
- 语义清洗指纹(`normalize_version`)与清洗模式
- **强制重新生成标题**`force_title=true`
### 配置(环境变量)
所有 AI 调用都基于 **OpenAI 兼容** 的服务:
- **`OPENAI_BASE_URL`**:例如 `https://your-host/v1`
- **`OPENAI_API_KEY`**:你的 Key
- **`EMBEDDING_MODEL`**:用于聚类的 embedding 模型
- **`AI_MODEL`**:默认 chat 模型(兜底默认)
- **`TOPIC_TITLE_MODEL`**:用于主题标题的 chat 模型(不配则回退到 `AI_MODEL`
- **`IIB_PROMPT_NORMALIZE`**`1/0` 是否开启提示词清洗
- **`IIB_PROMPT_NORMALIZE_MODE`**`balanced`(推荐)/ `theme_only`(更激进)
> 注意AI 调用**没有 mock 兜底**。只要服务端/模型返回异常或不符合约束,就会直接报错,避免产生“看似能跑但其实不可信”的结果。

View File

@ -176,3 +176,82 @@ https://user-images.githubusercontent.com/25872019/230768207-daab786b-d4ab-489f-
### Dark mode
<img width="768" alt="image" src="https://user-images.githubusercontent.com/25872019/230064879-c95866ac-999d-4d4b-87ea-3e38c8479415.png">
## Natural Language Categorization & Search (Experimental)
This feature groups images by **semantic similarity of prompts** and supports **natural-language retrieval** (similar to the retrieval stage in RAG).
Its experimental: results depend on the embedding/chat models and the quality of prompt metadata.
### How to Use (for end users)
1. Open **“Natural Language Categorization & Search (Experimental)”** from the startup page
2. Click **Scope** and select one or more folders (from QuickMovePaths)
3. **Categorize**: click **Refresh** to generate topic cards for the selected scope
4. **Search**: type a natural-language query and click **Search** (auto-opens the result grid)
> The selected scope is persisted in backend KV: `app_fe_setting["topic_search_scope"]`. Next time it will auto-restore and auto-refresh once.
### API Endpoints
- **Build/refresh embeddings**: `POST /infinite_image_browsing/db/build_iib_output_embeddings`
- Request: `folder`, `model`, `force`, `batch_size`, `max_chars`
- **Cluster (categorize)**: `POST /infinite_image_browsing/db/cluster_iib_output`
- Request: `folder_paths` (required, array), `threshold`, `min_cluster_size`, `force_embed`, `title_model`, `force_title`, `use_title_cache`, `assign_noise_threshold`, `lang`
- **Prompt retrieval (RAG-like)**: `POST /infinite_image_browsing/db/search_iib_output_by_prompt`
- Request: `query`, `folder_paths` (required, array), `top_k`, `min_score`, `ensure_embed`, `model`, `max_chars`
### How it Works (simple explanation)
- **1) Prompt extraction & normalization**
- Reads `image.exif` and keeps content before `Negative prompt:`
- Optionally removes “boilerplate” terms (quality/photography parameters, etc.) to focus on topic semantics (`IIB_PROMPT_NORMALIZE*`)
- **2) Embeddings**
- Calls OpenAI-compatible `/embeddings`
- Stores vectors in SQLite table `image_embedding` (incremental, to avoid repeated costs)
- **3) Clustering**
- Online centroid-sum clustering, plus a post-merge step for highly similar clusters
- Optionally reassigns members of small clusters into the closest large cluster to reduce noise
- **4) Title generation (LLM)**
- Calls `/chat/completions` with tool/function calling to force structured JSON output
- Stores titles/keywords in SQLite table `topic_title_cache`
- **5) Retrieval**
- Embeds the query and ranks images in the selected scope by cosine similarity, returning TopK
### Caching & Incremental Updates
#### 1) Embedding cache (`image_embedding`)
- **Where**: table `image_embedding` (keyed by `image_id`)
- **Skip rule (incremental update)**: an image is skipped if:
- same `model`
- same `text_hash`
- existing `vec` is present
- **Re-vectorization cache key**: `text_hash = sha256(f"{normalize_version}:{prompt_text}")`
- `prompt_text` is the extracted + (optionally) normalized text used for embeddings
- `normalize_version` is a **code-derived fingerprint** of normalization rules/mode (not user-configurable)
- **Force rebuild**: pass `force=true` to `build_iib_output_embeddings` or `force_embed=true` to `cluster_iib_output`
#### 2) Title cache (`topic_title_cache`)
- **Where**: table `topic_title_cache` keyed by `cluster_hash`
- **Hit rule**: when `use_title_cache=true` and `force_title=false`, titles/keywords are reused
- **Cache key (`cluster_hash`) includes**:
- member image IDs (sorted)
- embedding `model`, `threshold`, `min_cluster_size`
- `title_model`, output `lang`
- normalization fingerprint (`normalize_version`) and mode
- **Force title regeneration**: `force_title=true`
### Configuration (Environment Variables)
All calls use an **OpenAI-compatible** provider:
- **`OPENAI_BASE_URL`**: e.g. `https://your-host/v1`
- **`OPENAI_API_KEY`**: your API key
- **`EMBEDDING_MODEL`**: embeddings model used for clustering
- **`AI_MODEL`**: default chat model (fallback)
- **`TOPIC_TITLE_MODEL`**: chat model used for cluster titles (falls back to `AI_MODEL`)
- **`IIB_PROMPT_NORMALIZE`**: `1/0` enable prompt normalization
- **`IIB_PROMPT_NORMALIZE_MODE`**: `balanced` (recommended) / `theme_only`
> Note: There is **no mock fallback** for AI calls. If the provider/model fails or returns invalid output, the API will return an error directly.

View File

@ -7,4 +7,5 @@ pillow-avif-plugin
imageio
av>=14,<15
lxml
filetype
filetype
requests

View File

@ -6,7 +6,6 @@ from pathlib import Path
import shutil
import sqlite3
from scripts.iib.dir_cover_cache import get_top_4_media_info
from scripts.iib.tool import (
get_created_date_by_stat,
@ -48,6 +47,7 @@ from PIL import Image
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
import hashlib
from contextlib import closing
from scripts.iib.db.datamodel import (
DataBase,
ExtraPathType,
@ -58,9 +58,10 @@ from scripts.iib.db.datamodel import (
ExtraPath,
FileInfoDict,
Cursor,
GlobalSetting
GlobalSetting,
)
from scripts.iib.db.update_image_data import update_image_data, rebuild_image_index, add_image_data_single
from scripts.iib.topic_cluster import mount_topic_cluster_routes
from scripts.iib.logger import logger
from scripts.iib.seq import seq
import urllib.parse
@ -73,6 +74,13 @@ try:
except Exception as e:
logger.error(e)
import requests
import dotenv
# 加载环境变量
dotenv.load_dotenv()
index_html_path = get_data_file_path("vue/dist/index.html") if is_exe_ver else os.path.join(cwd, "vue/dist/index.html") # 在app.py也被使用
@ -83,6 +91,17 @@ secret_key = os.getenv("IIB_SECRET_KEY")
if secret_key:
print("Secret key loaded successfully. ")
# AI 配置
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
AI_MODEL = os.getenv("AI_MODEL", "gpt-4o-mini")
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
print(f"AI Model: {AI_MODEL or 'Not configured'}")
print(f"OpenAI Base URL: {OPENAI_BASE_URL}")
print(f"OpenAI API Key: {'Configured' if OPENAI_API_KEY else 'Not configured'}")
print(f"Embedding Model: {EMBEDDING_MODEL or 'Not configured'}")
WRITEABLE_PERMISSIONS = ["read-write", "write-only"]
is_api_writeable = not (os.getenv("IIB_ACCESS_CONTROL_PERMISSION")) or (
@ -376,6 +395,7 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
)
async def delete_files(req: DeleteFilesReq):
conn = DataBase.get_conn()
for path in req.file_paths:
check_path_trust(path)
try:
@ -390,10 +410,12 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
shutil.rmtree(path)
else:
close_video_file_reader(path)
os.remove(path)
txt_path = get_img_geninfo_txt_path(path)
os.remove(path)
if txt_path:
os.remove(txt_path)
img = DbImg.get(conn, os.path.normpath(path))
if img:
logger.info("delete file: %s", path)
@ -409,6 +431,8 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
)
raise HTTPException(400, detail=error_msg)
return {"ok": True}
class CreateFoldersReq(BaseModel):
dest_folder: str
@ -606,6 +630,7 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
img.thumbnail((int(w), int(h)))
os.makedirs(cache_dir, exist_ok=True)
img.save(cache_path, "webp")
# print(f"Image cache generated: {path}")
# 返回缓存文件
return FileResponse(
@ -1216,6 +1241,17 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
ImageTag.remove(conn, image_id=req.img_id, tag_id=req.tag_id)
# ===== 主题聚类 / Embedding拆分到独立模块减少 api.py 体积)=====
mount_topic_cluster_routes(
app=app,
db_api_base=db_api_base,
verify_secret=verify_secret,
write_permission_required=write_permission_required,
openai_base_url=OPENAI_BASE_URL,
openai_api_key=OPENAI_API_KEY,
embedding_model=EMBEDDING_MODEL,
ai_model=AI_MODEL,
)
class ExtraPathModel(BaseModel):
@ -1293,3 +1329,48 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
update_extra_paths(conn = DataBase.get_conn())
rebuild_image_index(search_dirs = get_img_search_dirs() + mem["extra_paths"])
# AI 相关路由
class AIChatRequest(BaseModel):
messages: List[dict]
temperature: Optional[float] = 0.7
max_tokens: Optional[int] = None
stream: Optional[bool] = False
@app.post(f"{api_base}/ai-chat", dependencies=[Depends(verify_secret), Depends(write_permission_required)])
async def ai_chat(req: AIChatRequest):
"""通用AI聊天接口转发到OpenAI兼容API"""
if not OPENAI_API_KEY:
raise HTTPException(status_code=500, detail="OpenAI API Key not configured")
try:
payload = {
"model": AI_MODEL,
"messages": req.messages,
"temperature": req.temperature,
"stream": req.stream
}
if req.max_tokens:
payload["max_tokens"] = req.max_tokens
headers = {
"Authorization": f"Bearer {OPENAI_API_KEY}",
"Content-Type": "application/json"
}
response = requests.post(
f"{OPENAI_BASE_URL}/chat/completions",
json=payload,
headers=headers,
timeout=60
)
if response.status_code != 200:
raise HTTPException(status_code=response.status_code, detail=response.text)
return response.json()
except requests.RequestException as e:
logger.error(f"AI API request failed: {e}")
raise HTTPException(status_code=500, detail=f"AI API request failed: {str(e)}")

View File

@ -18,6 +18,7 @@ from contextlib import closing
import os
import threading
import re
import hashlib
class FileInfoDict(TypedDict):
@ -80,6 +81,8 @@ class DataBase:
ExtraPath.create_table(conn)
DirCoverCache.create_table(conn)
GlobalSetting.create_table(conn)
ImageEmbedding.create_table(conn)
TopicTitleCache.create_table(conn)
finally:
conn.commit()
clz.num += 1
@ -301,6 +304,151 @@ class Image:
return images
class ImageEmbedding:
"""
Store embeddings for image prompt text.
Notes:
- vec is stored as float32 bytes (little-endian), compatible with Python's array('f').
- text_hash is used to skip recomputation when prompt text doesn't change.
"""
@classmethod
def create_table(cls, conn: Connection):
with closing(conn.cursor()) as cur:
cur.execute(
"""CREATE TABLE IF NOT EXISTS image_embedding (
image_id INTEGER PRIMARY KEY,
model TEXT NOT NULL,
dim INTEGER NOT NULL,
text_hash TEXT NOT NULL,
vec BLOB NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (image_id) REFERENCES image(id)
)"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS image_embedding_idx_model_hash ON image_embedding(model, text_hash)"
)
@staticmethod
def compute_text_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
@classmethod
def get_by_image_ids(cls, conn: Connection, image_ids: List[int]):
if not image_ids:
return {}
placeholders = ",".join("?" * len(image_ids))
query = f"SELECT image_id, model, dim, text_hash, vec, updated_at FROM image_embedding WHERE image_id IN ({placeholders})"
with closing(conn.cursor()) as cur:
cur.execute(query, image_ids)
rows = cur.fetchall()
res = {}
for row in rows:
res[row[0]] = {
"image_id": row[0],
"model": row[1],
"dim": row[2],
"text_hash": row[3],
"vec": row[4],
"updated_at": row[5],
}
return res
@classmethod
def upsert(
cls,
conn: Connection,
image_id: int,
model: str,
dim: int,
text_hash: str,
vec_blob: bytes,
updated_at: Optional[str] = None,
):
updated_at = updated_at or datetime.now().isoformat()
with closing(conn.cursor()) as cur:
cur.execute(
"""INSERT INTO image_embedding (image_id, model, dim, text_hash, vec, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(image_id) DO UPDATE SET
model = excluded.model,
dim = excluded.dim,
text_hash = excluded.text_hash,
vec = excluded.vec,
updated_at = excluded.updated_at
""",
(image_id, model, dim, text_hash, vec_blob, updated_at),
)
class TopicTitleCache:
"""
Cache cluster titles/keywords to avoid repeated LLM calls.
"""
@classmethod
def create_table(cls, conn: Connection):
with closing(conn.cursor()) as cur:
cur.execute(
"""CREATE TABLE IF NOT EXISTS topic_title_cache (
cluster_hash TEXT PRIMARY KEY,
title TEXT NOT NULL,
keywords TEXT NOT NULL,
model TEXT NOT NULL,
updated_at TEXT NOT NULL
)"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS topic_title_cache_idx_model ON topic_title_cache(model)"
)
@classmethod
def get(cls, conn: Connection, cluster_hash: str):
with closing(conn.cursor()) as cur:
cur.execute(
"SELECT title, keywords, model, updated_at FROM topic_title_cache WHERE cluster_hash = ?",
(cluster_hash,),
)
row = cur.fetchone()
if not row:
return None
title, keywords, model, updated_at = row
try:
kw = json.loads(keywords) if isinstance(keywords, str) else []
except Exception:
kw = []
if not isinstance(kw, list):
kw = []
return {"title": title, "keywords": kw, "model": model, "updated_at": updated_at}
@classmethod
def upsert(
cls,
conn: Connection,
cluster_hash: str,
title: str,
keywords: List[str],
model: str,
updated_at: Optional[str] = None,
):
updated_at = updated_at or datetime.now().isoformat()
kw = json.dumps([str(x) for x in (keywords or [])], ensure_ascii=False)
with closing(conn.cursor()) as cur:
cur.execute(
"""INSERT INTO topic_title_cache (cluster_hash, title, keywords, model, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(cluster_hash) DO UPDATE SET
title = excluded.title,
keywords = excluded.keywords,
model = excluded.model,
updated_at = excluded.updated_at
""",
(cluster_hash, title, kw, model, updated_at),
)
class Tag:
def __init__(self, name: str, score: int, type: str, count=0, color = ""):
self.name = name

View File

@ -6,11 +6,14 @@ from concurrent.futures import ThreadPoolExecutor
import time
from PIL import Image
def generate_image_cache(dirs, size:str, verbose=False):
def generate_image_cache(dirs: List[str], size:str, verbose=True):
dirs = [r"C:\Users\zanllp\Desktop\repo\Z-Image-Turbo\client"]
start_time = time.time()
cache_base_dir = get_cache_dir()
verbose=True
def process_image(item):
if '\\node_modules\\' in item.path:
return
if item.is_dir():
verbose and print(f"Processing directory: {item.path}")
for sub_item in os.scandir(item.path):

View File

@ -0,0 +1,931 @@
import hashlib
import json
import math
import os
import re
from array import array
from contextlib import closing
from typing import Dict, List, Optional, Tuple
import requests
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from scripts.iib.db.datamodel import DataBase, ImageEmbedding, TopicTitleCache
from scripts.iib.tool import cwd
def _normalize_base_url(base_url: str) -> str:
return base_url[:-1] if base_url.endswith("/") else base_url
_PROMPT_NORMALIZE_ENABLED = os.getenv("IIB_PROMPT_NORMALIZE", "1").strip().lower() not in ["0", "false", "no", "off"]
# balanced: keep some discriminative style words (e.g. "科学插图/纪实/胶片") while dropping boilerplate quality/camera terms
# theme_only: more aggressive removal, closer to "only subject/theme nouns"
_PROMPT_NORMALIZE_MODE = (os.getenv("IIB_PROMPT_NORMALIZE_MODE", "balanced") or "balanced").strip().lower()
# Remove common SD boilerplate / quality descriptors.
_DROP_PATTERNS_COMMON = [
# SD / A1111 tags
r"<lora:[^>]+>",
r"<lyco:[^>]+>",
# quality / resolution / generic
r"\b(masterpiece|best\s*quality|high\s*quality|best\s*rating|(?:highly|ultra|hyper)[-\s\u2010\u2011\u2012\u2013\u2212]*detailed|absurdres|absurd\s*res|hires|hdr|uhd|8k|4k|2k|raw\s*photo|photorealistic|realistic|cinematic)\b",
# photography / camera / lens
r"\b(film\s+photography|photography|dslr|camera|canon|nikon|sony|sigma|leica|lens|bokeh|depth\s+of\s+field|dof|sharp\s+focus|wide\s+angle|fisheye)\b",
r"\b(iso\s*\d{2,5}|f\/\d+(?:\.\d+)?|\d{2,4}mm)\b",
]
# chinese quality descriptors (common)
_DROP_PATTERNS_ZH_COMMON = [
r"(超高分辨率|高分辨率|高清|超清|8K|4K|2K|照片级|高质量|最佳质量|大师作品|杰作|超细节|细节丰富|极致细节|极致|完美)",
]
# chinese "style/photography" words: keep in balanced mode (discriminative), drop in theme_only mode
_DROP_PATTERNS_ZH_STYLE = [
r"(电影质感|写真|写实|真实感|摄影|摄影作品|摄影图像|摄影图|镜头|景深|胶片|光圈|光影|构图|色彩|渲染|纪实|插图|科学插图)",
]
def _build_drop_re() -> re.Pattern:
pats = list(_DROP_PATTERNS_COMMON) + list(_DROP_PATTERNS_ZH_COMMON)
if _PROMPT_NORMALIZE_MODE in ["theme", "theme_only", "strict"]:
pats += list(_DROP_PATTERNS_ZH_STYLE)
return re.compile("|".join(f"(?:{p})" for p in pats), flags=re.IGNORECASE)
_DROP_RE = _build_drop_re()
def _compute_prompt_normalize_version() -> str:
"""
IMPORTANT:
- Do NOT allow users to override normalize-version via environment variables.
- Version should be deterministic from the normalization rules themselves, so cache invalidation
happens automatically when we change rules in code (or switch mode).
"""
payload = {
"enabled": bool(_PROMPT_NORMALIZE_ENABLED),
"mode": str(_PROMPT_NORMALIZE_MODE),
"drop_common": list(_DROP_PATTERNS_COMMON),
"drop_zh_common": list(_DROP_PATTERNS_ZH_COMMON),
"drop_zh_style": list(_DROP_PATTERNS_ZH_STYLE),
}
s = json.dumps(payload, ensure_ascii=False, sort_keys=True)
return "nv_" + hashlib.sha1(s.encode("utf-8")).hexdigest()[:12]
# Derived normalize version fingerprint (for embedding/title cache invalidation)
_PROMPT_NORMALIZE_VERSION = _compute_prompt_normalize_version()
def _extract_prompt_text(raw_exif: str, max_chars: int = 4000) -> str:
"""
Extract the natural-language prompt part from stored exif text.
Keep text before 'Negative prompt:' to preserve semantics.
"""
if not isinstance(raw_exif, str):
return ""
s = raw_exif.strip()
if not s:
return ""
idx = s.lower().find("negative prompt:")
if idx != -1:
s = s[:idx].strip()
if len(s) > max_chars:
s = s[:max_chars]
return s.strip()
def _clean_prompt_for_semantic(text: str) -> str:
"""
Light, dependency-free prompt normalization:
- remove lora tags / SD boilerplate / quality & photography descriptors
- keep remaining text as 'theme' semantic signal for embeddings/clustering
"""
if not isinstance(text, str):
return ""
s = text
# remove negative prompt tail early (safety if caller passes raw exif)
s = re.sub(r"(negative prompt:).*", " ", s, flags=re.IGNORECASE | re.DOTALL)
# remove weights like (foo:1.2)
s = re.sub(r"\(([^()]{1,80}):\s*\d+(?:\.\d+)?\)", r"\1", s)
# drop boilerplate patterns
s = _DROP_RE.sub(" ", s)
# normalize separators
s = s.replace("**", " ")
s = re.sub(r"[\[\]{}()]", " ", s)
s = re.sub(r"\s+", " ", s).strip()
# If it's a comma-tag style prompt, remove empty / tiny segments.
parts = re.split(r"[,\n;]+", s)
kept: List[str] = []
for p in parts:
t = p.strip()
if not t:
continue
# drop segments that are basically leftover boilerplate (too short or all punctuation)
if len(t) <= 2:
continue
kept.append(t)
s2 = "".join(kept) if kept else s
return s2.strip()
def _clean_for_title(text: str) -> str:
if not isinstance(text, str):
return ""
s = text
s = s.replace("**", " ")
s = re.sub(r"<lora:[^>]+>", " ", s, flags=re.IGNORECASE)
s = re.sub(r"<lyco:[^>]+>", " ", s, flags=re.IGNORECASE)
s = re.sub(r"(negative prompt:).*", " ", s, flags=re.IGNORECASE | re.DOTALL)
s = re.sub(r"(prompt:|提示词[:]|提示[:]|输出[:])", " ", s, flags=re.IGNORECASE)
s = re.sub(r"\s+", " ", s).strip()
return s
def _title_from_representative_prompt(text: str, max_len: int = 18) -> str:
"""
Local fallback title: take the first sentence/clause and truncate.
This is much more readable than token n-grams without Chinese word segmentation.
"""
# Use the same semantic cleaner as embeddings to avoid boilerplate titles like
# "masterpiece, best quality" / "A highly detailed ..."
base = _clean_for_title(text)
s = _clean_prompt_for_semantic(base) if _PROMPT_NORMALIZE_ENABLED else base
if not s:
s = base
if not s:
return "主题"
# Split by common sentence punctuations, keep the first segment.
seg = re.split(r"[。!?!?\n\r;]+", s)[0].strip()
# Remove leading punctuation / separators
seg = re.sub(r"^[,;:\s-]+", "", seg).strip()
# Remove leading english articles for nicer titles
seg = re.sub(r"^(a|an|the)\s+", "", seg, flags=re.IGNORECASE).strip()
# Strip common boilerplate templates in titles while keeping discriminative words.
# English: "highly detailed scientific rendering/illustration of ..."
seg = re.sub(
r"^(?:(?:highly|ultra|hyper)[-\s\u2010\u2011\u2012\u2013\u2212]*detailed\s+)?(?:scientific\s+)?(?:rendering|illustration|image|depiction|scene)\s+of\s+",
"",
seg,
flags=re.IGNORECASE,
).strip()
# Chinese: "一张...图像/插图/照片..." template
seg = re.sub(r"^一[张幅]\s*[^,。]{0,20}(?:图像|插图|照片|摄影图像|摄影作品)\s*[, ]*", "", seg).strip()
# Remove trailing commas/colons
seg = re.sub(r"[,:]\s*$", "", seg).strip()
# If still too long, hard truncate.
if len(seg) > max_len:
seg = seg[:max_len].rstrip()
return seg or "主题"
def _vec_to_blob_f32(vec: List[float]) -> bytes:
arr = array("f", vec)
return arr.tobytes()
def _blob_to_vec_f32(blob: bytes) -> array:
arr = array("f")
arr.frombytes(blob)
return arr
def _l2_norm_sq(vec: array) -> float:
return sum((x * x for x in vec))
def _dot(a: array, b: array) -> float:
return sum((x * y for x, y in zip(a, b)))
def _cos_sum(a_sum: array, a_norm_sq: float, b_sum: array, b_norm_sq: float) -> float:
if a_norm_sq <= 0 or b_norm_sq <= 0:
return 0.0
dotv = sum((x * y for x, y in zip(a_sum, b_sum)))
return dotv / (math.sqrt(a_norm_sq) * math.sqrt(b_norm_sq))
def _call_embeddings(
*,
inputs: List[str],
model: str,
base_url: str,
api_key: str,
) -> List[List[float]]:
if not api_key:
raise HTTPException(status_code=500, detail="OpenAI API Key not configured")
url = f"{_normalize_base_url(base_url)}/embeddings"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"model": model, "input": inputs}
try:
resp = requests.post(url, json=payload, headers=headers, timeout=120)
except requests.RequestException as e:
raise HTTPException(status_code=502, detail=f"Embedding API request failed: {e}")
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
data = resp.json()
items = data.get("data") or []
items.sort(key=lambda x: x.get("index", 0))
embeddings = [x.get("embedding") for x in items]
if any((not isinstance(v, list) for v in embeddings)):
raise HTTPException(status_code=500, detail="Invalid embeddings response format")
return embeddings
def _call_chat_title(
*,
base_url: str,
api_key: str,
model: str,
prompt_samples: List[str],
output_lang: str,
) -> Optional[Dict]:
"""
Ask LLM to generate a short topic title and a few keywords. Returns dict or None.
"""
if not api_key:
raise HTTPException(status_code=500, detail="OpenAI API Key not configured")
url = f"{_normalize_base_url(base_url)}/chat/completions"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
samples = [(_clean_prompt_for_semantic(_clean_for_title(s) or s) or s).strip() for s in prompt_samples if (s or "").strip()]
samples = [s[:400] for s in samples][:6]
if not samples:
raise HTTPException(status_code=400, detail="No prompt samples for title generation")
json_example = '{"title":"...","keywords":["...","..."]}'
sys = (
"You are a topic naming assistant for image-generation prompts.\n"
"Given several prompt snippets that belong to the SAME theme, output:\n"
"- a short topic title\n"
"- 36 keywords.\n"
"\n"
"Rules:\n"
f"- Output language MUST be: {output_lang}\n"
"- Prefer 412 characters for Chinese (Simplified/Traditional), otherwise 26 English/German words.\n"
"- Avoid generic boilerplate like: masterpiece, best quality, highly detailed, cinematic, etc.\n"
"- Keep distinctive terms if they help differentiate themes (e.g., Warhammer 40K, Lolita, scientific illustration).\n"
"- Do NOT output explanations. Do NOT output markdown/code fences.\n"
"- The output MUST start with '{' and end with '}' (no leading/trailing characters).\n"
"\n"
"Output STRICT JSON only:\n"
+ json_example
)
user = "Prompt snippets:\n" + "\n".join([f"- {s}" for s in samples])
payload = {
"model": model,
"messages": [{"role": "system", "content": sys}, {"role": "user", "content": user}],
# Prefer deterministic, JSON-only output
"temperature": 0.0,
"top_p": 1.0,
# Give enough room for JSON across providers.
"max_tokens": 2048,
# Prefer tool/function call to force structured output across providers (e.g. Gemini).
"tools": [
{
"type": "function",
"function": {
"name": "set_topic",
"description": "Return a concise topic title and 3-6 keywords.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"keywords": {"type": "array", "items": {"type": "string"}},
},
"required": ["title", "keywords"],
"additionalProperties": False,
},
},
}
],
"tool_choice": {"type": "function", "function": {"name": "set_topic"}},
}
# Some OpenAI-compatible providers may use different token limit fields / casing.
# Set them all (still a single request; no retry/fallback).
payload["max_output_tokens"] = payload["max_tokens"]
payload["max_completion_tokens"] = payload["max_tokens"]
payload["maxOutputTokens"] = payload["max_tokens"]
payload["maxCompletionTokens"] = payload["max_tokens"]
def _post_and_parse(payload_obj: Dict) -> Dict:
try:
resp = requests.post(url, json=payload_obj, headers=headers, timeout=60)
except requests.RequestException as e:
raise HTTPException(status_code=502, detail=f"Chat API request failed: {e}")
if resp.status_code != 200:
# keep response body for debugging (truncated)
body = (resp.text or "")[:600]
raise HTTPException(status_code=resp.status_code, detail=body)
try:
data = resp.json()
except Exception as e:
txt = (resp.text or "")[:600]
raise HTTPException(status_code=502, detail=f"Chat API response is not JSON: {e}; body={txt}")
choice0 = (data.get("choices") or [{}])[0] if isinstance(data.get("choices"), list) else {}
msg = (choice0 or {}).get("message") or {}
# OpenAI-compatible providers may return JSON in different places:
# - message.tool_calls[].function.arguments (JSON string) <-- preferred when tools are used
# - message.function_call.arguments (legacy)
# - message.content (common)
# - choice.text (legacy completions)
raw = ""
if not raw and isinstance(msg, dict):
tcs = msg.get("tool_calls") or []
if isinstance(tcs, list) and tcs:
fn = ((tcs[0] or {}).get("function") or {}) if isinstance(tcs[0], dict) else {}
args = (fn.get("arguments") or "") if isinstance(fn, dict) else ""
if isinstance(args, str) and args.strip():
raw = args.strip()
if not raw and isinstance(msg, dict):
fc = msg.get("function_call") or {}
args = (fc.get("arguments") or "") if isinstance(fc, dict) else ""
if isinstance(args, str) and args.strip():
raw = args.strip()
if not raw:
content = (msg.get("content") or "") if isinstance(msg, dict) else ""
if isinstance(content, str) and content.strip():
raw = content.strip()
if not raw:
txt = (choice0.get("text") or "") if isinstance(choice0, dict) else ""
if isinstance(txt, str) and txt.strip():
raw = txt.strip()
m = re.search(r"\{[\s\S]*\}", raw)
if not m:
snippet = (raw or "")[:400].replace("\n", "\\n")
choice_dump = json.dumps(choice0, ensure_ascii=False)[:600] if isinstance(choice0, dict) else str(choice0)[:600]
raise HTTPException(
status_code=502,
detail=f"Chat API response has no JSON object; content_snippet={snippet}; choice0={choice_dump}",
)
try:
obj = json.loads(m.group(0))
except Exception as e:
snippet = (m.group(0) or "")[:400].replace("\n", "\\n")
raise HTTPException(status_code=502, detail=f"Chat API JSON parse failed: {e}; json_snippet={snippet}")
if not isinstance(obj, dict):
raise HTTPException(status_code=502, detail="Chat API response JSON is not an object")
title = str(obj.get("title") or "").strip()
keywords = obj.get("keywords") or []
if not title:
raise HTTPException(status_code=502, detail="Chat API response missing title")
if not isinstance(keywords, list):
keywords = []
keywords = [str(x).strip() for x in keywords if str(x).strip()][:6]
return {"title": title[:24], "keywords": keywords}
# No fallback / no retry: fail fast if provider/model doesn't support response_format or returns invalid output.
return _post_and_parse(payload)
def mount_topic_cluster_routes(
app: FastAPI,
db_api_base: str,
verify_secret,
write_permission_required,
*,
openai_base_url: str,
openai_api_key: str,
embedding_model: str,
ai_model: str,
):
"""
Mount embedding + topic clustering endpoints (MVP: manual, iib_output only).
"""
class BuildIibOutputEmbeddingReq(BaseModel):
folder: Optional[str] = None # default: {cwd}/iib_output
model: Optional[str] = None
force: Optional[bool] = False
batch_size: Optional[int] = 64
max_chars: Optional[int] = 4000
@app.post(
f"{db_api_base}/build_iib_output_embeddings",
dependencies=[Depends(verify_secret), Depends(write_permission_required)],
)
async def build_iib_output_embeddings(req: BuildIibOutputEmbeddingReq):
if not openai_api_key:
raise HTTPException(status_code=500, detail="OpenAI API Key not configured")
if not openai_base_url:
raise HTTPException(status_code=500, detail="OpenAI Base URL not configured")
folder = req.folder or os.path.join(cwd, "iib_output")
folder = os.path.normpath(folder)
model = req.model or embedding_model
batch_size = max(1, min(int(req.batch_size or 64), 256))
max_chars = max(256, min(int(req.max_chars or 4000), 8000))
force = bool(req.force)
if not os.path.exists(folder) or not os.path.isdir(folder):
raise HTTPException(status_code=400, detail=f"Folder not found: {folder}")
conn = DataBase.get_conn()
like_prefix = os.path.join(folder, "%")
with closing(conn.cursor()) as cur:
cur.execute("SELECT id, path, exif FROM image WHERE path LIKE ?", (like_prefix,))
rows = cur.fetchall()
images = []
for image_id, path, exif in rows:
if not isinstance(path, str) or not os.path.exists(path):
continue
text_raw = _extract_prompt_text(exif, max_chars=max_chars)
if _PROMPT_NORMALIZE_ENABLED:
text = _clean_prompt_for_semantic(text_raw)
if not text:
text = text_raw
else:
text = text_raw
if not text:
continue
images.append({"id": int(image_id), "path": path, "text": text})
if not images:
return {"folder": folder, "count": 0, "updated": 0, "skipped": 0, "model": model}
id_list = [x["id"] for x in images]
existing = ImageEmbedding.get_by_image_ids(conn, id_list)
to_embed = []
skipped = 0
for item in images:
# include normalize version to force refresh when rules change
text_hash = ImageEmbedding.compute_text_hash(f"{_PROMPT_NORMALIZE_VERSION}:{item['text']}")
old = existing.get(item["id"])
if (
(not force)
and old
and old.get("model") == model
and old.get("text_hash") == text_hash
and old.get("vec")
):
skipped += 1
continue
to_embed.append({**item, "text_hash": text_hash})
updated = 0
for i in range(0, len(to_embed), batch_size):
batch = to_embed[i : i + batch_size]
inputs = [x["text"] for x in batch]
vectors = _call_embeddings(
inputs=inputs,
model=model,
base_url=openai_base_url,
api_key=openai_api_key,
)
if len(vectors) != len(batch):
raise HTTPException(status_code=500, detail="Embeddings count mismatch")
for item, vec in zip(batch, vectors):
ImageEmbedding.upsert(
conn=conn,
image_id=item["id"],
model=model,
dim=len(vec),
text_hash=item["text_hash"],
vec_blob=_vec_to_blob_f32(vec),
)
updated += 1
conn.commit()
return {"folder": folder, "count": len(images), "updated": updated, "skipped": skipped, "model": model}
class ClusterIibOutputReq(BaseModel):
folder: Optional[str] = None
folder_paths: Optional[List[str]] = None
model: Optional[str] = None
force_embed: Optional[bool] = False
threshold: Optional[float] = 0.86
batch_size: Optional[int] = 64
max_chars: Optional[int] = 4000
min_cluster_size: Optional[int] = 2
# B: LLM title generation
title_model: Optional[str] = None
# Reduce noise by reassigning small-cluster members to best large cluster if similarity is high enough
assign_noise_threshold: Optional[float] = None
# Cache titles in sqlite to avoid repeated LLM calls
use_title_cache: Optional[bool] = True
force_title: Optional[bool] = False
# Output language for titles/keywords (from frontend globalStore.lang)
lang: Optional[str] = None
class PromptSearchReq(BaseModel):
query: str
folder: Optional[str] = None
folder_paths: Optional[List[str]] = None
model: Optional[str] = None
top_k: Optional[int] = 50
min_score: Optional[float] = 0.0
# Ensure embeddings exist/updated before searching
ensure_embed: Optional[bool] = True
# Use the same normalization as clustering
max_chars: Optional[int] = 4000
@app.post(
f"{db_api_base}/search_iib_output_by_prompt",
dependencies=[Depends(verify_secret), Depends(write_permission_required)],
)
async def search_iib_output_by_prompt(req: PromptSearchReq):
if not openai_api_key:
raise HTTPException(status_code=500, detail="OpenAI API Key not configured")
if not openai_base_url:
raise HTTPException(status_code=500, detail="OpenAI Base URL not configured")
q = (req.query or "").strip()
if not q:
raise HTTPException(status_code=400, detail="query is required")
folders: List[str] = []
if req.folder_paths:
for p in req.folder_paths:
if isinstance(p, str) and p.strip():
folders.append(os.path.normpath(p.strip()))
if req.folder and isinstance(req.folder, str) and req.folder.strip():
folders.append(os.path.normpath(req.folder.strip()))
# 用户不会用默认 iib_output未指定范围则直接报错
if not folders:
raise HTTPException(status_code=400, detail="folder_paths is required (select folders to search)")
# validate folders
folders = list(dict.fromkeys(folders)) # de-dup keep order
for f in folders:
if not os.path.exists(f) or not os.path.isdir(f):
raise HTTPException(status_code=400, detail=f"Folder not found: {f}")
folder = folders[0]
model = req.model or embedding_model
top_k = max(1, min(int(req.top_k or 50), 500))
min_score = float(req.min_score or 0.0)
min_score = max(-1.0, min(min_score, 1.0))
max_chars = max(256, min(int(req.max_chars or 4000), 8000))
if bool(req.ensure_embed):
for f in folders:
await build_iib_output_embeddings(
BuildIibOutputEmbeddingReq(folder=f, model=model, force=False, batch_size=64, max_chars=max_chars)
)
# Build query embedding
q_text = _extract_prompt_text(q, max_chars=max_chars)
if _PROMPT_NORMALIZE_ENABLED:
q_text2 = _clean_prompt_for_semantic(q_text)
if q_text2:
q_text = q_text2
vecs = _call_embeddings(inputs=[q_text], model=model, base_url=openai_base_url, api_key=openai_api_key)
if not vecs or not isinstance(vecs[0], list) or not vecs[0]:
raise HTTPException(status_code=502, detail="Embedding API returned empty vector")
qv = array("f", [float(x) for x in vecs[0]])
qn2 = _l2_norm_sq(qv)
if qn2 <= 0:
raise HTTPException(status_code=502, detail="Query embedding has zero norm")
qinv = 1.0 / math.sqrt(qn2)
for i in range(len(qv)):
qv[i] *= qinv
conn = DataBase.get_conn()
like_prefixes = [os.path.join(f, "%") for f in folders]
with closing(conn.cursor()) as cur:
where = " OR ".join(["image.path LIKE ?"] * len(like_prefixes))
cur.execute(
f"""SELECT image.id, image.path, image.exif, image_embedding.vec
FROM image
INNER JOIN image_embedding ON image_embedding.image_id = image.id
WHERE ({where}) AND image_embedding.model = ?""",
(*like_prefixes, model),
)
rows = cur.fetchall()
# TopK by cosine similarity (brute force; MVP only)
import heapq
heap: List[Tuple[float, Dict]] = []
total = 0
for image_id, path, exif, vec_blob in rows:
if not isinstance(path, str) or not os.path.exists(path):
continue
if not vec_blob:
continue
v = _blob_to_vec_f32(vec_blob)
n2 = _l2_norm_sq(v)
if n2 <= 0:
continue
inv = 1.0 / math.sqrt(n2)
for i in range(len(v)):
v[i] *= inv
score = _dot(qv, v)
total += 1
if score < min_score:
continue
item = {
"id": int(image_id),
"path": path,
"score": float(score),
"sample_prompt": _clean_for_title(_extract_prompt_text(exif, max_chars=max_chars))[:200],
}
if len(heap) < top_k:
heapq.heappush(heap, (score, item))
else:
if score > heap[0][0]:
heapq.heapreplace(heap, (score, item))
heap.sort(key=lambda x: x[0], reverse=True)
results = [x[1] for x in heap]
return {
"query": q,
"folder": folder,
"folders": folders,
"model": model,
"count": total,
"top_k": top_k,
"results": results,
}
def _cluster_sig(
*,
member_ids: List[int],
model: str,
threshold: float,
min_cluster_size: int,
title_model: str,
lang: str,
) -> str:
h = hashlib.sha1()
h.update(f"m={model}|t={threshold:.6f}|min={min_cluster_size}|tm={title_model}|lang={lang}|nv={_PROMPT_NORMALIZE_VERSION}|nm={_PROMPT_NORMALIZE_MODE}".encode("utf-8"))
for iid in sorted(member_ids):
h.update(b"|")
h.update(str(int(iid)).encode("utf-8"))
return h.hexdigest()
def _normalize_output_lang(lang: Optional[str]) -> str:
"""
Map frontend language keys to a human-readable instruction for LLM output language.
Frontend uses: en / zhHans / zhHant / de
"""
if not lang:
return "English"
l = str(lang).strip()
ll = l.lower()
if ll in ["zh", "zhhans", "zh-hans", "zh_cn", "zh-cn", "cn", "zh-hans-cns", "zhs"]:
return "Chinese (Simplified)"
if ll in ["zhhant", "zh-hant", "zh_tw", "zh-tw", "zh_hk", "zh-hk", "tw", "hk", "zht"]:
return "Chinese (Traditional)"
if ll.startswith("de"):
return "German"
if ll.startswith("en"):
return "English"
# fallback
return "English"
@app.post(
f"{db_api_base}/cluster_iib_output",
dependencies=[Depends(verify_secret), Depends(write_permission_required)],
)
async def cluster_iib_output(req: ClusterIibOutputReq):
if not openai_api_key:
raise HTTPException(status_code=500, detail="OpenAI API Key not configured")
if not openai_base_url:
raise HTTPException(status_code=500, detail="OpenAI Base URL not configured")
folders: List[str] = []
if req.folder_paths:
for p in req.folder_paths:
if isinstance(p, str) and p.strip():
folders.append(os.path.normpath(p.strip()))
if req.folder and isinstance(req.folder, str) and req.folder.strip():
folders.append(os.path.normpath(req.folder.strip()))
# 用户不会用默认 iib_output未指定范围则直接报错
if not folders:
raise HTTPException(status_code=400, detail="folder_paths is required (select folders to cluster)")
folders = list(dict.fromkeys(folders))
for f in folders:
if not os.path.exists(f) or not os.path.isdir(f):
raise HTTPException(status_code=400, detail=f"Folder not found: {f}")
# Ensure embeddings exist (incremental per folder)
for f in folders:
await build_iib_output_embeddings(
BuildIibOutputEmbeddingReq(
folder=f,
model=req.model,
force=req.force_embed,
batch_size=req.batch_size,
max_chars=req.max_chars,
)
)
folder = folders[0]
model = req.model or embedding_model
threshold = float(req.threshold or 0.86)
threshold = max(0.0, min(threshold, 0.999))
min_cluster_size = max(1, int(req.min_cluster_size or 2))
title_model = req.title_model or os.getenv("TOPIC_TITLE_MODEL") or ai_model
output_lang = _normalize_output_lang(req.lang)
assign_noise_threshold = req.assign_noise_threshold
if assign_noise_threshold is None:
# conservative: only reassign if very likely belongs to a large topic
assign_noise_threshold = max(0.72, min(threshold - 0.035, 0.93))
else:
assign_noise_threshold = max(0.0, min(float(assign_noise_threshold), 0.999))
use_title_cache = bool(True if req.use_title_cache is None else req.use_title_cache)
force_title = bool(req.force_title)
conn = DataBase.get_conn()
like_prefixes = [os.path.join(f, "%") for f in folders]
with closing(conn.cursor()) as cur:
where = " OR ".join(["image.path LIKE ?"] * len(like_prefixes))
cur.execute(
f"""SELECT image.id, image.path, image.exif, image_embedding.vec
FROM image
INNER JOIN image_embedding ON image_embedding.image_id = image.id
WHERE ({where}) AND image_embedding.model = ?""",
(*like_prefixes, model),
)
rows = cur.fetchall()
items = []
for image_id, path, exif, vec_blob in rows:
if not isinstance(path, str) or not os.path.exists(path):
continue
if not vec_blob:
continue
vec = _blob_to_vec_f32(vec_blob)
n2 = _l2_norm_sq(vec)
if n2 <= 0:
continue
inv = 1.0 / math.sqrt(n2)
for i in range(len(vec)):
vec[i] *= inv
text_raw = _extract_prompt_text(exif, max_chars=int(req.max_chars or 4000))
if _PROMPT_NORMALIZE_ENABLED:
text = _clean_prompt_for_semantic(text_raw)
if not text:
text = text_raw
else:
text = text_raw
items.append({"id": int(image_id), "path": path, "text": text, "vec": vec})
if not items:
return {"folder": folder, "folders": folders, "model": model, "threshold": threshold, "clusters": [], "noise": []}
# Incremental clustering by centroid-direction (sum vector)
clusters = [] # {sum, norm_sq, members:[idx], sample_text}
for idx, it in enumerate(items):
v = it["vec"]
best_ci = -1
best_sim = -1.0
best_dot = 0.0
for ci, c in enumerate(clusters):
dotv = _dot(v, c["sum"])
denom = math.sqrt(c["norm_sq"]) if c["norm_sq"] > 0 else 1.0
sim = dotv / denom
if sim > best_sim:
best_sim = sim
best_ci = ci
best_dot = dotv
if best_ci != -1 and best_sim >= threshold:
c = clusters[best_ci]
for i in range(len(v)):
c["sum"][i] += v[i]
c["norm_sq"] = c["norm_sq"] + 2.0 * best_dot + 1.0
c["members"].append(idx)
else:
clusters.append({"sum": array("f", v), "norm_sq": 1.0, "members": [idx], "sample_text": it.get("text") or ""})
# Merge highly similar clusters (fix: same theme split into multiple clusters)
merge_threshold = min(0.995, max(threshold + 0.04, 0.90))
merged = True
while merged and len(clusters) > 1:
merged = False
best_i = best_j = -1
best_sim = merge_threshold
for i in range(len(clusters)):
ci = clusters[i]
for j in range(i + 1, len(clusters)):
cj = clusters[j]
sim = _cos_sum(ci["sum"], ci["norm_sq"], cj["sum"], cj["norm_sq"])
if sim >= best_sim:
best_sim = sim
best_i, best_j = i, j
if best_i != -1:
a = clusters[best_i]
b = clusters[best_j]
for k in range(len(a["sum"])):
a["sum"][k] += b["sum"][k]
a["norm_sq"] = _l2_norm_sq(a["sum"])
a["members"].extend(b["members"])
if not a.get("sample_text"):
a["sample_text"] = b.get("sample_text", "")
clusters.pop(best_j)
merged = True
# Reassign members from small clusters into best large cluster to reduce noise
if min_cluster_size > 1 and assign_noise_threshold > 0 and clusters:
large = [c for c in clusters if len(c["members"]) >= min_cluster_size]
if large:
new_large = []
# copy large clusters first
for c in clusters:
if len(c["members"]) >= min_cluster_size:
new_large.append(c)
# reassign items from small clusters
for c in clusters:
if len(c["members"]) >= min_cluster_size:
continue
for mi in c["members"]:
v = items[mi]["vec"]
best_ci = -1
best_sim = -1.0
best_dot = 0.0
for ci, bigc in enumerate(new_large):
dotv = _dot(v, bigc["sum"])
denom = math.sqrt(bigc["norm_sq"]) if bigc["norm_sq"] > 0 else 1.0
sim = dotv / denom
if sim > best_sim:
best_sim = sim
best_ci = ci
best_dot = dotv
if best_ci != -1 and best_sim >= assign_noise_threshold:
bigc = new_large[best_ci]
for k in range(len(v)):
bigc["sum"][k] += v[k]
bigc["norm_sq"] = bigc["norm_sq"] + 2.0 * best_dot + 1.0
bigc["members"].append(mi)
# else: keep in small cluster -> will become noise below
clusters = new_large
# Split small clusters to noise, generate titles
out_clusters = []
noise = []
for cidx, c in enumerate(clusters):
if len(c["members"]) < min_cluster_size:
for mi in c["members"]:
noise.append(items[mi]["path"])
continue
member_items = [items[mi] for mi in c["members"]]
paths = [x["path"] for x in member_items]
texts = [x.get("text") or "" for x in member_items]
member_ids = [x["id"] for x in member_items]
# Representative prompt for LLM title generation
rep = (c.get("sample_text") or (texts[0] if texts else "")).strip()
cached = None
cluster_hash = _cluster_sig(
member_ids=member_ids,
model=model,
threshold=threshold,
min_cluster_size=min_cluster_size,
title_model=title_model,
lang=output_lang,
)
if use_title_cache and (not force_title):
cached = TopicTitleCache.get(conn, cluster_hash)
if cached and isinstance(cached, dict) and cached.get("title"):
title = str(cached.get("title"))
keywords = cached.get("keywords") or []
else:
llm = _call_chat_title(
base_url=openai_base_url,
api_key=openai_api_key,
model=title_model,
prompt_samples=[rep] + texts[:5],
output_lang=output_lang,
)
title = (llm or {}).get("title") if isinstance(llm, dict) else None
keywords = (llm or {}).get("keywords", []) if isinstance(llm, dict) else []
if not title:
raise HTTPException(status_code=502, detail="Chat API returned empty title")
if use_title_cache and title:
try:
TopicTitleCache.upsert(conn, cluster_hash, str(title), list(keywords or []), str(title_model))
conn.commit()
except Exception:
pass
out_clusters.append(
{
"id": f"topic_{cidx}",
"title": title,
"keywords": keywords,
"size": len(paths),
"paths": paths,
"sample_prompt": _clean_for_title(rep)[:200],
}
)
out_clusters.sort(key=lambda x: x["size"], reverse=True)
return {
"folder": folder,
"folders": folders,
"model": model,
"threshold": threshold,
"min_cluster_size": min_cluster_size,
"clusters": out_clusters,
"noise": noise,
"count": len(items),
"title_model": title_model,
}

View File

@ -1 +1,5 @@
<<<<<<<< HEAD:vue/dist/assets/Checkbox-bbe5a1a5.js
import{d as E,bC as $,r as f,m as M,_ as T,a as c,an as W,h as m,c as v,P as z}from"./index-5ed9cd5a.js";var G=["prefixCls","name","id","type","disabled","readonly","tabindex","autofocus","value","required"],H={prefixCls:String,name:String,id:String,type:String,defaultChecked:{type:[Boolean,Number],default:void 0},checked:{type:[Boolean,Number],default:void 0},disabled:Boolean,tabindex:{type:[Number,String]},readonly:Boolean,autofocus:Boolean,value:z.any,required:Boolean};const L=E({compatConfig:{MODE:3},name:"Checkbox",inheritAttrs:!1,props:$(H,{prefixCls:"rc-checkbox",type:"checkbox",defaultChecked:!1}),emits:["click","change"],setup:function(a,d){var t=d.attrs,h=d.emit,g=d.expose,o=f(a.checked===void 0?a.defaultChecked:a.checked),i=f();M(function(){return a.checked},function(){o.value=a.checked}),g({focus:function(){var e;(e=i.value)===null||e===void 0||e.focus()},blur:function(){var e;(e=i.value)===null||e===void 0||e.blur()}});var l=f(),x=function(e){if(!a.disabled){a.checked===void 0&&(o.value=e.target.checked),e.shiftKey=l.value;var r={target:c(c({},a),{},{checked:e.target.checked}),stopPropagation:function(){e.stopPropagation()},preventDefault:function(){e.preventDefault()},nativeEvent:e};a.checked!==void 0&&(i.value.checked=!!a.checked),h("change",r),l.value=!1}},C=function(e){h("click",e),l.value=e.shiftKey};return function(){var n,e=a.prefixCls,r=a.name,s=a.id,p=a.type,b=a.disabled,K=a.readonly,P=a.tabindex,B=a.autofocus,S=a.value,N=a.required,_=T(a,G),q=t.class,D=t.onFocus,j=t.onBlur,w=t.onKeydown,A=t.onKeypress,F=t.onKeyup,y=c(c({},_),t),O=Object.keys(y).reduce(function(k,u){return(u.substr(0,5)==="aria-"||u.substr(0,5)==="data-"||u==="role")&&(k[u]=y[u]),k},{}),R=W(e,q,(n={},m(n,"".concat(e,"-checked"),o.value),m(n,"".concat(e,"-disabled"),b),n)),V=c(c({name:r,id:s,type:p,readonly:K,disabled:b,tabindex:P,class:"".concat(e,"-input"),checked:!!o.value,autofocus:B,value:S},O),{},{onChange:x,onClick:C,onFocus:D,onBlur:j,onKeydown:w,onKeypress:A,onKeyup:F,required:N});return v("span",{class:R},[v("input",c({ref:i},V),null),v("span",{class:"".concat(e,"-inner")},null)])}}});export{L as V};
========
import{d as E,bC as $,r as f,m as M,_ as T,a as c,an as W,h as m,c as v,P as z}from"./index-db391c6a.js";var G=["prefixCls","name","id","type","disabled","readonly","tabindex","autofocus","value","required"],H={prefixCls:String,name:String,id:String,type:String,defaultChecked:{type:[Boolean,Number],default:void 0},checked:{type:[Boolean,Number],default:void 0},disabled:Boolean,tabindex:{type:[Number,String]},readonly:Boolean,autofocus:Boolean,value:z.any,required:Boolean};const L=E({compatConfig:{MODE:3},name:"Checkbox",inheritAttrs:!1,props:$(H,{prefixCls:"rc-checkbox",type:"checkbox",defaultChecked:!1}),emits:["click","change"],setup:function(a,d){var t=d.attrs,h=d.emit,g=d.expose,o=f(a.checked===void 0?a.defaultChecked:a.checked),i=f();M(function(){return a.checked},function(){o.value=a.checked}),g({focus:function(){var e;(e=i.value)===null||e===void 0||e.focus()},blur:function(){var e;(e=i.value)===null||e===void 0||e.blur()}});var l=f(),x=function(e){if(!a.disabled){a.checked===void 0&&(o.value=e.target.checked),e.shiftKey=l.value;var r={target:c(c({},a),{},{checked:e.target.checked}),stopPropagation:function(){e.stopPropagation()},preventDefault:function(){e.preventDefault()},nativeEvent:e};a.checked!==void 0&&(i.value.checked=!!a.checked),h("change",r),l.value=!1}},C=function(e){h("click",e),l.value=e.shiftKey};return function(){var n,e=a.prefixCls,r=a.name,s=a.id,p=a.type,b=a.disabled,K=a.readonly,P=a.tabindex,B=a.autofocus,S=a.value,N=a.required,_=T(a,G),q=t.class,D=t.onFocus,j=t.onBlur,w=t.onKeydown,A=t.onKeypress,F=t.onKeyup,y=c(c({},_),t),O=Object.keys(y).reduce(function(k,u){return(u.substr(0,5)==="aria-"||u.substr(0,5)==="data-"||u==="role")&&(k[u]=y[u]),k},{}),R=W(e,q,(n={},m(n,"".concat(e,"-checked"),o.value),m(n,"".concat(e,"-disabled"),b),n)),V=c(c({name:r,id:s,type:p,readonly:K,disabled:b,tabindex:P,class:"".concat(e,"-input"),checked:!!o.value,autofocus:B,value:S},O),{},{onChange:x,onClick:C,onFocus:D,onBlur:j,onKeydown:w,onKeypress:A,onKeyup:F,required:N});return v("span",{class:R},[v("input",c({ref:i},V),null),v("span",{class:"".concat(e,"-inner")},null)])}}});export{L as V};
>>>>>>>> 98a59b9 (Revert "Revert "feat: experimental natural-language categorization & search (persistent scope, clustering, retrieval)""):vue/dist/assets/Checkbox-be055c11.js

1
vue/dist/assets/FileItem-6eff082d.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
vue/dist/assets/FileItem-9be5bb5d.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{d as a,U as t,V as s,c as n,cI as _,a0 as c}from"./index-db391c6a.js";const o={class:"img-sli-container"},i=a({__name:"ImgSliPagePane",props:{paneIdx:{},tabIdx:{},left:{},right:{}},setup(l){return(e,r)=>(t(),s("div",o,[n(_,{left:e.left,right:e.right},null,8,["left","right"])]))}});const p=c(i,[["__scopeId","data-v-ec71de83"]]);export{p as default};

View File

@ -0,0 +1 @@
.img-sli-container[data-v-ec71de83]{position:relative;overflow-y:auto;height:calc(100vh - 40px)}

View File

@ -1 +0,0 @@
.img-sli-container[data-v-ae3fb9a8]{position:relative;overflow-y:auto;height:calc(100vh - 40px)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.container[data-v-6e837a6f]{background:var(--zp-secondary-background);position:relative}.container .action-bar[data-v-6e837a6f]{display:flex;align-items:center;user-select:none;gap:4px;padding:4px}.container .action-bar>*[data-v-6e837a6f]{flex-wrap:wrap}.container .file-list[data-v-6e837a6f]{list-style:none;padding:8px;overflow:auto;height:calc(var(--pane-max-height) - 40px);width:100%}.container .no-res-hint[data-v-6e837a6f]{height:var(--pane-max-height);display:flex;align-items:center;flex-direction:column;justify-content:center}.container .no-res-hint .hint[data-v-6e837a6f]{font-size:1.6em;margin-bottom:2em;text-align:center}

View File

@ -0,0 +1 @@
.container[data-v-795e5ef4]{background:var(--zp-secondary-background);position:relative;height:var(--pane-max-height)}.action-bar[data-v-795e5ef4]{display:flex;align-items:center;user-select:none;gap:6px;padding:6px 8px}.title[data-v-795e5ef4]{font-weight:700;max-width:40vw}.file-list[data-v-795e5ef4]{list-style:none;padding:8px;overflow:auto;height:calc(var(--pane-max-height) - 44px);width:100%}.no-res-hint[data-v-795e5ef4]{height:calc(var(--pane-max-height) - 44px);display:flex;align-items:center;flex-direction:column;justify-content:center}.no-res-hint .hint[data-v-795e5ef4]{font-size:1.2em;opacity:.7}.preview-switch[data-v-795e5ef4]{position:fixed;bottom:24px;right:24px;display:flex;gap:8px;font-size:36px;user-select:none}.disable[data-v-795e5ef4]{opacity:.3;pointer-events:none}

View File

@ -1 +0,0 @@
.container[data-v-4815fec6]{background:var(--zp-secondary-background);position:relative}.container .action-bar[data-v-4815fec6]{display:flex;align-items:center;user-select:none;gap:4px;padding:4px}.container .action-bar>*[data-v-4815fec6]{flex-wrap:wrap}.container .file-list[data-v-4815fec6]{list-style:none;padding:8px;overflow:auto;height:calc(var(--pane-max-height) - 40px);width:100%}.container .no-res-hint[data-v-4815fec6]{height:var(--pane-max-height);display:flex;align-items:center;flex-direction:column;justify-content:center}.container .no-res-hint .hint[data-v-4815fec6]{font-size:1.6em;margin-bottom:2em;text-align:center}

View File

@ -1 +0,0 @@
.full-screen-menu[data-v-50c80b83]{position:fixed;z-index:9999;background:var(--zp-primary-background);padding:8px 16px;box-shadow:0 0 4px var(--zp-secondary);border-radius:4px}.full-screen-menu .tags-container[data-v-50c80b83]{margin:4px 0}.full-screen-menu .tags-container .tag[data-v-50c80b83]{margin-right:4px;margin-bottom:4px;padding:2px 16px;border-radius:4px;display:inline-block;cursor:pointer;font-weight:700;transition:.5s all ease;border:2px solid var(--tag-color);color:var(--tag-color);background:var(--zp-primary-background);user-select:none}.full-screen-menu .tags-container .tag.selected[data-v-50c80b83]{background:var(--tag-color);color:#fff}.full-screen-menu .container[data-v-50c80b83]{height:100%;display:flex;overflow:hidden;flex-direction:column}.full-screen-menu .gen-info[data-v-50c80b83]{flex:1;word-break:break-all;white-space:pre-line;overflow:auto;z-index:1;padding-top:4px;position:relative}.full-screen-menu .gen-info code[data-v-50c80b83]{font-size:.9em;display:block;padding:4px;background:var(--zp-primary-background);border-radius:4px;margin-right:20px;white-space:pre-wrap;word-break:break-word;line-height:1.78em}.full-screen-menu .gen-info code[data-v-50c80b83] .natural-text{margin:.5em 0;line-height:1.6em;text-align:justify;color:var(--zp-primary)}.full-screen-menu .gen-info code[data-v-50c80b83] .short-tag{word-break:break-all;white-space:nowrap}.full-screen-menu .gen-info code[data-v-50c80b83] span.tag{background:var(--zp-secondary-variant-background);color:var(--zp-primary);padding:2px 4px;border-radius:6px;margin-right:6px;margin-top:4px;line-height:1.3em;display:inline-block}.full-screen-menu .gen-info code[data-v-50c80b83] .has-parentheses.tag{background:rgba(255,100,100,.14)}.full-screen-menu .gen-info code[data-v-50c80b83] span.tag:hover{background:rgba(120,0,0,.15)}.full-screen-menu .gen-info table[data-v-50c80b83]{font-size:1em;border-radius:4px;border-collapse:separate;margin-bottom:3em}.full-screen-menu .gen-info table tr td[data-v-50c80b83]:first-child{white-space:nowrap}.full-screen-menu .gen-info table td[data-v-50c80b83]{padding-right:14px;padding-left:4px;border-bottom:1px solid var(--zp-secondary);border-collapse:collapse}.full-screen-menu .gen-info .info-tags .info-tag[data-v-50c80b83]{display:inline-block;overflow:hidden;border-radius:4px;margin-right:8px;border:2px solid var(--zp-primary)}.full-screen-menu .gen-info .info-tags .name[data-v-50c80b83]{background-color:var(--zp-primary);color:var(--zp-primary-background);padding:4px;border-bottom-right-radius:4px}.full-screen-menu .gen-info .info-tags .value[data-v-50c80b83]{padding:4px}.full-screen-menu.unset-size[data-v-50c80b83]{width:unset!important;height:unset!important}.full-screen-menu .mouse-sensor[data-v-50c80b83]{position:absolute;bottom:0;right:0;transform:rotate(90deg);cursor:se-resize;z-index:1;background:var(--zp-primary-background);border-radius:2px}.full-screen-menu .mouse-sensor>*[data-v-50c80b83]{font-size:18px;padding:4px}.full-screen-menu .action-bar[data-v-50c80b83]{display:flex;align-items:center;user-select:none;gap:4px}.full-screen-menu .action-bar .icon[data-v-50c80b83]{font-size:1.5em;padding:2px 4px;border-radius:4px}.full-screen-menu .action-bar .icon[data-v-50c80b83]:hover{background:var(--zp-secondary-variant-background)}.full-screen-menu .action-bar>*[data-v-50c80b83]{flex-wrap:wrap}.full-screen-menu.lr[data-v-50c80b83]{top:var(--3eebcda7)!important;right:0!important;bottom:0!important;left:100vw!important;height:unset!important;width:var(--3e27f0da)!important;transition:left ease .3s}.full-screen-menu.lr.always-on[data-v-50c80b83],.full-screen-menu.lr.mouse-in[data-v-50c80b83]{left:var(--37c6591e)!important}.tag-alpha-item[data-v-50c80b83]{display:flex;margin-top:4px}.tag-alpha-item h4[data-v-50c80b83]{width:32px;flex-shrink:0}.sort-tag-switch[data-v-50c80b83]{display:inline-block;padding-right:16px;padding-left:8px;cursor:pointer;user-select:none}.sort-tag-switch span[data-v-50c80b83]{transition:all ease .3s;transform:scale(1.2)}.sort-tag-switch:hover span[data-v-50c80b83]{transform:scale(1.3)}.lr-layout-control[data-v-50c80b83]{display:flex;align-items:center;gap:16px;padding:4px 8px;flex-wrap:wrap;border-radius:2px;border-left:3px solid var(--zp-luminous);background-color:var(--zp-secondary-background)}.lr-layout-control .ctrl-item[data-v-50c80b83]{display:flex;align-items:center;gap:4px;flex-wrap:nowrap}.select-actions[data-v-b04c3508]>:not(:last-child){margin-right:4px}.float-panel[data-v-b04c3508]{position:absolute;bottom:32px;right:32px;background:var(--zp-primary-background);border-radius:4px;z-index:1000;padding:8px;box-shadow:0 0 4px var(--zp-secondary)}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.full-screen-menu[data-v-4fda442c]{position:fixed;z-index:9999;background:var(--zp-primary-background);padding:8px 16px;box-shadow:0 0 4px var(--zp-secondary);border-radius:4px}.full-screen-menu .tags-container[data-v-4fda442c]{margin:4px 0}.full-screen-menu .tags-container .tag[data-v-4fda442c]{margin-right:4px;margin-bottom:4px;padding:2px 16px;border-radius:4px;display:inline-block;cursor:pointer;font-weight:700;transition:.5s all ease;border:2px solid var(--tag-color);color:var(--tag-color);background:var(--zp-primary-background);user-select:none}.full-screen-menu .tags-container .tag.selected[data-v-4fda442c]{background:var(--tag-color);color:#fff}.full-screen-menu .container[data-v-4fda442c]{height:100%;display:flex;overflow:hidden;flex-direction:column}.full-screen-menu .gen-info[data-v-4fda442c]{flex:1;word-break:break-all;white-space:pre-line;overflow:auto;z-index:1;padding-top:4px;position:relative}.full-screen-menu .gen-info code[data-v-4fda442c]{font-size:.9em;display:block;padding:4px;background:var(--zp-primary-background);border-radius:4px;margin-right:20px;white-space:pre-wrap;word-break:break-word;line-height:1.78em}.full-screen-menu .gen-info code[data-v-4fda442c] .natural-text{margin:.5em 0;line-height:1.6em;text-align:justify;color:var(--zp-primary)}.full-screen-menu .gen-info code[data-v-4fda442c] .short-tag{word-break:break-all;white-space:nowrap}.full-screen-menu .gen-info code[data-v-4fda442c] span.tag{background:var(--zp-secondary-variant-background);color:var(--zp-primary);padding:2px 4px;border-radius:6px;margin-right:6px;margin-top:4px;line-height:1.3em;display:inline-block}.full-screen-menu .gen-info code[data-v-4fda442c] .has-parentheses.tag{background:rgba(255,100,100,.14)}.full-screen-menu .gen-info code[data-v-4fda442c] span.tag:hover{background:rgba(120,0,0,.15)}.full-screen-menu .gen-info table[data-v-4fda442c]{font-size:1em;border-radius:4px;border-collapse:separate;margin-bottom:3em}.full-screen-menu .gen-info table tr td[data-v-4fda442c]:first-child{white-space:nowrap;vertical-align:top}.full-screen-menu .gen-info table.extra-meta-table .extra-meta-value[data-v-4fda442c]{display:block;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:.85em;background:var(--zp-secondary-variant-background);padding:8px;border-radius:4px}.full-screen-menu .gen-info table td[data-v-4fda442c]{padding-right:14px;padding-left:4px;border-bottom:1px solid var(--zp-secondary);border-collapse:collapse}.full-screen-menu .gen-info .info-tags .info-tag[data-v-4fda442c]{display:inline-block;overflow:hidden;border-radius:4px;margin-right:8px;border:2px solid var(--zp-primary)}.full-screen-menu .gen-info .info-tags .name[data-v-4fda442c]{background-color:var(--zp-primary);color:var(--zp-primary-background);padding:4px;border-bottom-right-radius:4px}.full-screen-menu .gen-info .info-tags .value[data-v-4fda442c]{padding:4px}.full-screen-menu.unset-size[data-v-4fda442c]{width:unset!important;height:unset!important}.full-screen-menu .mouse-sensor[data-v-4fda442c]{position:absolute;bottom:0;right:0;transform:rotate(90deg);cursor:se-resize;z-index:1;background:var(--zp-primary-background);border-radius:2px}.full-screen-menu .mouse-sensor>*[data-v-4fda442c]{font-size:18px;padding:4px}.full-screen-menu .action-bar[data-v-4fda442c]{display:flex;align-items:center;user-select:none;gap:4px}.full-screen-menu .action-bar .icon[data-v-4fda442c]{font-size:1.5em;padding:2px 4px;border-radius:4px}.full-screen-menu .action-bar .icon[data-v-4fda442c]:hover{background:var(--zp-secondary-variant-background)}.full-screen-menu .action-bar>*[data-v-4fda442c]{flex-wrap:wrap}.full-screen-menu.lr[data-v-4fda442c]{top:var(--6e46b464)!important;right:0!important;bottom:0!important;left:100vw!important;height:unset!important;width:var(--02641c57)!important;transition:left ease .3s}.full-screen-menu.lr.always-on[data-v-4fda442c],.full-screen-menu.lr.mouse-in[data-v-4fda442c]{left:var(--c403b44a)!important}.tag-alpha-item[data-v-4fda442c]{display:flex;margin-top:4px}.tag-alpha-item h4[data-v-4fda442c]{width:32px;flex-shrink:0}.sort-tag-switch[data-v-4fda442c]{display:inline-block;padding-right:16px;padding-left:8px;cursor:pointer;user-select:none}.sort-tag-switch span[data-v-4fda442c]{transition:all ease .3s;transform:scale(1.2)}.sort-tag-switch:hover span[data-v-4fda442c]{transform:scale(1.3)}.lr-layout-control[data-v-4fda442c]{display:flex;align-items:center;gap:16px;padding:4px 8px;flex-wrap:wrap;border-radius:2px;border-left:3px solid var(--zp-luminous);background-color:var(--zp-secondary-background)}.lr-layout-control .ctrl-item[data-v-4fda442c]{display:flex;align-items:center;gap:4px;flex-wrap:nowrap}.select-actions[data-v-b6f9a67c]>:not(:last-child){margin-right:4px}.float-panel[data-v-b6f9a67c]{position:absolute;bottom:32px;right:32px;background:var(--zp-primary-background);border-radius:4px;z-index:1000;padding:8px;box-shadow:0 0 4px var(--zp-secondary)}

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
[data-v-e1bd92bd] .float-panel{position:fixed}.regex-icon[data-v-e1bd92bd]{user-select:none;padding:4px;margin:0 4px;cursor:pointer;border:1px solid var(--zp-border);border-radius:4px}.regex-icon img[data-v-e1bd92bd]{height:1.5em}.regex-icon[data-v-e1bd92bd]:hover{background:var(--zp-border)}.regex-icon.selected[data-v-e1bd92bd]{background:var(--primary-color-1);border:1px solid var(--primary-color)}.search-bar[data-v-e1bd92bd]{padding:8px 8px 0;display:flex}.search-bar.last[data-v-e1bd92bd]{padding-bottom:8px}.search-bar .form-name[data-v-e1bd92bd]{flex-shrink:0;padding:4px 8px}.search-bar .actions>*[data-v-e1bd92bd]{margin-right:4px}.container[data-v-e1bd92bd]{background:var(--zp-secondary-background);position:relative}.container .file-list[data-v-e1bd92bd]{list-style:none;padding:8px;height:100%;overflow:auto;height:var(--pane-max-height);width:100%}

View File

@ -0,0 +1 @@
[data-v-2a7f2e56] .float-panel{position:fixed}.regex-icon[data-v-2a7f2e56]{user-select:none;padding:4px;margin:0 4px;cursor:pointer;border:1px solid var(--zp-border);border-radius:4px}.regex-icon img[data-v-2a7f2e56]{height:1.5em}.regex-icon[data-v-2a7f2e56]:hover{background:var(--zp-border)}.regex-icon.selected[data-v-2a7f2e56]{background:var(--primary-color-1);border:1px solid var(--primary-color)}.search-bar[data-v-2a7f2e56]{padding:8px 8px 0;display:flex}.search-bar.last[data-v-2a7f2e56]{padding-bottom:8px}.search-bar .form-name[data-v-2a7f2e56]{flex-shrink:0;padding:4px 8px}.search-bar .actions>*[data-v-2a7f2e56]{margin-right:4px}.container[data-v-2a7f2e56]{background:var(--zp-secondary-background);position:relative}.container .file-list[data-v-2a7f2e56]{list-style:none;padding:8px;height:100%;overflow:auto;height:var(--pane-max-height);width:100%}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.topic-search[data-v-39216928]{height:var(--pane-max-height);overflow:auto;padding:12px}.toolbar[data-v-39216928]{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px;background:var(--zp-primary-background);border-radius:12px}.left[data-v-39216928]{display:flex;align-items:center;gap:8px}.title[data-v-39216928]{display:flex;align-items:center;gap:8px;font-weight:700}.right[data-v-39216928]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.label[data-v-39216928]{color:#888}.guide[data-v-39216928]{display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:var(--zp-primary-background);border-radius:12px;border:1px solid rgba(0,0,0,.06)}.guide-row[data-v-39216928]{display:flex;align-items:center;gap:10px}.guide-hint[data-v-39216928]{margin-top:4px;display:flex;align-items:center;gap:10px;opacity:.85}.guide-icon[data-v-39216928]{width:22px;text-align:center;flex:0 0 22px}.guide-text[data-v-39216928]{flex:1 1 auto;min-width:0;color:#000000bf}.grid[data-v-39216928]{margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}.card[data-v-39216928]{background:var(--zp-primary-background);border-radius:12px;padding:10px 12px;cursor:pointer;border:1px solid rgba(0,0,0,.06)}.card[data-v-39216928]:hover{border-color:#1890ff99}.card-top[data-v-39216928]{display:flex;align-items:center;justify-content:space-between;gap:8px}.card-title[data-v-39216928]{font-weight:700}.card-count[data-v-39216928]{min-width:28px;text-align:right;opacity:.75}.card-desc[data-v-39216928]{margin-top:6px;color:#666;font-size:12px}.empty[data-v-39216928]{height:calc(var(--pane-max-height) - 72px);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px}.hint[data-v-39216928]{font-size:16px;opacity:.75}

View File

@ -1 +1,5 @@
<<<<<<<< HEAD:vue/dist/assets/_isIterateeCall-cd370691.js
import{bU as i,b1 as t,e0 as f,bL as n}from"./index-5ed9cd5a.js";function u(e,s,r){if(!i(r))return!1;var a=typeof s;return(a=="number"?t(r)&&f(s,r.length):a=="string"&&s in r)?n(r[s],e):!1}export{u as i};
========
import{bV as i,b1 as t,e0 as f,bM as n}from"./index-db391c6a.js";function u(e,s,r){if(!i(r))return!1;var a=typeof s;return(a=="number"?t(r)&&f(s,r.length):a=="string"&&s in r)?n(r[s],e):!1}export{u as i};
>>>>>>>> 98a59b9 (Revert "Revert "feat: experimental natural-language categorization & search (persistent scope, clustering, retrieval)""):vue/dist/assets/_isIterateeCall-6ab5736a.js

View File

@ -0,0 +1 @@
import{d as F,a1 as B,cJ as $,cc as S,U as _,V as w,W as f,c as l,a3 as d,X as p,Y as c,a4 as s,a2 as A,af as R,cK as V,cL as y,z as x,B as E,ak as T,a0 as U}from"./index-db391c6a.js";import{_ as L}from"./index-30c22d1a.js";import{u as N,a as H,f as O,F as W,d as j}from"./FileItem-9be5bb5d.js";import"./numInput.vue_vue_type_style_index_0_scoped_bd954eda_lang-28fed536.js";/* empty css */import"./index-4a1bd1ce.js";import"./_isIterateeCall-6ab5736a.js";import"./index-ae90fb7e.js";import"./index-83d83387.js";const q={class:"actions-panel actions"},G={class:"item"},J={key:0,class:"file-list"},K={class:"hint"},P=F({__name:"batchDownload",props:{tabIdx:{},paneIdx:{},id:{}},setup(Q){const{stackViewEl:D}=N().toRefs(),{itemSize:h,gridItems:b,cellWidth:g}=H(),i=B(),m=O(),{selectdFiles:a}=$(m),r=S(),v=async e=>{const t=V(e);t&&m.addFiles(t.nodes)},C=async()=>{r.pushAction(async()=>{const e=await y.value.post("/zip",{paths:a.value.map(u=>u.fullpath),compress:i.batchDownloadCompress,pack_only:!1},{responseType:"blob"}),t=window.URL.createObjectURL(new Blob([e.data])),o=document.createElement("a");o.href=t,o.setAttribute("download",`iib_${new Date().toLocaleString()}.zip`),document.body.appendChild(o),o.click()})},I=async()=>{r.pushAction(async()=>{await y.value.post("/zip",{paths:a.value.map(e=>e.fullpath),compress:i.batchDownloadCompress,pack_only:!0},{responseType:"blob"}),x.success(E("success"))})},z=e=>{a.value.splice(e,1)};return(e,t)=>{const o=T,u=L;return _(),w("div",{class:"container",ref_key:"stackViewEl",ref:D,onDrop:v},[f("div",q,[l(o,{onClick:t[0]||(t[0]=n=>s(m).selectdFiles=[])},{default:d(()=>[p(c(e.$t("clear")),1)]),_:1}),f("div",G,[p(c(e.$t("compressFile"))+": ",1),l(u,{checked:s(i).batchDownloadCompress,"onUpdate:checked":t[1]||(t[1]=n=>s(i).batchDownloadCompress=n)},null,8,["checked"])]),l(o,{onClick:I,type:"primary",loading:!s(r).isIdle},{default:d(()=>[p(c(e.$t("packOnlyNotDownload")),1)]),_:1},8,["loading"]),l(o,{onClick:C,type:"primary",loading:!s(r).isIdle},{default:d(()=>[p(c(e.$t("zipDownload")),1)]),_:1},8,["loading"])]),s(a).length?(_(),A(s(j),{key:1,ref:"scroller",class:"file-list",items:s(a).slice(),"item-size":s(h).first,"key-field":"fullpath","item-secondary-size":s(h).second,gridItems:s(b)},{default:d(({item:n,index:k})=>[l(W,{idx:k,file:n,"cell-width":s(g),"enable-close-icon":"",onCloseIconClick:X=>z(k),"full-screen-preview-image-url":s(R)(n),"enable-right-click-menu":!1},null,8,["idx","file","cell-width","onCloseIconClick","full-screen-preview-image-url"])]),_:1},8,["items","item-size","item-secondary-size","gridItems"])):(_(),w("div",J,[f("p",K,c(e.$t("batchDownloaDDragAndDropHint")),1)]))],544)}}});const le=U(P,[["__scopeId","data-v-3d7e6f2d"]]);export{le as default};

View File

@ -1 +0,0 @@
.container[data-v-a2642a17]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-a2642a17]{padding:8px;background-color:var(--zp-primary-background)}.container .actions-panel.actions[data-v-a2642a17]{display:flex;align-items:center;gap:16px;z-index:333}.container .file-list[data-v-a2642a17]{flex:1;z-index:222;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-a2642a17]{text-align:center;font-size:2em;padding:30vh 128px 0}

View File

@ -0,0 +1 @@
.container[data-v-3d7e6f2d]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-3d7e6f2d]{padding:8px;background-color:var(--zp-primary-background)}.container .actions-panel.actions[data-v-3d7e6f2d]{display:flex;align-items:center;gap:16px;z-index:333}.container .file-list[data-v-3d7e6f2d]{flex:1;z-index:222;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-3d7e6f2d]{text-align:center;font-size:2em;padding:30vh 128px 0}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
vue/dist/assets/gridView-7100baf1.css vendored Normal file
View File

@ -0,0 +1 @@
.container[data-v-0c31f6b2]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-0c31f6b2]{padding:8px;background-color:var(--zp-primary-background)}.container .file-list[data-v-0c31f6b2]{flex:1;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-0c31f6b2]{text-align:center;font-size:2em;padding:30vh 128px 0}

1
vue/dist/assets/gridView-acf550fa.js vendored Normal file
View File

@ -0,0 +1 @@
import{u as w,a as y,F as b,d as k}from"./FileItem-9be5bb5d.js";import{d as x,a1 as h,c9 as F,r as D,bh as I,bl as C,U as V,V as E,c as n,a3 as z,a4 as e,af as S,cK as B,cM as K,a0 as R}from"./index-db391c6a.js";import"./numInput.vue_vue_type_style_index_0_scoped_bd954eda_lang-28fed536.js";/* empty css */import"./index-4a1bd1ce.js";import"./_isIterateeCall-6ab5736a.js";import"./index-ae90fb7e.js";import"./index-83d83387.js";const A=x({__name:"gridView",props:{tabIdx:{},paneIdx:{},id:{},removable:{type:Boolean},allowDragAndDrop:{type:Boolean},files:{},paneKey:{}},setup(p){const o=p,m=h(),{stackViewEl:d}=w().toRefs(),{itemSize:i,gridItems:u,cellWidth:f}=y(),g=F(),a=D(o.files??[]),_=async s=>{const l=B(s);o.allowDragAndDrop&&l&&(a.value=K([...a.value,...l.nodes]))},v=s=>{a.value.splice(s,1)};return I(()=>{m.pageFuncExportMap.set(o.paneKey,{getFiles:()=>C(a.value),setFiles:s=>a.value=s})}),(s,l)=>(V(),E("div",{class:"container",ref_key:"stackViewEl",ref:d,onDrop:_},[n(e(k),{ref:"scroller",class:"file-list",items:a.value.slice(),"item-size":e(i).first,"key-field":"fullpath","item-secondary-size":e(i).second,gridItems:e(u)},{default:z(({item:t,index:r})=>{var c;return[n(b,{idx:r,file:t,"cell-width":e(f),"enable-close-icon":o.removable,onCloseIconClick:M=>v(r),"full-screen-preview-image-url":e(S)(t),"extra-tags":(c=t==null?void 0:t.tags)==null?void 0:c.map(e(g).tagConvert),"enable-right-click-menu":!1},null,8,["idx","file","cell-width","enable-close-icon","onCloseIconClick","full-screen-preview-image-url","extra-tags"])]}),_:1},8,["items","item-size","item-secondary-size","gridItems"])],544))}});const j=R(A,[["__scopeId","data-v-0c31f6b2"]]);export{j as default};

View File

@ -1 +0,0 @@
.container[data-v-f35f4802]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-f35f4802]{padding:8px;background-color:var(--zp-primary-background)}.container .file-list[data-v-f35f4802]{flex:1;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-f35f4802]{text-align:center;font-size:2em;padding:30vh 128px 0}

View File

@ -1 +1,5 @@
<<<<<<<< HEAD:vue/dist/assets/hook-d4530521.js
import{am as F,r as g,l as P,k as A,O as b,G as R,cb as q,cm as O,cr as z}from"./index-5ed9cd5a.js";import{u as L,a as Q,b as j,e as H}from"./FileItem-2ecfe4d5.js";import{a as T,b as U,c as W}from"./MultiSelectKeep-68ce9bb5.js";import{u as B}from"./useGenInfoDiff-b31dce1f.js";let K=0;const V=()=>++K,X=(n,i,{dataUpdateStrategy:l="replace"}={})=>{const o=F([""]),c=g(!1),t=g(),a=g(!1);let f=g(-1);const v=new Set,w=e=>{var s;l==="replace"?t.value=e:l==="merge"&&(b((Array.isArray(t.value)||typeof t.value>"u")&&Array.isArray(e),"数据更新策略为合并时仅可用于值为数组的情况"),t.value=[...(s=t==null?void 0:t.value)!==null&&s!==void 0?s:[],...e])},d=e=>A(void 0,void 0,void 0,function*(){if(a.value||c.value&&typeof e>"u")return!1;a.value=!0;const s=V();f.value=s;try{let r;if(typeof e=="number"){if(r=o[e],typeof r!="string")return!1}else r=o[o.length-1];const h=yield n(r);if(v.has(s))return v.delete(s),!1;w(i(h));const u=h.cursor;if((e===o.length-1||typeof e!="number")&&(c.value=!u.has_next,u.has_next)){const m=u.next_cursor||u.next;b(typeof m=="string"),o.push(m)}}finally{f.value===s&&(a.value=!1)}return!0}),p=()=>{v.add(f.value),a.value=!1},x=(e=!1)=>A(void 0,void 0,void 0,function*(){const{refetch:s,force:r}=typeof e=="object"?e:{refetch:e};r&&p(),b(!a.value),o.splice(0,o.length,""),a.value=!1,t.value=void 0,c.value=!1,s&&(yield d())}),I=()=>({next:()=>A(void 0,void 0,void 0,function*(){if(a.value)throw new Error("不允许同时迭代");return{done:!(yield d()),value:t.value}})});return P({abort:p,load:c,next:d,res:t,loading:a,cursorStack:o,reset:x,[Symbol.asyncIterator]:I,iter:{[Symbol.asyncIterator]:I}})},se=n=>F(X(n,i=>i.files,{dataUpdateStrategy:"merge"})),ne=n=>{const i=F(new Set),l=R(()=>(n.res??[]).filter(y=>!i.has(y.fullpath))),o=q(),{stackViewEl:c,multiSelectedIdxs:t,stack:a,scroller:f,props:v}=L({images:l}).toRefs(),{itemSize:w,gridItems:d,cellWidth:p,onScroll:x}=Q({fetchNext:()=>n.next()}),{showMenuIdx:I}=j(),{onFileDragStart:e,onFileDragEnd:s}=T(),{showGenInfo:r,imageGenInfo:h,q:u,onContextMenuClick:m,onFileItemClick:C}=U({openNext:O}),{previewIdx:_,previewing:E,onPreviewVisibleChange:M,previewImgMove:D,canPreview:G}=W({loadNext:()=>n.next()}),J=async(y,S,N)=>{a.value=[{curr:"",files:l.value}],await m(y,S,N)};H("removeFiles",async({paths:y})=>{y.forEach(S=>i.add(S))});const k=()=>{z(l.value)};return{images:l,scroller:f,queue:o,iter:n,onContextMenuClickU:J,stackViewEl:c,previewIdx:_,previewing:E,onPreviewVisibleChange:M,previewImgMove:D,canPreview:G,itemSize:w,gridItems:d,showGenInfo:r,imageGenInfo:h,q:u,onContextMenuClick:m,onFileItemClick:C,showMenuIdx:I,multiSelectedIdxs:t,onFileDragStart:e,onFileDragEnd:s,cellWidth:p,onScroll:x,saveLoadedFileAsJson:k,saveAllFileAsJson:async()=>{for(;!n.load;)await n.next();k()},props:v,...B()}};export{se as c,ne as u};
========
import{am as F,r as g,l as P,k as A,O as b,G as R,cc as q,cn as O,cs as z}from"./index-db391c6a.js";import{u as L,a as Q,b as j,e as H}from"./FileItem-9be5bb5d.js";import{a as T,b as U,c as W}from"./MultiSelectKeep-cd70772d.js";import{u as B}from"./useGenInfoDiff-9f58ef19.js";let K=0;const V=()=>++K,X=(n,i,{dataUpdateStrategy:l="replace"}={})=>{const o=F([""]),c=g(!1),t=g(),a=g(!1);let f=g(-1);const v=new Set,w=e=>{var s;l==="replace"?t.value=e:l==="merge"&&(b((Array.isArray(t.value)||typeof t.value>"u")&&Array.isArray(e),"数据更新策略为合并时仅可用于值为数组的情况"),t.value=[...(s=t==null?void 0:t.value)!==null&&s!==void 0?s:[],...e])},d=e=>A(void 0,void 0,void 0,function*(){if(a.value||c.value&&typeof e>"u")return!1;a.value=!0;const s=V();f.value=s;try{let r;if(typeof e=="number"){if(r=o[e],typeof r!="string")return!1}else r=o[o.length-1];const h=yield n(r);if(v.has(s))return v.delete(s),!1;w(i(h));const u=h.cursor;if((e===o.length-1||typeof e!="number")&&(c.value=!u.has_next,u.has_next)){const m=u.next_cursor||u.next;b(typeof m=="string"),o.push(m)}}finally{f.value===s&&(a.value=!1)}return!0}),p=()=>{v.add(f.value),a.value=!1},x=(e=!1)=>A(void 0,void 0,void 0,function*(){const{refetch:s,force:r}=typeof e=="object"?e:{refetch:e};r&&p(),b(!a.value),o.splice(0,o.length,""),a.value=!1,t.value=void 0,c.value=!1,s&&(yield d())}),I=()=>({next:()=>A(void 0,void 0,void 0,function*(){if(a.value)throw new Error("不允许同时迭代");return{done:!(yield d()),value:t.value}})});return P({abort:p,load:c,next:d,res:t,loading:a,cursorStack:o,reset:x,[Symbol.asyncIterator]:I,iter:{[Symbol.asyncIterator]:I}})},se=n=>F(X(n,i=>i.files,{dataUpdateStrategy:"merge"})),ne=n=>{const i=F(new Set),l=R(()=>(n.res??[]).filter(y=>!i.has(y.fullpath))),o=q(),{stackViewEl:c,multiSelectedIdxs:t,stack:a,scroller:f,props:v}=L({images:l}).toRefs(),{itemSize:w,gridItems:d,cellWidth:p,onScroll:x}=Q({fetchNext:()=>n.next()}),{showMenuIdx:I}=j(),{onFileDragStart:e,onFileDragEnd:s}=T(),{showGenInfo:r,imageGenInfo:h,q:u,onContextMenuClick:m,onFileItemClick:C}=U({openNext:O}),{previewIdx:_,previewing:E,onPreviewVisibleChange:M,previewImgMove:D,canPreview:G}=W({loadNext:()=>n.next()}),J=async(y,S,N)=>{a.value=[{curr:"",files:l.value}],await m(y,S,N)};H("removeFiles",async({paths:y})=>{y.forEach(S=>i.add(S))});const k=()=>{z(l.value)};return{images:l,scroller:f,queue:o,iter:n,onContextMenuClickU:J,stackViewEl:c,previewIdx:_,previewing:E,onPreviewVisibleChange:M,previewImgMove:D,canPreview:G,itemSize:w,gridItems:d,showGenInfo:r,imageGenInfo:h,q:u,onContextMenuClick:m,onFileItemClick:C,showMenuIdx:I,multiSelectedIdxs:t,onFileDragStart:e,onFileDragEnd:s,cellWidth:p,onScroll:x,saveLoadedFileAsJson:k,saveAllFileAsJson:async()=>{for(;!n.load;)await n.next();k()},props:v,...B()}};export{se as c,ne as u};
>>>>>>>> 98a59b9 (Revert "Revert "feat: experimental natural-language categorization & search (persistent scope, clustering, retrieval)""):vue/dist/assets/hook-6746a807.js

1
vue/dist/assets/index-017a4092.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1 +1,5 @@
<<<<<<<< HEAD:vue/dist/assets/index-20b432d5.js
import{d as w,bC as A,av as D,cz as j,az as k,n as B,cA as V,cB as y,e as $,c as a,_ as T,h as r,a as P,cC as M,P as b}from"./index-5ed9cd5a.js";var O=["class","style"],W=function(){return{prefixCls:String,spinning:{type:Boolean,default:void 0},size:String,wrapperClassName:String,tip:b.any,delay:Number,indicator:b.any}},p=null;function q(t,n){return!!t&&!!n&&!isNaN(Number(n))}function G(t){var n=t.indicator;p=typeof n=="function"?n:function(){return a(n,null,null)}}const H=w({compatConfig:{MODE:3},name:"ASpin",inheritAttrs:!1,props:A(W(),{size:"default",spinning:!0,wrapperClassName:""}),setup:function(){return{originalUpdateSpinning:null,configProvider:D("configProvider",j)}},data:function(){var n=this.spinning,e=this.delay,i=q(n,e);return{sSpinning:n&&!i}},created:function(){this.originalUpdateSpinning=this.updateSpinning,this.debouncifyUpdateSpinning(this.$props)},mounted:function(){this.updateSpinning()},updated:function(){var n=this;k(function(){n.debouncifyUpdateSpinning(),n.updateSpinning()})},beforeUnmount:function(){this.cancelExistingSpin()},methods:{debouncifyUpdateSpinning:function(n){var e=n||this.$props,i=e.delay;i&&(this.cancelExistingSpin(),this.updateSpinning=B(this.originalUpdateSpinning,i))},updateSpinning:function(){var n=this.spinning,e=this.sSpinning;e!==n&&(this.sSpinning=n)},cancelExistingSpin:function(){var n=this.updateSpinning;n&&n.cancel&&n.cancel()},renderIndicator:function(n){var e="".concat(n,"-dot"),i=V(this,"indicator");return i===null?null:(Array.isArray(i)&&(i=i.length===1?i[0]:i),y(i)?$(i,{class:e}):p&&y(p())?$(p(),{class:e}):a("span",{class:"".concat(e," ").concat(n,"-dot-spin")},[a("i",{class:"".concat(n,"-dot-item")},null),a("i",{class:"".concat(n,"-dot-item")},null),a("i",{class:"".concat(n,"-dot-item")},null),a("i",{class:"".concat(n,"-dot-item")},null)]))}},render:function(){var n,e,i,o=this.$props,f=o.size,x=o.prefixCls,h=o.tip,d=h===void 0?(n=(e=this.$slots).tip)===null||n===void 0?void 0:n.call(e):h,C=o.wrapperClassName,l=this.$attrs,v=l.class,N=l.style,_=T(l,O),S=this.configProvider,U=S.getPrefixCls,z=S.direction,s=U("spin",x),u=this.sSpinning,E=(i={},r(i,s,!0),r(i,"".concat(s,"-sm"),f==="small"),r(i,"".concat(s,"-lg"),f==="large"),r(i,"".concat(s,"-spinning"),u),r(i,"".concat(s,"-show-text"),!!d),r(i,"".concat(s,"-rtl"),z==="rtl"),r(i,v,!!v),i),m=a("div",P(P({},_),{},{style:N,class:E}),[this.renderIndicator(s),d?a("div",{class:"".concat(s,"-text")},[d]):null]),g=M(this);if(g&&g.length){var c,I=(c={},r(c,"".concat(s,"-container"),!0),r(c,"".concat(s,"-blur"),u),c);return a("div",{class:["".concat(s,"-nested-loading"),C]},[u&&a("div",{key:"loading"},[m]),a("div",{class:I,key:"container"},[g])])}return m}});export{H as S,G as s};
========
import{d as w,bC as D,av as A,cE as j,az as k,n as V,cF as B,cG as y,e as $,c as a,_ as T,h as r,a as P,cH as F,P as b}from"./index-db391c6a.js";var G=["class","style"],H=function(){return{prefixCls:String,spinning:{type:Boolean,default:void 0},size:String,wrapperClassName:String,tip:b.any,delay:Number,indicator:b.any}},p=null;function M(t,n){return!!t&&!!n&&!isNaN(Number(n))}function W(t){var n=t.indicator;p=typeof n=="function"?n:function(){return a(n,null,null)}}const q=w({compatConfig:{MODE:3},name:"ASpin",inheritAttrs:!1,props:D(H(),{size:"default",spinning:!0,wrapperClassName:""}),setup:function(){return{originalUpdateSpinning:null,configProvider:A("configProvider",j)}},data:function(){var n=this.spinning,e=this.delay,i=M(n,e);return{sSpinning:n&&!i}},created:function(){this.originalUpdateSpinning=this.updateSpinning,this.debouncifyUpdateSpinning(this.$props)},mounted:function(){this.updateSpinning()},updated:function(){var n=this;k(function(){n.debouncifyUpdateSpinning(),n.updateSpinning()})},beforeUnmount:function(){this.cancelExistingSpin()},methods:{debouncifyUpdateSpinning:function(n){var e=n||this.$props,i=e.delay;i&&(this.cancelExistingSpin(),this.updateSpinning=V(this.originalUpdateSpinning,i))},updateSpinning:function(){var n=this.spinning,e=this.sSpinning;e!==n&&(this.sSpinning=n)},cancelExistingSpin:function(){var n=this.updateSpinning;n&&n.cancel&&n.cancel()},renderIndicator:function(n){var e="".concat(n,"-dot"),i=B(this,"indicator");return i===null?null:(Array.isArray(i)&&(i=i.length===1?i[0]:i),y(i)?$(i,{class:e}):p&&y(p())?$(p(),{class:e}):a("span",{class:"".concat(e," ").concat(n,"-dot-spin")},[a("i",{class:"".concat(n,"-dot-item")},null),a("i",{class:"".concat(n,"-dot-item")},null),a("i",{class:"".concat(n,"-dot-item")},null),a("i",{class:"".concat(n,"-dot-item")},null)]))}},render:function(){var n,e,i,o=this.$props,f=o.size,x=o.prefixCls,h=o.tip,d=h===void 0?(n=(e=this.$slots).tip)===null||n===void 0?void 0:n.call(e):h,N=o.wrapperClassName,l=this.$attrs,v=l.class,_=l.style,C=T(l,G),S=this.configProvider,U=S.getPrefixCls,E=S.direction,s=U("spin",x),u=this.sSpinning,z=(i={},r(i,s,!0),r(i,"".concat(s,"-sm"),f==="small"),r(i,"".concat(s,"-lg"),f==="large"),r(i,"".concat(s,"-spinning"),u),r(i,"".concat(s,"-show-text"),!!d),r(i,"".concat(s,"-rtl"),E==="rtl"),r(i,v,!!v),i),m=a("div",P(P({},C),{},{style:_,class:z}),[this.renderIndicator(s),d?a("div",{class:"".concat(s,"-text")},[d]):null]),g=F(this);if(g&&g.length){var c,I=(c={},r(c,"".concat(s,"-container"),!0),r(c,"".concat(s,"-blur"),u),c);return a("div",{class:["".concat(s,"-nested-loading"),N]},[u&&a("div",{key:"loading"},[m]),a("div",{class:I,key:"container"},[g])])}return m}});export{q as S,W as s};
>>>>>>>> 98a59b9 (Revert "Revert "feat: experimental natural-language categorization & search (persistent scope, clustering, retrieval)""):vue/dist/assets/index-6be5f2d5.js

1
vue/dist/assets/index-30c22d1a.js vendored Normal file
View File

@ -0,0 +1 @@
import{cw as j,ay as z,d as K,j as U,dp as $,w as g,r as b,G as S,m as A,u as D,o as E,az as G,h as d,c as s,a as C,aw as H,bf as L,g as _,dq as W,P as c,dr as x}from"./index-db391c6a.js";var q=z("small","default"),R=function(){return{id:String,prefixCls:String,size:c.oneOf(q),disabled:{type:Boolean,default:void 0},checkedChildren:c.any,unCheckedChildren:c.any,tabindex:c.oneOfType([c.string,c.number]),autofocus:{type:Boolean,default:void 0},loading:{type:Boolean,default:void 0},checked:c.oneOfType([c.string,c.number,c.looseBool]),checkedValue:c.oneOfType([c.string,c.number,c.looseBool]).def(!0),unCheckedValue:c.oneOfType([c.string,c.number,c.looseBool]).def(!1),onChange:{type:Function},onClick:{type:Function},onKeydown:{type:Function},onMouseup:{type:Function},"onUpdate:checked":{type:Function},onBlur:Function,onFocus:Function}},J=K({compatConfig:{MODE:3},name:"ASwitch",__ANT_SWITCH:!0,inheritAttrs:!1,props:R(),slots:["checkedChildren","unCheckedChildren"],setup:function(n,r){var o=r.attrs,y=r.slots,B=r.expose,l=r.emit,m=U();$(function(){g(!("defaultChecked"in o),"Switch","'defaultChecked' is deprecated, please use 'v-model:checked'"),g(!("value"in o),"Switch","`value` is not validate prop, do you mean `checked`?")});var h=b(n.checked!==void 0?n.checked:o.defaultChecked),f=S(function(){return h.value===n.checkedValue});A(function(){return n.checked},function(){h.value=n.checked});var v=D("switch",n),u=v.prefixCls,F=v.direction,T=v.size,i=b(),w=function(){var e;(e=i.value)===null||e===void 0||e.focus()},V=function(){var e;(e=i.value)===null||e===void 0||e.blur()};B({focus:w,blur:V}),E(function(){G(function(){n.autofocus&&!n.disabled&&i.value.focus()})});var k=function(e,t){n.disabled||(l("update:checked",e),l("change",e,t),m.onFieldChange())},I=function(e){l("blur",e)},N=function(e){w();var t=f.value?n.unCheckedValue:n.checkedValue;k(t,e),l("click",t,e)},M=function(e){e.keyCode===x.LEFT?k(n.unCheckedValue,e):e.keyCode===x.RIGHT&&k(n.checkedValue,e),l("keydown",e)},O=function(e){var t;(t=i.value)===null||t===void 0||t.blur(),l("mouseup",e)},P=S(function(){var a;return a={},d(a,"".concat(u.value,"-small"),T.value==="small"),d(a,"".concat(u.value,"-loading"),n.loading),d(a,"".concat(u.value,"-checked"),f.value),d(a,"".concat(u.value,"-disabled"),n.disabled),d(a,u.value,!0),d(a,"".concat(u.value,"-rtl"),F.value==="rtl"),a});return function(){var a;return s(W,{insertExtraNode:!0},{default:function(){return[s("button",C(C(C({},H(n,["prefixCls","checkedChildren","unCheckedChildren","checked","autofocus","checkedValue","unCheckedValue","id","onChange","onUpdate:checked"])),o),{},{id:(a=n.id)!==null&&a!==void 0?a:m.id.value,onKeydown:M,onClick:N,onBlur:I,onMouseup:O,type:"button",role:"switch","aria-checked":h.value,disabled:n.disabled||n.loading,class:[o.class,P.value],ref:i}),[s("div",{class:"".concat(u.value,"-handle")},[n.loading?s(L,{class:"".concat(u.value,"-loading-icon")},null):null]),s("span",{class:"".concat(u.value,"-inner")},[f.value?_(y,n,"checkedChildren"):_(y,n,"unCheckedChildren")])])]}})}}});const X=j(J);export{X as _};

1
vue/dist/assets/index-4a1bd1ce.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
vue/dist/assets/index-63826c0f.css vendored Normal file
View File

@ -0,0 +1 @@
.ant-tag{box-sizing:border-box;margin:0 8px 0 0;color:#000000d9;font-size:14px;font-variant:tabular-nums;line-height:1.5715;list-style:none;font-feature-settings:"tnum";display:inline-block;height:auto;padding:0 7px;font-size:12px;line-height:20px;white-space:nowrap;background:#fafafa;border:1px solid #d9d9d9;border-radius:2px;opacity:1;transition:all .3s}.ant-tag,.ant-tag a,.ant-tag a:hover{color:#000000d9}.ant-tag>a:first-child:last-child{display:inline-block;margin:0 -8px;padding:0 8px}.ant-tag-close-icon{margin-left:3px;color:#00000073;font-size:10px;cursor:pointer;transition:all .3s}.ant-tag-close-icon:hover{color:#000000d9}.ant-tag-has-color{border-color:transparent}.ant-tag-has-color,.ant-tag-has-color a,.ant-tag-has-color a:hover,.ant-tag-has-color .anticon-close,.ant-tag-has-color .anticon-close:hover{color:#fff}.ant-tag-checkable{background-color:transparent;border-color:transparent;cursor:pointer}.ant-tag-checkable:not(.ant-tag-checkable-checked):hover{color:#d03f0a}.ant-tag-checkable:active,.ant-tag-checkable-checked{color:#fff}.ant-tag-checkable-checked{background-color:#d03f0a}.ant-tag-checkable:active{background-color:#ab2800}.ant-tag-hidden{display:none}.ant-tag-pink{color:#c41d7f;background:#fff0f6;border-color:#ffadd2}.ant-tag-pink-inverse{color:#fff;background:#eb2f96;border-color:#eb2f96}.ant-tag-magenta{color:#c41d7f;background:#fff0f6;border-color:#ffadd2}.ant-tag-magenta-inverse{color:#fff;background:#eb2f96;border-color:#eb2f96}.ant-tag-red{color:#cf1322;background:#fff1f0;border-color:#ffa39e}.ant-tag-red-inverse{color:#fff;background:#f5222d;border-color:#f5222d}.ant-tag-volcano{color:#d4380d;background:#fff2e8;border-color:#ffbb96}.ant-tag-volcano-inverse{color:#fff;background:#fa541c;border-color:#fa541c}.ant-tag-orange{color:#d46b08;background:#fff7e6;border-color:#ffd591}.ant-tag-orange-inverse{color:#fff;background:#fa8c16;border-color:#fa8c16}.ant-tag-yellow{color:#d4b106;background:#feffe6;border-color:#fffb8f}.ant-tag-yellow-inverse{color:#fff;background:#fadb14;border-color:#fadb14}.ant-tag-gold{color:#d48806;background:#fffbe6;border-color:#ffe58f}.ant-tag-gold-inverse{color:#fff;background:#faad14;border-color:#faad14}.ant-tag-cyan{color:#08979c;background:#e6fffb;border-color:#87e8de}.ant-tag-cyan-inverse{color:#fff;background:#13c2c2;border-color:#13c2c2}.ant-tag-lime{color:#7cb305;background:#fcffe6;border-color:#eaff8f}.ant-tag-lime-inverse{color:#fff;background:#a0d911;border-color:#a0d911}.ant-tag-green{color:#389e0d;background:#f6ffed;border-color:#b7eb8f}.ant-tag-green-inverse{color:#fff;background:#52c41a;border-color:#52c41a}.ant-tag-blue{color:#096dd9;background:#e6f7ff;border-color:#91d5ff}.ant-tag-blue-inverse{color:#fff;background:#1890ff;border-color:#1890ff}.ant-tag-geekblue{color:#1d39c4;background:#f0f5ff;border-color:#adc6ff}.ant-tag-geekblue-inverse{color:#fff;background:#2f54eb;border-color:#2f54eb}.ant-tag-purple{color:#531dab;background:#f9f0ff;border-color:#d3adf7}.ant-tag-purple-inverse{color:#fff;background:#722ed1;border-color:#722ed1}.ant-tag-success{color:#52c41a;background:#f6ffed;border-color:#b7eb8f}.ant-tag-processing{color:#d03f0a;background:#fff1e6;border-color:#f7ae83}.ant-tag-error{color:#ff4d4f;background:#fff2f0;border-color:#ffccc7}.ant-tag-warning{color:#faad14;background:#fffbe6;border-color:#ffe58f}.ant-tag>.anticon+span,.ant-tag>span+.anticon{margin-left:7px}.ant-tag.ant-tag-rtl{margin-right:0;margin-left:8px;direction:rtl;text-align:right}.ant-tag-rtl .ant-tag-close-icon{margin-right:3px;margin-left:0}.ant-tag-rtl.ant-tag>.anticon+span,.ant-tag-rtl.ant-tag>span+.anticon{margin-right:7px;margin-left:0}

View File

@ -1 +1,5 @@
<<<<<<<< HEAD:vue/dist/assets/index-6e8ee846.js
import{d as x,a1 as $,aK as g,cI as b,r as w,U as p,V as i,W as a,c as r,a3 as d,X as u,Y as n,Z as B,a8 as I,a4 as m,y as V,z as _,B as v,aj as W,ak as D,cJ as N,a0 as R}from"./index-5ed9cd5a.js";/* empty css */const F={class:"container"},K={class:"actions"},L={class:"uni-desc"},U={class:"snapshot"},j=x({__name:"index",props:{tabIdx:{},paneIdx:{},id:{},paneKey:{}},setup(q){const h=$(),t=g(),f=e=>{h.tabList=V(e.tabs)},k=b(async e=>{await N(`workspace_snapshot_${e.id}`),t.snapshots=t.snapshots.filter(c=>c.id!==e.id),_.success(v("deleteSuccess"))}),o=w(""),y=async()=>{if(!o.value){_.error(v("nameRequired"));return}const e=t.createSnapshot(o.value);await t.addSnapshot(e),_.success(v("saveCompleted"))};return(e,c)=>{const C=W,l=D;return p(),i("div",F,[a("div",K,[r(C,{value:o.value,"onUpdate:value":c[0]||(c[0]=s=>o.value=s),placeholder:e.$t("name"),style:{"max-width":"300px"}},null,8,["value","placeholder"]),r(l,{type:"primary",onClick:y},{default:d(()=>[u(n(e.$t("saveWorkspaceSnapshot")),1)]),_:1})]),a("p",L,n(e.$t("WorkspaceSnapshotDesc")),1),a("ul",U,[(p(!0),i(B,null,I(m(t).snapshots,s=>(p(),i("li",{key:s.id},[a("div",null,[a("span",null,n(s.name),1)]),a("div",null,[r(l,{onClick:S=>f(s)},{default:d(()=>[u(n(e.$t("restore")),1)]),_:2},1032,["onClick"]),r(l,{onClick:S=>m(k)(s)},{default:d(()=>[u(n(e.$t("remove")),1)]),_:2},1032,["onClick"])])]))),128))])])}}});const E=R(j,[["__scopeId","data-v-2c44013c"]]);export{E as default};
========
import{d as x,a1 as $,aK as g,cN as b,r as w,U as p,V as i,W as a,c as r,a3 as d,X as u,Y as n,Z as B,a8 as I,a4 as m,y as N,z as _,B as v,aj as V,ak as W,cO as D,a0 as R}from"./index-db391c6a.js";/* empty css */const F={class:"container"},K={class:"actions"},L={class:"uni-desc"},U={class:"snapshot"},j=x({__name:"index",props:{tabIdx:{},paneIdx:{},id:{},paneKey:{}},setup(q){const h=$(),t=g(),f=e=>{h.tabList=N(e.tabs)},k=b(async e=>{await D(`workspace_snapshot_${e.id}`),t.snapshots=t.snapshots.filter(c=>c.id!==e.id),_.success(v("deleteSuccess"))}),o=w(""),y=async()=>{if(!o.value){_.error(v("nameRequired"));return}const e=t.createSnapshot(o.value);await t.addSnapshot(e),_.success(v("saveCompleted"))};return(e,c)=>{const C=V,l=W;return p(),i("div",F,[a("div",K,[r(C,{value:o.value,"onUpdate:value":c[0]||(c[0]=s=>o.value=s),placeholder:e.$t("name"),style:{"max-width":"300px"}},null,8,["value","placeholder"]),r(l,{type:"primary",onClick:y},{default:d(()=>[u(n(e.$t("saveWorkspaceSnapshot")),1)]),_:1})]),a("p",L,n(e.$t("WorkspaceSnapshotDesc")),1),a("ul",U,[(p(!0),i(B,null,I(m(t).snapshots,s=>(p(),i("li",{key:s.id},[a("div",null,[a("span",null,n(s.name),1)]),a("div",null,[r(l,{onClick:S=>f(s)},{default:d(()=>[u(n(e.$t("restore")),1)]),_:2},1032,["onClick"]),r(l,{onClick:S=>m(k)(s)},{default:d(()=>[u(n(e.$t("remove")),1)]),_:2},1032,["onClick"])])]))),128))])])}}});const E=R(j,[["__scopeId","data-v-e55e3025"]]);export{E as default};
>>>>>>>> 98a59b9 (Revert "Revert "feat: experimental natural-language categorization & search (persistent scope, clustering, retrieval)""):vue/dist/assets/index-7cb023d5.js

File diff suppressed because one or more lines are too long

1
vue/dist/assets/index-7e587e4d.css vendored Normal file
View File

@ -0,0 +1 @@
.container[data-v-e55e3025]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column;padding:16px}.container .actions[data-v-e55e3025]{margin-bottom:16px}.container .actions *[data-v-e55e3025]{margin-right:10px}.snapshot[data-v-e55e3025]{list-style:none;padding:0;margin:0;width:512px}.snapshot li[data-v-e55e3025]{display:flex;justify-content:space-between;align-items:center;padding:10px;background-color:var(--zp-secondary-variant-background);border-radius:4px;margin-bottom:10px;transition:all .3s ease;border-bottom:2px solid var(--zp-luminous-deep)}.snapshot li[data-v-e55e3025]:hover{border-bottom:2px solid var(--zp-luminous)}.snapshot li div[data-v-e55e3025]:first-child{flex-grow:1;font-weight:700}.snapshot li div[data-v-e55e3025]:last-child{display:flex;gap:10px}

File diff suppressed because one or more lines are too long

1
vue/dist/assets/index-ae90fb7e.js vendored Normal file
View File

@ -0,0 +1 @@
import{d as F,u as S,G as k,an as j,h as d,c as s,aq as M,e1 as U,r as W,bh as G,Z as V,dq as Z,P as N,cb as z}from"./index-db391c6a.js";var H=function(){return{prefixCls:String,checked:{type:Boolean,default:void 0},onChange:{type:Function},onClick:{type:Function},"onUpdate:checked":Function}},J=F({compatConfig:{MODE:3},name:"ACheckableTag",props:H(),setup:function(e,i){var l=i.slots,r=i.emit,g=S("tag",e),u=g.prefixCls,o=function(C){var v=e.checked;r("update:checked",!v),r("change",!v),r("click",C)},p=k(function(){var a;return j(u.value,(a={},d(a,"".concat(u.value,"-checkable"),!0),d(a,"".concat(u.value,"-checkable-checked"),e.checked),a))});return function(){var a;return s("span",{class:p.value,onClick:o},[(a=l.default)===null||a===void 0?void 0:a.call(l)])}}});const b=J;var K=new RegExp("^(".concat(M.join("|"),")(-inverse)?$")),L=new RegExp("^(".concat(U.join("|"),")$")),Q=function(){return{prefixCls:String,color:{type:String},closable:{type:Boolean,default:!1},closeIcon:N.any,visible:{type:Boolean,default:void 0},onClose:{type:Function},"onUpdate:visible":Function,icon:N.any}},f=F({compatConfig:{MODE:3},name:"ATag",props:Q(),slots:["closeIcon","icon"],setup:function(e,i){var l=i.slots,r=i.emit,g=i.attrs,u=S("tag",e),o=u.prefixCls,p=u.direction,a=W(!0);G(function(){e.visible!==void 0&&(a.value=e.visible)});var C=function(t){t.stopPropagation(),r("update:visible",!1),r("close",t),!t.defaultPrevented&&e.visible===void 0&&(a.value=!1)},v=k(function(){var n=e.color;return n?K.test(n)||L.test(n):!1}),E=k(function(){var n;return j(o.value,(n={},d(n,"".concat(o.value,"-").concat(e.color),v.value),d(n,"".concat(o.value,"-has-color"),e.color&&!v.value),d(n,"".concat(o.value,"-hidden"),!a.value),d(n,"".concat(o.value,"-rtl"),p.value==="rtl"),n))});return function(){var n,t,h,m=e.icon,R=m===void 0?(n=l.icon)===null||n===void 0?void 0:n.call(l):m,y=e.color,_=e.closeIcon,P=_===void 0?(t=l.closeIcon)===null||t===void 0?void 0:t.call(l):_,x=e.closable,w=x===void 0?!1:x,B=function(){return w?P?s("span",{class:"".concat(o.value,"-close-icon"),onClick:C},[P]):s(z,{class:"".concat(o.value,"-close-icon"),onClick:C},null):null},O={backgroundColor:y&&!v.value?y:void 0},I=R||null,T=(h=l.default)===null||h===void 0?void 0:h.call(l),q=I?s(V,null,[I,s("span",null,[T])]):T,A="onClick"in g,$=s("span",{class:E.value,style:O},[q,B()]);return A?s(Z,null,{default:function(){return[$]}}):$}}});f.CheckableTag=b;f.install=function(c){return c.component(f.name,f),c.component(b.name,b),c};const Y=f;export{Y as _};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
.container[data-v-2c44013c]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column;padding:16px}.container .actions[data-v-2c44013c]{margin-bottom:16px}.container .actions *[data-v-2c44013c]{margin-right:10px}.snapshot[data-v-2c44013c]{list-style:none;padding:0;margin:0;width:512px}.snapshot li[data-v-2c44013c]{display:flex;justify-content:space-between;align-items:center;padding:10px;background-color:var(--zp-secondary-variant-background);border-radius:4px;margin-bottom:10px;transition:all .3s ease;border-bottom:2px solid var(--zp-luminous-deep)}.snapshot li[data-v-2c44013c]:hover{border-bottom:2px solid var(--zp-luminous)}.snapshot li div[data-v-2c44013c]:first-child{flex-grow:1;font-weight:700}.snapshot li div[data-v-2c44013c]:last-child{display:flex;gap:10px}

1
vue/dist/assets/index-d55a76b1.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1 +1,5 @@
<<<<<<<< HEAD:vue/dist/assets/index-eec830e6.js
import{r as F,o as P,cw as K,av as L,G as l,ax as T,ay as $,d as I,u as B,cx as _,b as y,bk as V,cy as A,an as E,h as c,c as M,a as G}from"./index-5ed9cd5a.js";const W=function(){var o=F(!1);return P(function(){o.value=K()}),o};var D=Symbol("rowContextKey"),k=function(r){T(D,r)},U=function(){return L(D,{gutter:l(function(){}),wrap:l(function(){}),supportFlexGap:l(function(){})})};$("top","middle","bottom","stretch");$("start","end","center","space-around","space-between");var q=function(){return{align:String,justify:String,prefixCls:String,gutter:{type:[Number,Array,Object],default:0},wrap:{type:Boolean,default:void 0}}},H=I({compatConfig:{MODE:3},name:"ARow",props:q(),setup:function(r,N){var m=N.slots,v=B("row",r),d=v.prefixCls,h=v.direction,j,x=F({xs:!0,sm:!0,md:!0,lg:!0,xl:!0,xxl:!0,xxxl:!0}),w=W();P(function(){j=_.subscribe(function(e){var t=r.gutter||0;(!Array.isArray(t)&&y(t)==="object"||Array.isArray(t)&&(y(t[0])==="object"||y(t[1])==="object"))&&(x.value=e)})}),V(function(){_.unsubscribe(j)});var S=l(function(){var e=[0,0],t=r.gutter,n=t===void 0?0:t,s=Array.isArray(n)?n:[n,0];return s.forEach(function(i,b){if(y(i)==="object")for(var a=0;a<A.length;a++){var p=A[a];if(x.value[p]&&i[p]!==void 0){e[b]=i[p];break}}else e[b]=i||0}),e});k({gutter:S,supportFlexGap:w,wrap:l(function(){return r.wrap})});var R=l(function(){var e;return E(d.value,(e={},c(e,"".concat(d.value,"-no-wrap"),r.wrap===!1),c(e,"".concat(d.value,"-").concat(r.justify),r.justify),c(e,"".concat(d.value,"-").concat(r.align),r.align),c(e,"".concat(d.value,"-rtl"),h.value==="rtl"),e))}),O=l(function(){var e=S.value,t={},n=e[0]>0?"".concat(e[0]/-2,"px"):void 0,s=e[1]>0?"".concat(e[1]/-2,"px"):void 0;return n&&(t.marginLeft=n,t.marginRight=n),w.value?t.rowGap="".concat(e[1],"px"):s&&(t.marginTop=s,t.marginBottom=s),t});return function(){var e;return M("div",{class:R.value,style:O.value},[(e=m.default)===null||e===void 0?void 0:e.call(m)])}}});const Y=H;function J(o){return typeof o=="number"?"".concat(o," ").concat(o," auto"):/^\d+(\.\d+)?(px|em|rem|%)$/.test(o)?"0 0 ".concat(o):o}var Q=function(){return{span:[String,Number],order:[String,Number],offset:[String,Number],push:[String,Number],pull:[String,Number],xs:{type:[String,Number,Object],default:void 0},sm:{type:[String,Number,Object],default:void 0},md:{type:[String,Number,Object],default:void 0},lg:{type:[String,Number,Object],default:void 0},xl:{type:[String,Number,Object],default:void 0},xxl:{type:[String,Number,Object],default:void 0},xxxl:{type:[String,Number,Object],default:void 0},prefixCls:String,flex:[String,Number]}};const Z=I({compatConfig:{MODE:3},name:"ACol",props:Q(),setup:function(r,N){var m=N.slots,v=U(),d=v.gutter,h=v.supportFlexGap,j=v.wrap,x=B("col",r),w=x.prefixCls,S=x.direction,R=l(function(){var e,t=r.span,n=r.order,s=r.offset,i=r.push,b=r.pull,a=w.value,p={};return["xs","sm","md","lg","xl","xxl","xxxl"].forEach(function(g){var f,u={},C=r[g];typeof C=="number"?u.span=C:y(C)==="object"&&(u=C||{}),p=G(G({},p),{},(f={},c(f,"".concat(a,"-").concat(g,"-").concat(u.span),u.span!==void 0),c(f,"".concat(a,"-").concat(g,"-order-").concat(u.order),u.order||u.order===0),c(f,"".concat(a,"-").concat(g,"-offset-").concat(u.offset),u.offset||u.offset===0),c(f,"".concat(a,"-").concat(g,"-push-").concat(u.push),u.push||u.push===0),c(f,"".concat(a,"-").concat(g,"-pull-").concat(u.pull),u.pull||u.pull===0),c(f,"".concat(a,"-rtl"),S.value==="rtl"),f))}),E(a,(e={},c(e,"".concat(a,"-").concat(t),t!==void 0),c(e,"".concat(a,"-order-").concat(n),n),c(e,"".concat(a,"-offset-").concat(s),s),c(e,"".concat(a,"-push-").concat(i),i),c(e,"".concat(a,"-pull-").concat(b),b),e),p)}),O=l(function(){var e=r.flex,t=d.value,n={};if(t&&t[0]>0){var s="".concat(t[0]/2,"px");n.paddingLeft=s,n.paddingRight=s}if(t&&t[1]>0&&!h.value){var i="".concat(t[1]/2,"px");n.paddingTop=i,n.paddingBottom=i}return e&&(n.flex=J(e),j.value===!1&&!n.minWidth&&(n.minWidth=0)),n});return function(){var e;return M("div",{class:R.value,style:O.value},[(e=m.default)===null||e===void 0?void 0:e.call(m)])}}});export{Z as C,Y as R};
========
import{r as F,o as P,cx as K,av as L,G as i,ax as T,ay as I,d as $,u as B,cy as _,b as y,bk as V,cz as A,an as E,h as c,c as M,a as G}from"./index-db391c6a.js";const W=function(){var o=F(!1);return P(function(){o.value=K()}),o};var D=Symbol("rowContextKey"),k=function(r){T(D,r)},U=function(){return L(D,{gutter:i(function(){}),wrap:i(function(){}),supportFlexGap:i(function(){})})};I("top","middle","bottom","stretch");I("start","end","center","space-around","space-between");var q=function(){return{align:String,justify:String,prefixCls:String,gutter:{type:[Number,Array,Object],default:0},wrap:{type:Boolean,default:void 0}}},z=$({compatConfig:{MODE:3},name:"ARow",props:q(),setup:function(r,N){var m=N.slots,v=B("row",r),d=v.prefixCls,h=v.direction,j,x=F({xs:!0,sm:!0,md:!0,lg:!0,xl:!0,xxl:!0,xxxl:!0}),S=W();P(function(){j=_.subscribe(function(e){var t=r.gutter||0;(!Array.isArray(t)&&y(t)==="object"||Array.isArray(t)&&(y(t[0])==="object"||y(t[1])==="object"))&&(x.value=e)})}),V(function(){_.unsubscribe(j)});var w=i(function(){var e=[0,0],t=r.gutter,n=t===void 0?0:t,s=Array.isArray(n)?n:[n,0];return s.forEach(function(l,b){if(y(l)==="object")for(var a=0;a<A.length;a++){var p=A[a];if(x.value[p]&&l[p]!==void 0){e[b]=l[p];break}}else e[b]=l||0}),e});k({gutter:w,supportFlexGap:S,wrap:i(function(){return r.wrap})});var R=i(function(){var e;return E(d.value,(e={},c(e,"".concat(d.value,"-no-wrap"),r.wrap===!1),c(e,"".concat(d.value,"-").concat(r.justify),r.justify),c(e,"".concat(d.value,"-").concat(r.align),r.align),c(e,"".concat(d.value,"-rtl"),h.value==="rtl"),e))}),O=i(function(){var e=w.value,t={},n=e[0]>0?"".concat(e[0]/-2,"px"):void 0,s=e[1]>0?"".concat(e[1]/-2,"px"):void 0;return n&&(t.marginLeft=n,t.marginRight=n),S.value?t.rowGap="".concat(e[1],"px"):s&&(t.marginTop=s,t.marginBottom=s),t});return function(){var e;return M("div",{class:R.value,style:O.value},[(e=m.default)===null||e===void 0?void 0:e.call(m)])}}});const X=z;function H(o){return typeof o=="number"?"".concat(o," ").concat(o," auto"):/^\d+(\.\d+)?(px|em|rem|%)$/.test(o)?"0 0 ".concat(o):o}var J=function(){return{span:[String,Number],order:[String,Number],offset:[String,Number],push:[String,Number],pull:[String,Number],xs:{type:[String,Number,Object],default:void 0},sm:{type:[String,Number,Object],default:void 0},md:{type:[String,Number,Object],default:void 0},lg:{type:[String,Number,Object],default:void 0},xl:{type:[String,Number,Object],default:void 0},xxl:{type:[String,Number,Object],default:void 0},xxxl:{type:[String,Number,Object],default:void 0},prefixCls:String,flex:[String,Number]}};const Y=$({compatConfig:{MODE:3},name:"ACol",props:J(),setup:function(r,N){var m=N.slots,v=U(),d=v.gutter,h=v.supportFlexGap,j=v.wrap,x=B("col",r),S=x.prefixCls,w=x.direction,R=i(function(){var e,t=r.span,n=r.order,s=r.offset,l=r.push,b=r.pull,a=S.value,p={};return["xs","sm","md","lg","xl","xxl","xxxl"].forEach(function(g){var f,u={},C=r[g];typeof C=="number"?u.span=C:y(C)==="object"&&(u=C||{}),p=G(G({},p),{},(f={},c(f,"".concat(a,"-").concat(g,"-").concat(u.span),u.span!==void 0),c(f,"".concat(a,"-").concat(g,"-order-").concat(u.order),u.order||u.order===0),c(f,"".concat(a,"-").concat(g,"-offset-").concat(u.offset),u.offset||u.offset===0),c(f,"".concat(a,"-").concat(g,"-push-").concat(u.push),u.push||u.push===0),c(f,"".concat(a,"-").concat(g,"-pull-").concat(u.pull),u.pull||u.pull===0),c(f,"".concat(a,"-rtl"),w.value==="rtl"),f))}),E(a,(e={},c(e,"".concat(a,"-").concat(t),t!==void 0),c(e,"".concat(a,"-order-").concat(n),n),c(e,"".concat(a,"-offset-").concat(s),s),c(e,"".concat(a,"-push-").concat(l),l),c(e,"".concat(a,"-pull-").concat(b),b),e),p)}),O=i(function(){var e=r.flex,t=d.value,n={};if(t&&t[0]>0){var s="".concat(t[0]/2,"px");n.paddingLeft=s,n.paddingRight=s}if(t&&t[1]>0&&!h.value){var l="".concat(t[1]/2,"px");n.paddingTop=l,n.paddingBottom=l}return e&&(n.flex=H(e),j.value===!1&&!n.minWidth&&(n.minWidth=0)),n});return function(){var e;return M("div",{class:R.value,style:O.value},[(e=m.default)===null||e===void 0?void 0:e.call(m)])}}});export{Y as C,X as R};
>>>>>>>> 98a59b9 (Revert "Revert "feat: experimental natural-language categorization & search (persistent scope, clustering, retrieval)""):vue/dist/assets/index-e3af27b3.js

View File

@ -0,0 +1 @@
import{cl as e,cm as i,cn as r,co as a,b1 as n}from"./index-db391c6a.js";function c(s,t){return e(i(s,t,r),s+"")}function b(s){return a(s)&&n(s)}export{c as b,b as i};

1
vue/dist/assets/numInput-ca4fe815.css vendored Normal file
View File

@ -0,0 +1 @@
.ant-slider{box-sizing:border-box;color:#000000d9;font-size:14px;font-variant:tabular-nums;line-height:1.5715;list-style:none;font-feature-settings:"tnum";position:relative;height:12px;margin:10px 6px;padding:4px 0;cursor:pointer;touch-action:none}.ant-slider-vertical{width:12px;height:100%;margin:6px 10px;padding:0 4px}.ant-slider-vertical .ant-slider-rail{width:4px;height:100%}.ant-slider-vertical .ant-slider-track{width:4px}.ant-slider-vertical .ant-slider-handle{margin-top:-6px;margin-left:-5px}.ant-slider-vertical .ant-slider-mark{top:0;left:12px;width:18px;height:100%}.ant-slider-vertical .ant-slider-mark-text{left:4px;white-space:nowrap}.ant-slider-vertical .ant-slider-step{width:4px;height:100%}.ant-slider-vertical .ant-slider-dot{top:auto;left:2px;margin-bottom:-4px}.ant-slider-tooltip .ant-tooltip-inner{min-width:unset}.ant-slider-rtl.ant-slider-vertical .ant-slider-handle{margin-right:-5px;margin-left:0}.ant-slider-rtl.ant-slider-vertical .ant-slider-mark{right:12px;left:auto}.ant-slider-rtl.ant-slider-vertical .ant-slider-mark-text{right:4px;left:auto}.ant-slider-rtl.ant-slider-vertical .ant-slider-dot{right:2px;left:auto}.ant-slider-with-marks{margin-bottom:28px}.ant-slider-rail{position:absolute;width:100%;height:4px;background-color:#f5f5f5;border-radius:2px;transition:background-color .3s}.ant-slider-track{position:absolute;height:4px;background-color:#f7ae83;border-radius:2px;transition:background-color .3s}.ant-slider-handle{position:absolute;width:14px;height:14px;margin-top:-5px;background-color:#fff;border:solid 2px #f7ae83;border-radius:50%;box-shadow:0;cursor:pointer;transition:border-color .3s,box-shadow .6s,transform .3s cubic-bezier(.18,.89,.32,1.28)}.ant-slider-handle-dragging.ant-slider-handle-dragging.ant-slider-handle-dragging{border-color:#d9653b;box-shadow:0 0 0 5px #d03f0a1f}.ant-slider-handle:focus{border-color:#d9653b;outline:none;box-shadow:0 0 0 5px #d03f0a1f}.ant-slider-handle.ant-tooltip-open{border-color:#d03f0a}.ant-slider:hover .ant-slider-rail{background-color:#e1e1e1}.ant-slider:hover .ant-slider-track{background-color:#eb8857}.ant-slider:hover .ant-slider-handle:not(.ant-tooltip-open){border-color:#eb8857}.ant-slider-mark{position:absolute;top:14px;left:0;width:100%;font-size:14px}.ant-slider-mark-text{position:absolute;display:inline-block;color:#00000073;text-align:center;word-break:keep-all;cursor:pointer;user-select:none}.ant-slider-mark-text-active{color:#000000d9}.ant-slider-step{position:absolute;width:100%;height:4px;background:transparent}.ant-slider-dot{position:absolute;top:-2px;width:8px;height:8px;margin-left:-4px;background-color:#fff;border:2px solid #f0f0f0;border-radius:50%;cursor:pointer}.ant-slider-dot:first-child{margin-left:-4px}.ant-slider-dot:last-child{margin-left:-4px}.ant-slider-dot-active{border-color:#e89f85}.ant-slider-disabled{cursor:not-allowed}.ant-slider-disabled .ant-slider-rail{background-color:#f5f5f5!important}.ant-slider-disabled .ant-slider-track{background-color:#00000040!important}.ant-slider-disabled .ant-slider-handle,.ant-slider-disabled .ant-slider-dot{background-color:#fff;border-color:#00000040!important;box-shadow:none;cursor:not-allowed}.ant-slider-disabled .ant-slider-mark-text,.ant-slider-disabled .ant-slider-dot{cursor:not-allowed!important}.ant-slider-rtl{direction:rtl}.ant-slider-rtl .ant-slider-mark{right:0;left:auto}.ant-slider-rtl .ant-slider-dot,.ant-slider-rtl .ant-slider-dot:first-child{margin-right:-4px;margin-left:0}.ant-slider-rtl .ant-slider-dot:last-child{margin-right:-4px;margin-left:0}.num-input[data-v-bd954eda]{display:flex}.num-input .slide[data-v-bd954eda]{flex:1;min-width:128px;max-width:256px;margin-left:8px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 195 B

After

Width:  |  Height:  |  Size: 195 B

View File

@ -0,0 +1 @@
import{d as Z,a1 as ee,r as F,J as te,K as le,o as ie,U as v,V as N,c as i,a4 as e,W as g,a3 as n,X as k,Y as u,a5 as R,L as se,a6 as ae,af as oe,ag as $,$ as A,a2 as ne,z as w,B as re,cP as ce,cQ as de,ak as ue,ai as me,T as fe,a0 as pe}from"./index-db391c6a.js";import{u as ve,c as ge,a as ke,F as we,d as he}from"./FileItem-9be5bb5d.js";import{a as Ce,b as Se,c as _e,M as Ie,o as z,L as ye,R as xe,f as be}from"./MultiSelectKeep-cd70772d.js";import"./numInput.vue_vue_type_style_index_0_scoped_bd954eda_lang-28fed536.js";/* empty css */import"./index-4a1bd1ce.js";import"./_isIterateeCall-6ab5736a.js";import"./index-ae90fb7e.js";import"./index-83d83387.js";import"./shortcut-ace377a3.js";import"./Checkbox-be055c11.js";import"./index-30c22d1a.js";const Ve={class:"refresh-button"},Me={class:"hint"},Te={key:0,class:"preview-switch"},Fe=Z({__name:"randomImage",props:{tabIdx:{},paneIdx:{},id:{},paneKey:{}},setup(Ne){const B=ee(),m=F(!1),l=F([]),r=l,h=te(`${le}randomImageSettingNotificationShown`,!1),P=()=>{h.value||(w.info({content:re("randomImageSettingNotification"),duration:6,key:"randomImageSetting"}),h.value=!0)},f=async()=>{try{m.value=!0;const s=await ce();s.length===0&&w.warn("No data, please generate index in image search page first"),l.value=s}finally{m.value=!1,_()}},C=()=>{if(l.value.length===0){w.warn("没有图片可以浏览");return}z(l.value,o.value||0)};ie(()=>{f(),setTimeout(()=>{P()},2e3)});const{stackViewEl:K,multiSelectedIdxs:p,stack:L,scroller:U}=ve({images:l}).toRefs(),{onClearAllSelected:D,onSelectAll:E,onReverseSelect:G}=ge();Ce();const{itemSize:S,gridItems:O,cellWidth:W,onScroll:_}=ke(),{showGenInfo:c,imageGenInfo:I,q:Q,onContextMenuClick:q,onFileItemClick:H}=Se({openNext:de}),{previewIdx:o,previewing:y,onPreviewVisibleChange:J,previewImgMove:x,canPreview:b}=_e(),V=async(s,t,d)=>{L.value=[{curr:"",files:l.value}],await q(s,t,d)};return(s,t)=>{var M;const d=ue,X=me,Y=fe;return v(),N("div",{class:"container",ref_key:"stackViewEl",ref:K},[i(Ie,{show:!!e(p).length||e(B).keepMultiSelect,onClearAllSelected:e(D),onSelectAll:e(E),onReverseSelect:e(G)},null,8,["show","onClearAllSelected","onSelectAll","onReverseSelect"]),g("div",Ve,[i(d,{onClick:f,onTouchstart:R(f,["prevent"]),type:"primary",loading:m.value,shape:"round"},{default:n(()=>[k(u(s.$t("shuffle")),1)]),_:1},8,["onTouchstart","loading"]),i(d,{onClick:C,onTouchstart:R(C,["prevent"]),type:"default",disabled:!((M=l.value)!=null&&M.length),shape:"round"},{default:n(()=>[k(u(s.$t("tiktokView")),1)]),_:1},8,["onTouchstart","disabled"])]),i(Y,{visible:e(c),"onUpdate:visible":t[1]||(t[1]=a=>ae(c)?c.value=a:null),width:"70vw","mask-closable":"",onOk:t[2]||(t[2]=a=>c.value=!1)},{cancelText:n(()=>[]),default:n(()=>[i(X,{active:"",loading:!e(Q).isIdle},{default:n(()=>[g("div",{style:{width:"100%","word-break":"break-all","white-space":"pre-line","max-height":"70vh",overflow:"auto"},onDblclick:t[0]||(t[0]=a=>e(se)(e(I)))},[g("div",Me,u(s.$t("doubleClickToCopy")),1),k(" "+u(e(I)),1)],32)]),_:1},8,["loading"])]),_:1},8,["visible"]),i(e(he),{ref_key:"scroller",ref:U,class:"file-list",items:l.value.slice(),"item-size":e(S).first,"key-field":"fullpath","item-secondary-size":e(S).second,gridItems:e(O),onScroll:e(_)},{default:n(({item:a,index:T})=>[i(we,{idx:T,file:a,"cell-width":e(W),"full-screen-preview-image-url":e(r)[e(o)]?e(oe)(e(r)[e(o)]):"",onContextMenuClick:V,onPreviewVisibleChange:e(J),"is-selected-mutil-files":e(p).length>1,selected:e(p).includes(T),onFileItemClick:e(H),onTiktokView:(Re,j)=>e(z)(l.value,j)},null,8,["idx","file","cell-width","full-screen-preview-image-url","onPreviewVisibleChange","is-selected-mutil-files","selected","onFileItemClick","onTiktokView"])]),_:1},8,["items","item-size","item-secondary-size","gridItems","onScroll"]),e(y)?(v(),N("div",Te,[i(e(ye),{onClick:t[3]||(t[3]=a=>e(x)("prev")),class:$({disable:!e(b)("prev")})},null,8,["class"]),i(e(xe),{onClick:t[4]||(t[4]=a=>e(x)("next")),class:$({disable:!e(b)("next")})},null,8,["class"])])):A("",!0),e(y)&&e(r)&&e(r)[e(o)]?(v(),ne(be,{key:1,file:e(r)[e(o)],idx:e(o),onContextMenuClick:V},null,8,["file","idx"])):A("",!0)],512)}}});const We=pe(Fe,[["__scopeId","data-v-e1531e89"]]);export{We as default};

View File

@ -1 +1 @@
.container[data-v-49082269]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-49082269]{padding:8px;background-color:var(--zp-primary-background)}.container .refresh-button[data-v-49082269]{position:absolute;top:90%;left:50%;transform:translate(-50%,-50%);z-index:99;background:white;border-radius:9999px;box-shadow:0 0 20px var(--zp-secondary);padding:4px;display:flex;align-items:center;gap:8px}.container .file-list[data-v-49082269]{flex:1;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-49082269]{text-align:center;font-size:2em;padding:30vh 128px 0}
.container[data-v-e1531e89]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-e1531e89]{padding:8px;background-color:var(--zp-primary-background)}.container .refresh-button[data-v-e1531e89]{position:absolute;top:90%;left:50%;transform:translate(-50%,-50%);z-index:99;background:white;border-radius:9999px;box-shadow:0 0 20px var(--zp-secondary);padding:4px;display:flex;align-items:center;gap:8px}.container .file-list[data-v-e1531e89]{flex:1;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-e1531e89]{text-align:center;font-size:2em;padding:30vh 128px 0}

View File

@ -1 +1,5 @@
<<<<<<<< HEAD:vue/dist/assets/searchHistory-4bd924a9.js
import{R as y,C as v}from"./index-eec830e6.js";import{cv as f,c as d,A as P,d as w,U as o,V as c,W as r,Z as S,a8 as V,aG as O,a3 as R,X as u,Y as p,a4 as b,ak as $,a0 as x,R as H,J as _,K as m}from"./index-5ed9cd5a.js";const A=f(y),E=f(v);var L={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M878.3 392.1L631.9 145.7c-6.5-6.5-15-9.7-23.5-9.7s-17 3.2-23.5 9.7L423.8 306.9c-12.2-1.4-24.5-2-36.8-2-73.2 0-146.4 24.1-206.5 72.3-15.4 12.3-16.6 35.4-2.7 49.4l181.7 181.7-215.4 215.2a15.8 15.8 0 00-4.6 9.8l-3.4 37.2c-.9 9.4 6.6 17.4 15.9 17.4.5 0 1 0 1.5-.1l37.2-3.4c3.7-.3 7.2-2 9.8-4.6l215.4-215.4 181.7 181.7c6.5 6.5 15 9.7 23.5 9.7 9.7 0 19.3-4.2 25.9-12.4 56.3-70.3 79.7-158.3 70.2-243.4l161.1-161.1c12.9-12.8 12.9-33.8 0-46.8z"}}]},name:"pushpin",theme:"filled"};const C=L;function h(t){for(var e=1;e<arguments.length;e++){var s=arguments[e]!=null?Object(arguments[e]):{},n=Object.keys(s);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(s).filter(function(i){return Object.getOwnPropertyDescriptor(s,i).enumerable}))),n.forEach(function(i){N(t,i,s[i])})}return t}function N(t,e,s){return e in t?Object.defineProperty(t,e,{value:s,enumerable:!0,configurable:!0,writable:!0}):t[e]=s,t}var l=function(e,s){var n=h({},e,s.attrs);return d(P,h({},n,{icon:C}),null)};l.displayName="PushpinFilled";l.inheritAttrs=!1;const z=l,F={class:"record-container"},k={style:{flex:"1"}},I={class:"rec-actions"},B=["onClick"],J=w({__name:"HistoryRecord",props:{records:{}},emits:["reuseRecord"],setup(t){return(e,s)=>{const n=$;return o(),c("div",null,[r("ul",F,[(o(!0),c(S,null,V(e.records.getRecords(),i=>(o(),c("li",{key:i.id,class:"record"},[r("div",k,[O(e.$slots,"default",{record:i},void 0,!0)]),r("div",I,[d(n,{onClick:g=>e.$emit("reuseRecord",i),type:"primary"},{default:R(()=>[u(p(e.$t("restore")),1)]),_:2},1032,["onClick"]),r("div",{class:"pin",onClick:g=>e.records.switchPin(i)},[d(b(z)),u(" "+p(e.records.isPinned(i)?e.$t("unpin"):e.$t("pin")),1)],8,B)])]))),128))])])}}});const q=x(J,[["__scopeId","data-v-834a248f"]]);class a{constructor(e=128,s=[],n=[]){this.maxLength=e,this.records=s,this.pinnedValues=n}isPinned(e){return this.pinnedValues.some(s=>s.id===e.id)}add(e){this.records.length>=this.maxLength&&this.records.pop(),this.records.unshift({...e,id:H()+Date.now(),time:new Date().toLocaleString()})}pin(e){const s=this.records.findIndex(n=>n.id===e.id);s!==-1&&this.records.splice(s,1),this.pinnedValues.push(e)}unpin(e){const s=this.pinnedValues.findIndex(n=>n.id===e.id);s!==-1&&this.pinnedValues.splice(s,1),this.records.unshift(e)}switchPin(e){this.isPinned(e)?this.unpin(e):this.pin(e)}getRecords(){return[...this.pinnedValues,...this.records]}getPinnedValues(){return this.pinnedValues}}const G=_(`${m}fuzzy-search-HistoryRecord`,new a,{serializer:{read:t=>{const e=JSON.parse(t);return new a(e.maxLength,e.records,e.pinnedValues)},write:JSON.stringify}}),M=_(`${m}tag-search-HistoryRecord`,new a,{serializer:{read:t=>{const e=JSON.parse(t);return new a(e.maxLength,e.records,e.pinnedValues)},write:JSON.stringify}});export{q as H,E as _,A as a,G as f,M as t};
========
import{R as y,C as v}from"./index-e3af27b3.js";import{cw as f,c as d,A as w,d as P,U as a,V as c,W as r,Z as S,a8 as V,aG as O,a3 as R,X as u,Y as p,a4 as b,ak as $,a0 as x,R as H,J as _,K as m}from"./index-db391c6a.js";const A=f(y),E=f(v);var L={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M878.3 392.1L631.9 145.7c-6.5-6.5-15-9.7-23.5-9.7s-17 3.2-23.5 9.7L423.8 306.9c-12.2-1.4-24.5-2-36.8-2-73.2 0-146.4 24.1-206.5 72.3-15.4 12.3-16.6 35.4-2.7 49.4l181.7 181.7-215.4 215.2a15.8 15.8 0 00-4.6 9.8l-3.4 37.2c-.9 9.4 6.6 17.4 15.9 17.4.5 0 1 0 1.5-.1l37.2-3.4c3.7-.3 7.2-2 9.8-4.6l215.4-215.4 181.7 181.7c6.5 6.5 15 9.7 23.5 9.7 9.7 0 19.3-4.2 25.9-12.4 56.3-70.3 79.7-158.3 70.2-243.4l161.1-161.1c12.9-12.8 12.9-33.8 0-46.8z"}}]},name:"pushpin",theme:"filled"};const C=L;function h(t){for(var e=1;e<arguments.length;e++){var s=arguments[e]!=null?Object(arguments[e]):{},n=Object.keys(s);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(s).filter(function(i){return Object.getOwnPropertyDescriptor(s,i).enumerable}))),n.forEach(function(i){N(t,i,s[i])})}return t}function N(t,e,s){return e in t?Object.defineProperty(t,e,{value:s,enumerable:!0,configurable:!0,writable:!0}):t[e]=s,t}var l=function(e,s){var n=h({},e,s.attrs);return d(w,h({},n,{icon:C}),null)};l.displayName="PushpinFilled";l.inheritAttrs=!1;const z=l,F={class:"record-container"},k={style:{flex:"1"}},I={class:"rec-actions"},B=["onClick"],J=P({__name:"HistoryRecord",props:{records:{}},emits:["reuseRecord"],setup(t){return(e,s)=>{const n=$;return a(),c("div",null,[r("ul",F,[(a(!0),c(S,null,V(e.records.getRecords(),i=>(a(),c("li",{key:i.id,class:"record"},[r("div",k,[O(e.$slots,"default",{record:i},void 0,!0)]),r("div",I,[d(n,{onClick:g=>e.$emit("reuseRecord",i),type:"primary"},{default:R(()=>[u(p(e.$t("restore")),1)]),_:2},1032,["onClick"]),r("div",{class:"pin",onClick:g=>e.records.switchPin(i)},[d(b(z)),u(" "+p(e.records.isPinned(i)?e.$t("unpin"):e.$t("pin")),1)],8,B)])]))),128))])])}}});const q=x(J,[["__scopeId","data-v-fff181dd"]]);class o{constructor(e=128,s=[],n=[]){this.maxLength=e,this.records=s,this.pinnedValues=n}isPinned(e){return this.pinnedValues.some(s=>s.id===e.id)}add(e){this.records.length>=this.maxLength&&this.records.pop(),this.records.unshift({...e,id:H()+Date.now(),time:new Date().toLocaleString()})}pin(e){const s=this.records.findIndex(n=>n.id===e.id);s!==-1&&this.records.splice(s,1),this.pinnedValues.push(e)}unpin(e){const s=this.pinnedValues.findIndex(n=>n.id===e.id);s!==-1&&this.pinnedValues.splice(s,1),this.records.unshift(e)}switchPin(e){this.isPinned(e)?this.unpin(e):this.pin(e)}getRecords(){return[...this.pinnedValues,...this.records]}getPinnedValues(){return this.pinnedValues}}const G=_(`${m}fuzzy-search-HistoryRecord`,new o,{serializer:{read:t=>{const e=JSON.parse(t);return new o(e.maxLength,e.records,e.pinnedValues)},write:JSON.stringify}}),M=_(`${m}tag-search-HistoryRecord`,new o,{serializer:{read:t=>{const e=JSON.parse(t);return new o(e.maxLength,e.records,e.pinnedValues)},write:JSON.stringify}});export{q as H,E as _,A as a,G as f,M as t};
>>>>>>>> 98a59b9 (Revert "Revert "feat: experimental natural-language categorization & search (persistent scope, clustering, retrieval)""):vue/dist/assets/searchHistory-f5718832.js

View File

@ -0,0 +1 @@
[data-v-fff181dd] .ant-row .ant-col:nth-child(1){font-weight:700}.record-container[data-v-fff181dd]{list-style:none;padding:8px;margin:16px;max-height:50vh;overflow:auto}.record[data-v-fff181dd]{display:flex;flex-direction:row;justify-content:space-between;align-items:center;padding:10px;border-bottom:1px solid var(--zp-tertiary);position:relative;flex-wrap:nowrap;transition:all .3s ease}.record[data-v-fff181dd]:hover{background:var(--zp-secondary-background)}.record .rec-actions[data-v-fff181dd]{user-select:none;display:flex;gap:8px}.record .pin[data-v-fff181dd]{cursor:pointer;padding:4px 8px;border-radius:4px;transition:all .3s ease}.record .pin[data-v-fff181dd]:hover{background:var(--zp-primary-background)}

View File

@ -1 +0,0 @@
[data-v-834a248f] .ant-row .ant-col:nth-child(1){font-weight:700}.record-container[data-v-834a248f]{list-style:none;padding:8px;margin:16px;max-height:50vh;overflow:auto}.record[data-v-834a248f]{display:flex;flex-direction:row;justify-content:space-between;align-items:center;padding:10px;border-bottom:1px solid var(--zp-tertiary);position:relative;flex-wrap:nowrap;transition:all .3s ease}.record[data-v-834a248f]:hover{background:var(--zp-secondary-background)}.record .rec-actions[data-v-834a248f]{user-select:none;display:flex;gap:8px}.record .pin[data-v-834a248f]{cursor:pointer;padding:4px 8px;border-radius:4px;transition:all .3s ease}.record .pin[data-v-834a248f]:hover{background:var(--zp-primary-background)}

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
.ant-breadcrumb{box-sizing:border-box;margin:0;padding:0;color:#000000d9;font-variant:tabular-nums;line-height:1.5715;list-style:none;font-feature-settings:"tnum";color:#00000073;font-size:14px}.ant-breadcrumb .anticon{font-size:14px}.ant-breadcrumb a{color:#00000073;transition:color .3s}.ant-breadcrumb a:hover{color:#de632f}.ant-breadcrumb>span:last-child{color:#000000d9}.ant-breadcrumb>span:last-child a{color:#000000d9}.ant-breadcrumb>span:last-child .ant-breadcrumb-separator{display:none}.ant-breadcrumb-separator{margin:0 8px;color:#00000073}.ant-breadcrumb-link>.anticon+span,.ant-breadcrumb-link>.anticon+a{margin-left:4px}.ant-breadcrumb-overlay-link>.anticon{margin-left:4px}.ant-breadcrumb-rtl{direction:rtl}.ant-breadcrumb-rtl:before{display:table;content:""}.ant-breadcrumb-rtl:after{display:table;clear:both;content:""}.ant-breadcrumb-rtl>span{float:right}.ant-breadcrumb-rtl .ant-breadcrumb-link>.anticon+span,.ant-breadcrumb-rtl .ant-breadcrumb-link>.anticon+a{margin-right:4px;margin-left:0}.ant-breadcrumb-rtl .ant-breadcrumb-overlay-link>.anticon{margin-right:4px;margin-left:0}.nprogress{pointer-events:none}.nprogress .bar{background:#29d;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}.nprogress .peg{display:block;position:absolute;right:0px;width:100px;height:100%;box-shadow:0 0 10px #29d,0 0 5px #29d;opacity:1;-webkit-transform:rotate(3deg) translate(0px,-4px);-ms-transform:rotate(3deg) translate(0px,-4px);transform:rotate(3deg) translateY(-4px)}.nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}.nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border:solid 2px transparent;border-top-color:#29d;border-left-color:#29d;border-radius:50%;-webkit-animation:nprogress-spinner .4s linear infinite;animation:nprogress-spinner .4s linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent .nprogress .spinner,.nprogress-custom-parent .nprogress .bar{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(360deg)}}@keyframes nprogress-spinner{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.base-info[data-v-afd25667]{position:absolute;padding:4px;font-size:.8em;background:var(--zp-primary-background);color:var(--zp-primary);left:0;bottom:0;border-top-right-radius:4px}.location-act[data-v-317d328e]{margin-left:8px}.location-act .copy[data-v-317d328e]{margin-right:4px}@media (max-width: 768px){.location-act[data-v-317d328e]{display:flex;flex-direction:column}.location-act>*[data-v-317d328e],.location-act .copy[data-v-317d328e]{margin:2px}}.breadcrumb[data-v-317d328e]{display:flex;align-items:center}.breadcrumb>*[data-v-317d328e]{margin-right:4px}@media (max-width: 768px){.breadcrumb[data-v-317d328e]{width:100%}.breadcrumb .ant-breadcrumb>*[data-v-317d328e]{display:inline-block}}.container[data-v-317d328e]{background:var(--zp-secondary-background);height:var(--pane-max-height)}.location-bar[data-v-317d328e]{padding:4px 16px;background:var(--zp-primary-background);border-bottom:1px solid var(--zp-border);display:flex;align-items:center;justify-content:space-between}@media (max-width: 768px){.location-bar[data-v-317d328e]{flex-direction:column}.location-bar[data-v-317d328e] ::-webkit-scrollbar{height:2px;background-color:var(--zp-secondary-variant-background)}.location-bar .actions[data-v-317d328e]{padding:4px 0;width:100%;overflow:auto;display:flex;align-items:center}.location-bar .actions>*[data-v-317d328e]{flex-shrink:0}}.location-bar .actions[data-v-317d328e]{display:flex;align-items:center;flex-shrink:0}.location-bar a.opt[data-v-317d328e]{margin-left:8px}.view[data-v-317d328e]{padding:8px;height:calc(100vh - 48px)}.view .file-list[data-v-317d328e]{list-style:none;padding:8px;height:100%;overflow:auto}.hint[data-v-317d328e]{padding:4px;border:4px;background:var(--zp-secondary-background);border:1px solid var(--zp-border)}
.ant-breadcrumb{box-sizing:border-box;margin:0;padding:0;color:#000000d9;font-variant:tabular-nums;line-height:1.5715;list-style:none;font-feature-settings:"tnum";color:#00000073;font-size:14px}.ant-breadcrumb .anticon{font-size:14px}.ant-breadcrumb a{color:#00000073;transition:color .3s}.ant-breadcrumb a:hover{color:#de632f}.ant-breadcrumb>span:last-child{color:#000000d9}.ant-breadcrumb>span:last-child a{color:#000000d9}.ant-breadcrumb>span:last-child .ant-breadcrumb-separator{display:none}.ant-breadcrumb-separator{margin:0 8px;color:#00000073}.ant-breadcrumb-link>.anticon+span,.ant-breadcrumb-link>.anticon+a{margin-left:4px}.ant-breadcrumb-overlay-link>.anticon{margin-left:4px}.ant-breadcrumb-rtl{direction:rtl}.ant-breadcrumb-rtl:before{display:table;content:""}.ant-breadcrumb-rtl:after{display:table;clear:both;content:""}.ant-breadcrumb-rtl>span{float:right}.ant-breadcrumb-rtl .ant-breadcrumb-link>.anticon+span,.ant-breadcrumb-rtl .ant-breadcrumb-link>.anticon+a{margin-right:4px;margin-left:0}.ant-breadcrumb-rtl .ant-breadcrumb-overlay-link>.anticon{margin-right:4px;margin-left:0}.nprogress{pointer-events:none}.nprogress .bar{background:#29d;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}.nprogress .peg{display:block;position:absolute;right:0px;width:100px;height:100%;box-shadow:0 0 10px #29d,0 0 5px #29d;opacity:1;-webkit-transform:rotate(3deg) translate(0px,-4px);-ms-transform:rotate(3deg) translate(0px,-4px);transform:rotate(3deg) translateY(-4px)}.nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}.nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border:solid 2px transparent;border-top-color:#29d;border-left-color:#29d;border-radius:50%;-webkit-animation:nprogress-spinner .4s linear infinite;animation:nprogress-spinner .4s linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent .nprogress .spinner,.nprogress-custom-parent .nprogress .bar{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(360deg)}}@keyframes nprogress-spinner{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.base-info[data-v-7f9c0b15]{position:absolute;padding:4px;font-size:.8em;background:var(--zp-primary-background);color:var(--zp-primary);left:0;bottom:0;border-top-right-radius:4px}.location-act[data-v-8d4bd42a]{margin-left:8px}.location-act .copy[data-v-8d4bd42a]{margin-right:4px}@media (max-width: 768px){.location-act[data-v-8d4bd42a]{display:flex;flex-direction:column}.location-act>*[data-v-8d4bd42a],.location-act .copy[data-v-8d4bd42a]{margin:2px}}.breadcrumb[data-v-8d4bd42a]{display:flex;align-items:center}.breadcrumb>*[data-v-8d4bd42a]{margin-right:4px}@media (max-width: 768px){.breadcrumb[data-v-8d4bd42a]{width:100%}.breadcrumb .ant-breadcrumb>*[data-v-8d4bd42a]{display:inline-block}}.container[data-v-8d4bd42a]{background:var(--zp-secondary-background);height:var(--pane-max-height)}.location-bar[data-v-8d4bd42a]{padding:4px 16px;background:var(--zp-primary-background);border-bottom:1px solid var(--zp-border);display:flex;align-items:center;justify-content:space-between}@media (max-width: 768px){.location-bar[data-v-8d4bd42a]{flex-direction:column}.location-bar[data-v-8d4bd42a] ::-webkit-scrollbar{height:2px;background-color:var(--zp-secondary-variant-background)}.location-bar .actions[data-v-8d4bd42a]{padding:4px 0;width:100%;overflow:auto;display:flex;align-items:center}.location-bar .actions>*[data-v-8d4bd42a]{flex-shrink:0}}.location-bar .actions[data-v-8d4bd42a]{display:flex;align-items:center;flex-shrink:0}.location-bar a.opt[data-v-8d4bd42a]{margin-left:8px}.view[data-v-8d4bd42a]{padding:8px;height:calc(100vh - 48px)}.view .file-list[data-v-8d4bd42a]{list-style:none;padding:8px;height:100%;overflow:auto}.hint[data-v-8d4bd42a]{padding:4px;border:4px;background:var(--zp-secondary-background);border:1px solid var(--zp-border)}

1
vue/dist/assets/stackView-e9d82d72.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{u as G,g as d}from"./FileItem-9be5bb5d.js";import{r as b,t as j,ct as m,cu as y,cv as D}from"./index-db391c6a.js";const r=new Map,A=()=>{const{useEventListen:k,sortedFiles:f,getViewableAreaFiles:w}=G().toRefs(),c=b(d.defaultChangeIndchecked),u=b(d.defaultSeedChangeChecked),g=async()=>{if(await j(100),!c.value)return;const o=w.value().filter(e=>m(e.fullpath)&&!e.gen_info_obj);if(!o.length)return;const t=await y(o.map(e=>e.fullpath).filter(e=>!r.has(e)));o.forEach(e=>{const i=t[e.fullpath]||r.get(e.fullpath)||"";r.set(e.fullpath,i),e.gen_info_obj=D(i),e.gen_info_raw=i})};k.value("viewableAreaFilesChange",g);const F=o=>{const t=f.value;return[o,u.value,t[o-1],t[o],t[o+1]]};function I(o,t,e,i){const a={diff:{},empty:!0,ownFile:"",otherFile:""};if(t+e<0||t+e>=f.value.length||f.value[t]==null||!("gen_info_obj"in f.value[t])||!("gen_info_obj"in f.value[t+e]))return a;const l=o,s=f.value[t+e].gen_info_obj;if(s==null)return a;const h=["hashes","resources"];a.diff={},a.ownFile=i.name,a.otherFile=f.value[t+e].name,a.empty=!1,u.value||h.push("seed");for(const n in l)if(!h.includes(n)){if(!(n in s)){a.diff[n]="+";continue}if(l[n]!=s[n])if(n.includes("rompt")&&l[n]!=""&&s[n]!=""){const p=l[n].split(","),C=s[n].split(",");let _=0;for(const v in p)p[v]!=C[v]&&_++;a.diff[n]=_}else a.diff[n]=[l[n],s[n]]}return a}return{getGenDiff:I,changeIndchecked:c,seedChangeChecked:u,getRawGenParams:()=>g(),getGenDiffWatchDep:F}};export{A as u};

View File

@ -161,4 +161,94 @@ export interface RenameFileParams {
export const renameFile = async (data: RenameFileParams) => {
const resp = await axiosInst.value.post('/db/rename', data)
return resp.data as Promise<{ new_path:string }>
}
// ===== Natural language topic clustering =====
export interface BuildIibOutputEmbeddingsReq {
folder?: string
model?: string
force?: boolean
batch_size?: number
max_chars?: number
}
export interface BuildIibOutputEmbeddingsResp {
folder: string
count: number
updated: number
skipped: number
model: string
}
export const buildIibOutputEmbeddings = async (req: BuildIibOutputEmbeddingsReq) => {
const resp = await axiosInst.value.post('/db/build_iib_output_embeddings', req)
return resp.data as BuildIibOutputEmbeddingsResp
}
export interface ClusterIibOutputReq {
folder?: string
folder_paths?: string[]
model?: string
force_embed?: boolean
threshold?: number
batch_size?: number
max_chars?: number
min_cluster_size?: number
lang?: string
// advanced (backend-supported; optional)
force_title?: boolean
use_title_cache?: boolean
assign_noise_threshold?: number
}
export interface ClusterIibOutputResp {
folder: string
model: string
threshold: number
min_cluster_size: number
count: number
clusters: Array<{
id: string
title: string
size: number
paths: string[]
sample_prompt: string
}>
noise: string[]
}
export const clusterIibOutput = async (req: ClusterIibOutputReq) => {
const resp = await axiosInst.value.post('/db/cluster_iib_output', req, { timeout: Infinity })
return resp.data as ClusterIibOutputResp
}
// ===== Natural language prompt query (RAG-like retrieval) =====
export interface PromptSearchReq {
query: string
folder?: string
folder_paths?: string[]
model?: string
top_k?: number
min_score?: number
ensure_embed?: boolean
max_chars?: number
}
export interface PromptSearchResp {
query: string
folder: string
model: string
count: number
top_k: number
results: Array<{
id: number
path: string
score: number
sample_prompt: string
}>
}
export const searchIibOutputByPrompt = async (req: PromptSearchReq) => {
const resp = await axiosInst.value.post('/db/search_iib_output_by_prompt', req, { timeout: Infinity })
return resp.data as PromptSearchResp
}

View File

@ -30,7 +30,7 @@ export const getTargetFolderFiles = async (folder_path: string) => {
export const deleteFiles = async (file_paths: string[]) => {
const resp = await axiosInst.value.post('/delete_files', { file_paths })
return resp.data as { files: FileNodeInfo[] }
return resp.data as { ok: true }
}
export const moveFiles = async (

View File

@ -154,6 +154,15 @@ export const getGlobalSetting = async () => {
return data
}
/**
* global_setting app_fe_setting isSync()
* 使 KVGlobalSetting 使
*/
export const getGlobalSettingRaw = async () => {
const resp = await axiosInst.value.get('/global_setting')
return resp.data as GlobalConf
}
export const getVersion = async () => {
const resp = await axiosInst.value.get('/version')
return resp.data as { hash?: string, tag?: string }
@ -207,6 +216,14 @@ export const setAppFeSetting = async (name: keyof GlobalConf['app_fe_setting'],
await axiosInst.value.post('/app_fe_setting', { name, value: JSON.stringify(setting) })
}
/**
* app_fe_setting KV isSync()
* / TopicSearch
*/
export const setAppFeSettingForce = async (name: string, setting: Record<string, any>) => {
await axiosInst.value.post('/app_fe_setting', { name, value: JSON.stringify(setting) })
}
export const removeAppFeSetting = async (name: keyof GlobalConf['app_fe_setting']) => {
if (!isSync()) return
await axiosInst.value.delete('/app_fe_setting', { data: { name } })
@ -214,4 +231,39 @@ export const removeAppFeSetting = async (name: keyof GlobalConf['app_fe_setting'
export const setTargetFrameAsCover = async (body: { path: string, base64_img: string, updated_time: string }) => {
await axiosInst.value.post('/set_target_frame_as_video_cover', body)
}
// AI 相关 API
export interface AIChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface AIChatRequest {
messages: AIChatMessage[]
temperature?: number
max_tokens?: number
stream?: boolean
}
export interface AIChatResponse {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
message: AIChatMessage
finish_reason: string
}>
usage: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
}
export const aiChat = async (req: AIChatRequest) => {
const resp = await axiosInst.value.post('/ai-chat', req)
return resp.data as AIChatResponse
}

View File

@ -20,6 +20,32 @@ export const de: Partial<IIBI18nMap> = {
exclude: 'Ausschliessen',
showTiktokNavigator: 'Navigationsschaltflächen anzeigen',
showTiktokNavigatorDesc: 'Zeigen Sie die Navigationstasten (Pfeile nach oben/unten) in der TikTok-Ansicht an',
// ===== Topic Search (Experimental) =====
topicSearchExperimental: 'Natürliche Sprach-Kategorisierung & Suche (Experimentell)',
topicSearchTitleExperimental: 'Natürliche Sprach-Kategorisierung & Suche (Experimentell)',
topicSearchScope: 'Bereich',
topicSearchNeedScope: 'Bitte zuerst einen Bereich (Ordner) auswählen',
topicSearchQueryPlaceholder: 'Geben Sie einen Satz ein, um ähnliche Bilder zu finden (RAG-ähnliche Suche)',
topicSearchOpenResults: 'Ergebnisse öffnen',
topicSearchThreshold: 'Schwelle',
topicSearchMinClusterSize: 'Min. Cluster',
topicSearchEmptyNoScope: 'Bitte zuerst einen Bereich auswählen, dann aktualisieren/kategorisieren',
topicSearchEmptyNoTopics: 'Noch keine Themen (versuchen Sie „Min. Cluster“ zu verringern oder „Schwelle“ anzupassen)',
topicSearchChooseScope: 'Bereich auswählen',
topicSearchRefreshAndCluster: 'Aktualisieren/Kategorisieren',
topicSearchScopeModalTitle: 'Vektorisierungsbereich auswählen (Ordner)',
topicSearchScopeTip: 'Standardmäßig ist kein Bereich aktiviert. Sie müssen Ordner auswählen, um zu kategorisieren/suchen. Bereich stammt aus QuickMovePaths.',
topicSearchSavingToBackend: 'Wird im Backend gespeichert...',
topicSearchScopePlaceholder: 'Ordner auswählen (Mehrfachauswahl)',
topicSearchRecallMsg: '{0} / {1} abgerufen (TopK={2})',
topicSearchGuideTitle: 'Schnellstart (Experimentell)',
topicSearchGuideStep1: 'Wählen Sie die Ordner (Bereich) zur Analyse aus (Mehrfachauswahl)',
topicSearchGuideStep2: 'Klicken Sie auf „Aktualisieren“, um Themenkarten zu erzeugen (inkrementelle Vektorisierung)',
topicSearchGuideStep3: 'Geben Sie einen Satz ein, um zu suchen; ähnliche Bilder werden abgerufen und die Ergebnisse geöffnet',
topicSearchGuideEmptyReasonNoScope: 'Leer, weil: kein Bereich ausgewählt (standardmäßig deaktiviert). Klicken Sie auf „Bereich“, um Ordner zu wählen.',
topicSearchGuideEmptyReasonNoTopics: 'Leer, weil: für diesen Bereich noch keine Themen erzeugt wurden (Aktualisieren oder Min. Cluster/Schwelle senken).',
'auto.refreshed': 'Automatische Aktualisierung erfolgreich durchgeführt!',
copied: 'In die Zwischenablage kopiert!',
'index.expired': 'Index abgelaufen, automatische Aktualisierung wird durchgeführt',
@ -81,6 +107,7 @@ export const de: Partial<IIBI18nMap> = {
'Fehler beim Senden des Bildes. Bitte kontaktieren Sie den Entwickler mit der Fehlermeldung aus der Konsole.',
confirmDelete: 'Sind Sie sicher, dass Sie dies löschen möchten?',
deleteSuccess: 'Erfolgreich gelöscht',
moveToTrashSuccess: 'In den Papierkorb verschoben',
doubleClickToCopy: 'Doppelklick zum Kopieren',
root: 'Root',
drive: ' Laufwerk',

View File

@ -20,6 +20,32 @@ export const en: IIBI18nMap = {
clearCacheIfNotTakeEffect: 'If the changes do not take effect, try clearing the page cache',
showTiktokNavigator: 'Show Navigation Buttons',
showTiktokNavigatorDesc: 'Show the navigation buttons (up/down arrows) in TikTok view',
// ===== Topic Search (Experimental) =====
topicSearchExperimental: 'Natural Language Categorization & Search (Experimental)',
topicSearchTitleExperimental: 'Natural Language Categorization & Search (Experimental)',
topicSearchScope: 'Scope',
topicSearchNeedScope: 'Please select a scope folder first',
topicSearchQueryPlaceholder: 'Type a sentence to retrieve similar images (RAG-like retrieval)',
topicSearchOpenResults: 'Open results',
topicSearchThreshold: 'Threshold',
topicSearchMinClusterSize: 'Min cluster',
topicSearchEmptyNoScope: 'Please select a scope, then refresh/categorize',
topicSearchEmptyNoTopics: 'No topics yet (try lowering “Min cluster” or adjusting “Threshold”)',
topicSearchChooseScope: 'Select scope',
topicSearchRefreshAndCluster: 'Refresh/Categorize',
topicSearchScopeModalTitle: 'Select vectorization scope (folders)',
topicSearchScopeTip: 'No scope is enabled by default. You must select folders to categorize/search. Scope comes from QuickMovePaths.',
topicSearchSavingToBackend: 'Saving to backend...',
topicSearchScopePlaceholder: 'Select folders (multi-select)',
topicSearchRecallMsg: 'Retrieved {0} / {1} (TopK={2})',
topicSearchGuideTitle: 'Quick Start (Experimental)',
topicSearchGuideStep1: 'Select the scope folders to analyze (multi-select)',
topicSearchGuideStep2: 'Click Refresh to generate topic cards (incremental vectorization)',
topicSearchGuideStep3: 'Type a sentence to search; it will retrieve similar images and open the result page',
topicSearchGuideEmptyReasonNoScope: 'Empty because: no scope selected (disabled by default). Click “Scope” to choose folders.',
topicSearchGuideEmptyReasonNoTopics: 'Empty because: no topics generated yet for this scope (try Refresh or lower Min cluster/Threshold).',
success: 'Success',
setCurrFrameAsVideoPoster: 'Set Current Frame as Video Cover',
sync: 'Sync',
@ -269,6 +295,7 @@ You can specify which snapshot to restore to when starting IIB in the global set
'Failed to send image. Please contact the developer with the error message from the console.',
confirmDelete: 'Are you sure you want to delete?',
deleteSuccess: 'Deleted successfully',
moveToTrashSuccess: 'Moved to trash',
doubleClickToCopy: 'Double-click to copy',
root: 'Root',
drive: ' drive',
@ -384,5 +411,6 @@ You can specify which snapshot to restore to when starting IIB in the global set
'autoTag.fields.seed': 'Seed',
'autoTag.operators.contains': 'Contains',
'autoTag.operators.equals': 'Equals',
'autoTag.operators.regex': 'Regex'
'autoTag.operators.regex': 'Regex',
aiAnalyzeTags: 'AI Analyze Tags'
}

View File

@ -18,6 +18,32 @@ export const zhHans = {
clearCacheIfNotTakeEffect: '如果更改没有生效,请尝试清理页面缓存',
showTiktokNavigator: '显示导航按钮',
showTiktokNavigatorDesc: '在 TikTok 视图中显示导航按钮(上/下箭头)',
// ===== Topic Search (Experimental) =====
topicSearchExperimental: '自然语言分类&搜索(实验性)',
topicSearchTitleExperimental: '自然语言分类 & 搜索(实验性)',
topicSearchScope: '范围',
topicSearchNeedScope: '请先选择向量化范围(文件夹)',
topicSearchQueryPlaceholder: '输入一句话召回相似图片RAG 召回)',
topicSearchOpenResults: '打开结果',
topicSearchThreshold: '阈值',
topicSearchMinClusterSize: '最小组',
topicSearchEmptyNoScope: '请先选择范围,然后刷新/归类',
topicSearchEmptyNoTopics: '暂无主题结果(可尝试降低“最小组”或调整“阈值”)',
topicSearchChooseScope: '选择范围',
topicSearchRefreshAndCluster: '刷新/归类',
topicSearchScopeModalTitle: '选择向量化范围(文件夹)',
topicSearchScopeTip: '默认不启用任何范围;必须选择后才能归类/搜索。范围来源于 QuickMovePaths。',
topicSearchSavingToBackend: '正在保存到后端...',
topicSearchScopePlaceholder: '选择文件夹(可多选)',
topicSearchRecallMsg: '召回 {0} / {1}TopK={2}',
topicSearchGuideTitle: '快速上手(实验性)',
topicSearchGuideStep1: '选择要分析的文件夹范围(可多选)',
topicSearchGuideStep2: '点击刷新,生成主题卡片(会增量向量化)',
topicSearchGuideStep3: '输入一句话搜索,会召回相似图片并打开结果页',
topicSearchGuideEmptyReasonNoScope: '当前为空:未选择范围(已默认关闭),请先点“范围”选择文件夹',
topicSearchGuideEmptyReasonNoTopics: '当前为空:该范围内还未生成主题(可点刷新,或调低最小组/阈值)',
success: '成功',
setCurrFrameAsVideoPoster: '设置当前帧为视频封面',
sync: '同步',
@ -128,6 +154,7 @@ export const zhHans = {
sendImageFailed: '发送图像失败请携带console的错误消息找开发者',
confirmDelete: '确认删除?',
deleteSuccess: '删除成功',
moveToTrashSuccess: '已移动到回收站',
doubleClickToCopy: '双击复制',
root: '根',
drive: '盘',
@ -362,5 +389,6 @@ export const zhHans = {
'autoTag.fields.seed': 'Seed',
'autoTag.operators.contains': '包含 (Contains)',
'autoTag.operators.equals': '等于 (Equals)',
'autoTag.operators.regex': '正则 (Regex)'
'autoTag.operators.regex': '正则 (Regex)',
aiAnalyzeTags: 'AI分析标签'
}

View File

@ -20,6 +20,32 @@ export const zhHant: Partial<IIBI18nMap> = {
addNewCustomTag: '添加新的自定義標籤',
showTiktokNavigator: '顯示導航按鈕',
showTiktokNavigatorDesc: '在 TikTok 視圖中顯示導航按鈕(上/下箭頭)',
// ===== Topic Search (Experimental) =====
topicSearchExperimental: '自然語言分類&搜尋(實驗性)',
topicSearchTitleExperimental: '自然語言分類 & 搜尋(實驗性)',
topicSearchScope: '範圍',
topicSearchNeedScope: '請先選擇向量化範圍(資料夾)',
topicSearchQueryPlaceholder: '輸入一句話召回相似圖片RAG 召回)',
topicSearchOpenResults: '打開結果',
topicSearchThreshold: '閾值',
topicSearchMinClusterSize: '最小組',
topicSearchEmptyNoScope: '請先選擇範圍,然後刷新/歸類',
topicSearchEmptyNoTopics: '暫無主題結果(可嘗試降低「最小組」或調整「閾值」)',
topicSearchChooseScope: '選擇範圍',
topicSearchRefreshAndCluster: '刷新/歸類',
topicSearchScopeModalTitle: '選擇向量化範圍(資料夾)',
topicSearchScopeTip: '預設不啟用任何範圍;必須選擇後才能歸類/搜尋。範圍來源於 QuickMovePaths。',
topicSearchSavingToBackend: '正在保存到後端...',
topicSearchScopePlaceholder: '選擇資料夾(可多選)',
topicSearchRecallMsg: '召回 {0} / {1}TopK={2}',
topicSearchGuideTitle: '快速上手(實驗性)',
topicSearchGuideStep1: '選擇要分析的資料夾範圍(可多選)',
topicSearchGuideStep2: '點擊刷新,生成主題卡片(會增量向量化)',
topicSearchGuideStep3: '輸入一句話搜尋,召回相似圖片並打開結果頁',
topicSearchGuideEmptyReasonNoScope: '目前為空:尚未選擇範圍(預設關閉),請先點「範圍」選擇資料夾',
topicSearchGuideEmptyReasonNoTopics: '目前為空:此範圍尚未生成主題(可點刷新,或調低最小組/閾值)',
clearCacheIfNotTakeEffect: '如果更改沒有生效,請嘗試清理頁面緩存',
success: '成功',
setCurrFrameAsVideoPoster: '設置當前幀為視頻封面',
@ -134,6 +160,7 @@ export const zhHant: Partial<IIBI18nMap> = {
sendImageFailed: '發送圖像失敗,請攜帶console的錯誤訊息找開發者',
confirmDelete: '確認刪除?',
deleteSuccess: '刪除成功',
moveToTrashSuccess: '已移動到回收站',
doubleClickToCopy: '雙擊複製',
promptcompare: 'Compare Prompts',
root: '根',

View File

@ -21,8 +21,12 @@ const compMap: Record<TabPane['type'], ReturnType<typeof defineAsyncComponent>>
'tag-search-matched-image-grid': defineAsyncComponent(
() => import('@/page/TagSearch/MatchedImageGrid.vue')
),
'topic-search-matched-image-grid': defineAsyncComponent(
() => import('@/page/TopicSearch/MatchedImageGrid.vue')
),
'tag-search': defineAsyncComponent(() => import('@/page/TagSearch/TagSearch.vue')),
'fuzzy-search': defineAsyncComponent(() => import('@/page/TagSearch/SubstrSearch.vue')),
'topic-search': defineAsyncComponent(() => import('@/page/TopicSearch/TopicSearch.vue')),
'img-sli': defineAsyncComponent(() => import('@/page/ImgSli/ImgSliPagePane.vue')),
'batch-download': defineAsyncComponent(() => import('@/page/batchDownload/batchDownload.vue')),
'grid-view': defineAsyncComponent(() => import('@/page/gridView/gridView.vue')),

View File

@ -40,6 +40,7 @@ const compCnMap: Partial<Record<TabPane['type'], string>> = {
local: t('local'),
'tag-search': t('imgSearch'),
'fuzzy-search': t('fuzzy-search'),
'topic-search': t('topicSearchExperimental'),
'batch-download': t('batchDownload') + ' / ' + t('archive'),
'workspace-snapshot': t('WorkspaceSnapshot'),
'random-image': t('randomImage'),
@ -51,6 +52,7 @@ const createPane = (type: TabPane['type'], path?: string, mode?: FileTransModeIn
switch (type) {
case 'grid-view':
case 'tag-search-matched-image-grid':
case 'topic-search-matched-image-grid':
case 'img-sli':
return
case 'global-setting':
@ -58,6 +60,7 @@ const createPane = (type: TabPane['type'], path?: string, mode?: FileTransModeIn
case 'batch-download':
case 'workspace-snapshot':
case 'fuzzy-search':
case 'topic-search':
case 'random-image':
case 'empty':
pane = { type, name: compCnMap[type]!, key: Date.now() + uniqueId() }

View File

@ -0,0 +1,264 @@
<script lang="ts" setup>
import fileItemCell from '@/components/FileItem.vue'
import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css'
// @ts-ignore
import { RecycleScroller } from '@zanllp/vue-virtual-scroller'
import { toRawFileUrl } from '@/util/file'
import { nextTick, watch, reactive } from 'vue'
import { copy2clipboardI18n } from '@/util'
import fullScreenContextMenu from '@/page/fileTransfer/fullScreenContextMenu.vue'
import { LeftCircleOutlined, RightCircleOutlined } from '@/icon'
import { useImageSearch } from '@/page/TagSearch/hook'
import { useGlobalStore } from '@/store/useGlobalStore'
import { useKeepMultiSelect } from '@/page/fileTransfer/hook'
import { openTiktokViewWithFiles } from '@/util/tiktokHelper'
import { batchGetFilesInfo } from '@/api/files'
const props = defineProps<{
tabIdx: number
paneIdx: number
id: string
title: string
paths: string[]
}>()
type Iter = {
res: any[]
load: boolean
loading: boolean
next: () => Promise<void>
reset: (opts?: { refetch?: boolean }) => Promise<void>
}
const iter = reactive<Iter>({
res: [],
load: false,
loading: false,
async next() {
if (iter.loading || iter.load) return
iter.loading = true
try {
const pageSize = 200
const offset = iter.res.length
const slice = (props.paths ?? []).slice(offset, offset + pageSize)
if (!slice.length) {
iter.load = true
return
}
const infoMap = await batchGetFilesInfo(slice)
const files = slice.map((p) => infoMap[p]).filter(Boolean)
iter.res.push(...files)
if (offset + pageSize >= (props.paths?.length ?? 0)) {
iter.load = true
}
} finally {
iter.loading = false
}
},
async reset() {
iter.res = []
iter.load = false
await iter.next()
}
})
const {
queue,
images,
onContextMenuClickU,
stackViewEl,
previewIdx,
previewing,
onPreviewVisibleChange,
previewImgMove,
canPreview,
itemSize,
gridItems,
showGenInfo,
imageGenInfo,
q: genInfoQueue,
multiSelectedIdxs,
onFileItemClick,
scroller,
showMenuIdx,
onFileDragStart,
onFileDragEnd,
cellWidth,
onScroll,
saveAllFileAsJson,
saveLoadedFileAsJson,
changeIndchecked,
seedChangeChecked,
getGenDiff,
getGenDiffWatchDep
} = useImageSearch(iter as any)
watch(
() => props.paths,
async () => {
await iter.reset({ refetch: true } as any)
await nextTick()
scroller.value?.scrollToItem(0)
onScroll()
},
{ immediate: true }
)
const g = useGlobalStore()
const { onClearAllSelected, onSelectAll, onReverseSelect } = useKeepMultiSelect()
const onTiktokViewClick = () => {
if (images.value.length === 0) return
openTiktokViewWithFiles(images.value, 0)
}
</script>
<template>
<div class="container" ref="stackViewEl">
<MultiSelectKeep
:show="!!multiSelectedIdxs.length || g.keepMultiSelect"
@clear-all-selected="onClearAllSelected"
@select-all="onSelectAll"
@reverse-select="onReverseSelect"
/>
<ASpin size="large" :spinning="!queue.isIdle || iter.loading">
<AModal v-model:visible="showGenInfo" width="70vw" mask-closable @ok="showGenInfo = false">
<template #cancelText />
<ASkeleton active :loading="!genInfoQueue.isIdle">
<div
style="
width: 100%;
word-break: break-all;
white-space: pre-line;
max-height: 70vh;
overflow: auto;
"
@dblclick="copy2clipboardI18n(imageGenInfo)"
>
<div class="hint">{{ $t('doubleClickToCopy') }}</div>
{{ imageGenInfo }}
</div>
</ASkeleton>
</AModal>
<div class="action-bar">
<div class="title line-clamp-1">🧩 {{ props.title }}</div>
<div flex-placeholder />
<a-button @click="onTiktokViewClick" :disabled="!images?.length">{{ $t('tiktokView') }}</a-button>
<a-button @click="saveLoadedFileAsJson" :disabled="!images?.length">{{ $t('saveLoadedImageAsJson') }}</a-button>
<a-button @click="saveAllFileAsJson" :disabled="!images?.length">{{ $t('saveAllAsJson') }}</a-button>
</div>
<RecycleScroller
ref="scroller"
class="file-list"
v-if="images?.length"
:items="images"
:item-size="itemSize.first"
key-field="fullpath"
:item-secondary-size="itemSize.second"
:gridItems="gridItems"
@scroll="onScroll"
>
<template #after>
<div style="padding: 16px 0 512px;" />
</template>
<template v-slot="{ item: file, index: idx }">
<file-item-cell
:idx="idx"
:file="file"
:cell-width="cellWidth"
v-model:show-menu-idx="showMenuIdx"
@dragstart="onFileDragStart"
@dragend="onFileDragEnd"
@file-item-click="onFileItemClick"
@tiktok-view="(_file, idx) => openTiktokViewWithFiles(images, idx)"
:full-screen-preview-image-url="images[previewIdx] ? toRawFileUrl(images[previewIdx]) : ''"
:selected="multiSelectedIdxs.includes(idx)"
@context-menu-click="onContextMenuClickU"
@preview-visible-change="onPreviewVisibleChange"
:is-selected-mutil-files="multiSelectedIdxs.length > 1"
:enable-change-indicator="changeIndchecked"
:seed-change-checked="seedChangeChecked"
:get-gen-diff="getGenDiff"
:get-gen-diff-watch-dep="getGenDiffWatchDep"
/>
</template>
</RecycleScroller>
<div v-else class="no-res-hint">
<p class="hint">暂无结果</p>
</div>
<div v-if="previewing" class="preview-switch">
<LeftCircleOutlined @click="previewImgMove('prev')" :class="{ disable: !canPreview('prev') }" />
<RightCircleOutlined @click="previewImgMove('next')" :class="{ disable: !canPreview('next') }" />
</div>
</ASpin>
<fullScreenContextMenu
v-if="previewing && images && images[previewIdx]"
:file="images[previewIdx]"
:idx="previewIdx"
@context-menu-click="onContextMenuClickU"
/>
</div>
</template>
<style scoped lang="scss">
.container {
background: var(--zp-secondary-background);
position: relative;
height: var(--pane-max-height);
}
.action-bar {
display: flex;
align-items: center;
user-select: none;
gap: 6px;
padding: 6px 8px;
}
.title {
font-weight: 700;
max-width: 40vw;
}
.file-list {
list-style: none;
padding: 8px;
overflow: auto;
height: calc(var(--pane-max-height) - 44px);
width: 100%;
}
.no-res-hint {
height: calc(var(--pane-max-height) - 44px);
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
.hint {
font-size: 1.2em;
opacity: 0.7;
}
}
.preview-switch {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
gap: 8px;
font-size: 36px;
user-select: none;
}
.disable {
opacity: 0.3;
pointer-events: none;
}
</style>

View File

@ -0,0 +1,472 @@
<script setup lang="ts">
import { getGlobalSettingRaw, setAppFeSettingForce } from '@/api'
import { clusterIibOutput, searchIibOutputByPrompt, type ClusterIibOutputResp, type PromptSearchResp } from '@/api/db'
import { t } from '@/i18n'
import { useGlobalStore } from '@/store/useGlobalStore'
import { computed, onMounted, ref, watch } from 'vue'
import { uniqueId } from 'lodash-es'
import { message } from 'ant-design-vue'
const props = defineProps<{ tabIdx: number; paneIdx: number }>()
const g = useGlobalStore()
const loading = ref(false)
const threshold = ref(0.86)
const minClusterSize = ref(2)
const result = ref<ClusterIibOutputResp | null>(null)
const query = ref('')
const qLoading = ref(false)
const qResult = ref<PromptSearchResp | null>(null)
const scopeOpen = ref(false)
const selectedFolders = ref<string[]>([])
const _scopeInitDone = ref(false)
const _SCOPE_SETTING_NAME = 'topic_search_scope'
const _saving = ref(false)
let _saveTimer: any = null
let _lastSavedSig = ''
const excludedDirs = computed(() => {
const list = (g.quickMovePaths ?? []) as any[]
return list
.filter((v) => {
const key = String(v?.key ?? '')
const types = (v?.types ?? []) as string[]
// 3 / home /
return types.includes('preset') && ['cwd', 'home', 'desktop'].includes(key)
})
.map((v) => String(v?.dir ?? ''))
.filter(Boolean)
})
watch(
excludedDirs,
(dirs) => {
if (!dirs?.length) return
selectedFolders.value = (selectedFolders.value ?? []).filter((p) => !dirs.includes(p))
},
{ immediate: true }
)
const folderOptions = computed(() => {
const list = (g.quickMovePaths ?? []) as any[]
return list
.filter((v) => {
const key = String(v?.key ?? '')
const types = (v?.types ?? []) as string[]
return !(types.includes('preset') && ['cwd', 'home', 'desktop'].includes(key))
})
.map((v) => ({ value: v.dir, label: v.zh || v.dir }))
})
const scopeCount = computed(() => (selectedFolders.value ?? []).filter(Boolean).length)
const scopeFolders = computed(() => (selectedFolders.value ?? []).filter(Boolean))
const clusters = computed(() => (result.value?.clusters ?? []).slice(0, 12))
const loadScopeFromBackend = async () => {
if (_scopeInitDone.value) return
try {
const conf = await getGlobalSettingRaw()
// store
g.conf = conf as any
} catch (e) {
// ignoreaxios interceptor
}
const app = (g.conf?.app_fe_setting as any) || {}
const savedPaths = app?.[_SCOPE_SETTING_NAME]?.folder_paths
if (Array.isArray(savedPaths) && savedPaths.length && (!selectedFolders.value?.length)) {
selectedFolders.value = savedPaths.map((x: any) => String(x)).filter(Boolean)
}
_scopeInitDone.value = true
}
const saveScopeToBackend = async () => {
if (g.conf?.is_readonly) return
if (!_scopeInitDone.value) return
const payload = {
folder_paths: scopeFolders.value,
updated_at: Date.now()
}
await setAppFeSettingForce(_SCOPE_SETTING_NAME, payload)
if (g.conf?.app_fe_setting) {
(g.conf.app_fe_setting as any)[_SCOPE_SETTING_NAME] = payload
}
}
const scheduleSaveScope = () => {
if (g.conf?.is_readonly) return
if (!_scopeInitDone.value) return
const sig = JSON.stringify(scopeFolders.value)
if (sig === _lastSavedSig) return
if (_saveTimer) clearTimeout(_saveTimer)
_saveTimer = setTimeout(async () => {
_saving.value = true
try {
await saveScopeToBackend()
_lastSavedSig = sig
} finally {
_saving.value = false
}
}, 500)
}
const refresh = async () => {
if (g.conf?.is_readonly) return
if (!scopeCount.value) {
message.warning(t('topicSearchNeedScope'))
scopeOpen.value = true
return
}
loading.value = true
try {
result.value = await clusterIibOutput({
threshold: threshold.value,
min_cluster_size: minClusterSize.value,
lang: g.lang,
folder_paths: scopeFolders.value
})
} finally {
loading.value = false
}
}
const runQuery = async () => {
const q = (query.value || '').trim()
if (!q) return
if (!scopeCount.value) {
message.warning(t('topicSearchNeedScope'))
scopeOpen.value = true
return
}
qLoading.value = true
try {
qResult.value = await searchIibOutputByPrompt({
query: q,
top_k: 80,
ensure_embed: true,
folder_paths: scopeFolders.value
})
//
openQueryResult()
} finally {
qLoading.value = false
}
}
const openQueryResult = () => {
const paths = (qResult.value?.results ?? []).map((r) => r.path).filter(Boolean)
if (!paths.length) return
const title = `Query: ${query.value.trim()}${paths.length}`
const pane = {
type: 'topic-search-matched-image-grid' as const,
name: title,
key: Date.now() + uniqueId(),
id: uniqueId(),
title,
paths
}
const tab = g.tabList[props.tabIdx]
tab.panes.push(pane as any)
tab.key = (pane as any).key
}
const openCluster = (item: ClusterIibOutputResp['clusters'][0]) => {
const pane = {
type: 'topic-search-matched-image-grid' as const,
name: `${item.title}${item.size}`,
key: Date.now() + uniqueId(),
id: uniqueId(),
title: item.title,
paths: item.paths
}
const tab = g.tabList[props.tabIdx]
tab.panes.push(pane as any)
tab.key = (pane as any).key
}
onMounted(() => {
//
void (async () => {
await loadScopeFromBackend()
if (scopeCount.value) {
await refresh()
}
})()
})
watch(
() => scopeFolders.value,
() => {
// OK
scheduleSaveScope()
},
{ deep: true }
)
</script>
<template>
<div class="topic-search">
<div class="toolbar">
<div class="left">
<div class="title">
<span class="icon">🧠</span>
<span>{{ $t('topicSearchTitleExperimental') }}</span>
</div>
<a-tag v-if="result" color="blue"> {{ result.count }} </a-tag>
<a-tag v-if="result" color="geekblue"> {{ result.clusters.length }}</a-tag>
<a-tag v-if="result" color="default"> {{ result.noise.length }}</a-tag>
</div>
<div class="right">
<a-button @click="scopeOpen = true">
{{ $t('topicSearchScope') }}
<span v-if="scopeCount" style="opacity: 0.75;">{{ scopeCount }}</span>
</a-button>
<a-input
v-model:value="query"
style="width: min(420px, 72vw);"
:placeholder="$t('topicSearchQueryPlaceholder')"
:disabled="qLoading"
@keydown.enter="runQuery"
allow-clear
/>
<a-button :loading="qLoading" @click="runQuery">{{ $t('search') }}</a-button>
<a-button v-if="qResult?.results?.length" @click="openQueryResult">{{ $t('topicSearchOpenResults') }}</a-button>
<span class="label">{{ $t('topicSearchThreshold') }}</span>
<a-input-number v-model:value="threshold" :min="0.5" :max="0.99" :step="0.01" />
<span class="label">{{ $t('topicSearchMinClusterSize') }}</span>
<a-input-number v-model:value="minClusterSize" :min="1" :max="50" :step="1" />
<a-button type="primary" ghost :loading="loading" :disabled="g.conf?.is_readonly" @click="refresh">{{ $t('refresh') }}</a-button>
</div>
</div>
<a-alert
v-if="g.conf?.is_readonly"
type="warning"
:message="$t('readonlyModeSettingPageDesc')"
style="margin: 12px 0;"
show-icon
/>
<a-spin :spinning="loading">
<div v-if="qResult" style="margin-top: 10px;">
<a-alert
type="info"
:message="$t('topicSearchRecallMsg', [qResult.results.length, qResult.count, qResult.top_k])"
show-icon
/>
</div>
<div class="grid" v-if="clusters.length">
<div class="card" v-for="c in clusters" :key="c.id" @click="openCluster(c)">
<div class="card-top">
<div class="card-title line-clamp-1">{{ c.title }}</div>
<div class="card-count">{{ c.size }}</div>
</div>
<div class="card-desc line-clamp-2">{{ c.sample_prompt }}</div>
</div>
</div>
<div class="empty" v-else>
<a-alert
type="info"
show-icon
:message="$t('topicSearchGuideTitle')"
style="margin-bottom: 10px;"
/>
<div class="guide">
<div class="guide-row">
<span class="guide-icon">🗂</span>
<span class="guide-text">{{ $t('topicSearchGuideStep1') }}</span>
<a-button size="small" @click="scopeOpen = true">{{ $t('topicSearchScope') }}</a-button>
</div>
<div class="guide-row">
<span class="guide-icon">🧠</span>
<span class="guide-text">{{ $t('topicSearchGuideStep2') }}</span>
<a-button size="small" :loading="loading" :disabled="g.conf?.is_readonly" @click="refresh">{{ $t('refresh') }}</a-button>
</div>
<div class="guide-row">
<span class="guide-icon">🔎</span>
<span class="guide-text">{{ $t('topicSearchGuideStep3') }}</span>
</div>
<div class="guide-hint">
<span class="guide-icon">💡</span>
<span class="guide-text" v-if="!scopeCount">{{ $t('topicSearchGuideEmptyReasonNoScope') }}</span>
<span class="guide-text" v-else>{{ $t('topicSearchGuideEmptyReasonNoTopics') }}</span>
</div>
</div>
</div>
</a-spin>
<a-modal
v-model:visible="scopeOpen"
:title="$t('topicSearchScopeModalTitle')"
:mask-closable="true"
@ok="
() => {
scopeOpen = false
void saveScopeToBackend()
}
"
>
<a-alert
type="info"
show-icon
:message="$t('topicSearchScopeTip')"
style="margin-bottom: 10px;"
/>
<a-alert
v-if="_saving"
type="info"
show-icon
:message="$t('topicSearchSavingToBackend')"
style="margin-bottom: 10px;"
/>
<a-select
v-model:value="selectedFolders"
mode="multiple"
style="width: 100%;"
:options="folderOptions"
:placeholder="$t('topicSearchScopePlaceholder')"
:max-tag-count="3"
:getPopupContainer="(trigger: HTMLElement) => trigger.parentElement || trigger"
allow-clear
/>
</a-modal>
</div>
</template>
<style scoped lang="scss">
.topic-search {
height: var(--pane-max-height);
overflow: auto;
padding: 12px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: var(--zp-primary-background);
border-radius: 12px;
}
.left {
display: flex;
align-items: center;
gap: 8px;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
}
.right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.label {
color: #888;
}
.guide {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--zp-primary-background);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.guide-row {
display: flex;
align-items: center;
gap: 10px;
}
.guide-hint {
margin-top: 4px;
display: flex;
align-items: center;
gap: 10px;
opacity: 0.85;
}
.guide-icon {
width: 22px;
text-align: center;
flex: 0 0 22px;
}
.guide-text {
flex: 1 1 auto;
min-width: 0;
color: rgba(0, 0, 0, 0.75);
}
.grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.card {
background: var(--zp-primary-background);
border-radius: 12px;
padding: 10px 12px;
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.card:hover {
border-color: rgba(24, 144, 255, 0.6);
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-title {
font-weight: 700;
}
.card-count {
min-width: 28px;
text-align: right;
opacity: 0.75;
}
.card-desc {
margin-top: 6px;
color: #666;
font-size: 12px;
}
.empty {
height: calc(var(--pane-max-height) - 72px);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
}
.hint {
font-size: 16px;
opacity: 0.75;
}
</style>

View File

@ -33,6 +33,8 @@ import { prefix } from '@/util/const'
// @ts-ignore
import * as Pinyin from 'jian-pinyin'
import { Tag } from '@/api/db'
import { aiChat } from '@/api'
import { message } from 'ant-design-vue'
const global = useGlobalStore()
@ -58,9 +60,15 @@ const geninfoStructNoPrompts = computed(() => {
let p = parse(cleanImageGenInfo.value)
delete p.prompt
delete p.negativePrompt
delete p.extraJsonMetaInfo
return p
})
// extraJsonMetaInfo meta使 imageGenInfo HTML
const extraJsonMetaInfo = computed(() => {
const p = parse(imageGenInfo.value) // 使 imageGenInfo cleanImageGenInfo
return p.extraJsonMetaInfo as Record<string, any> | undefined
})
const emit = defineEmits<{
(type: 'contextMenuClick', e: MenuInfo, file: FileNodeInfo, idx: number): void
@ -305,6 +313,80 @@ const onTiktokViewClick = () => {
emit('contextMenuClick', { key: 'tiktokView' } as any, props.file, props.idx)
}
// AItag
const analyzeTagsWithAI = async () => {
if (!geninfoStruct.value.prompt) {
message.warning('没有找到提示词')
return
}
if (!global.conf?.all_custom_tags?.length) {
message.warning('没有自定义标签')
return
}
try {
const prompt = geninfoStruct.value.prompt
const availableTags = global.conf.all_custom_tags.map(tag => tag.name).join(', ')
const systemMessage = `你是一个专业的AI助手负责分析Stable Diffusion提示词并将其分类到相应的标签中。
你的任务是
1. 分析给定的提示词
2. 从提供的标签列表中找出所有相关的标签
3. 只返回匹配的标签名称用逗号分隔
4. 如果没有匹配的标签返回空字符串
5. 标签匹配应该基于语义相似性和主题相关性
可用的标签${availableTags}
请只返回标签名称不要包含其他内容`
const response = await aiChat({
messages: [
{ role: 'system', content: systemMessage },
{ role: 'user', content: `请分析这个提示词并返回匹配的标签:${prompt}` }
],
temperature: 0.3,
max_tokens: 200
})
const matchedTagsText = response.choices[0].message.content.trim()
if (!matchedTagsText) {
message.info('AI没有找到匹配的标签')
return
}
//
const matchedTagNames = matchedTagsText.split(',').map((name: string) => name.trim()).filter((name: string) => name)
// tag
const tagsToAdd = global.conf.all_custom_tags.filter((tag: Tag) =>
matchedTagNames.some((matchedName: string) =>
tag.name.toLowerCase() === matchedName.toLowerCase() ||
tag.name.toLowerCase().includes(matchedName.toLowerCase()) ||
matchedName.toLowerCase().includes(tag.name.toLowerCase())
)
)
if (tagsToAdd.length === 0) {
message.info('没有找到有效的匹配标签')
return
}
// tag
for (const tag of tagsToAdd) {
emit('contextMenuClick', { key: `toggle-tag-${tag.id}` } as any, props.file, props.idx)
}
message.success(`已添加 ${tagsToAdd.length} 个标签:${tagsToAdd.map(t => t.name).join(', ')}`)
} catch (error) {
console.error('AI分析标签失败:', error)
message.error('AI分析标签失败请检查配置')
}
}
</script>
<template>
@ -384,6 +466,13 @@ const onTiktokViewClick = () => {
<a-button @click="copyPositivePrompt" v-if="imageGenInfo">{{
$t('copyPositivePrompt')
}}</a-button>
<a-button
@click="analyzeTagsWithAI"
type="primary"
v-if="imageGenInfo && global.conf?.all_custom_tags?.length"
>
{{ $t('aiAnalyzeTags') }}
</a-button>
<a-button
@click="onTiktokViewClick"
@touchstart.prevent="onTiktokViewClick"
@ -490,6 +579,17 @@ const onTiktokViewClick = () => {
</tr>
</table>
</template>
<template v-if="extraJsonMetaInfo && Object.keys(extraJsonMetaInfo).length"> <br />
<h3>Extra Meta Info</h3>
<table class="extra-meta-table">
<tr v-for="(val, key) in extraJsonMetaInfo" :key="key" class="gen-info-frag">
<td style="font-weight: 600;text-transform: capitalize;">{{ key }}</td>
<td style="cursor: pointer;" @dblclick="copy(val)">
<code class="extra-meta-value">{{ typeof val === 'string' ? val : JSON.stringify(val, null, 2) }}</code>
</td>
</tr>
</table>
</template>
</a-tab-pane>
<a-tab-pane key="sourceText" :tab="$t('sourceText')">
<code>{{ imageGenInfo }}</code>
@ -608,6 +708,21 @@ const onTiktokViewClick = () => {
tr td:first-child {
white-space: nowrap;
vertical-align: top;
}
}
table.extra-meta-table {
.extra-meta-value {
display: block;
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85em;
background: var(--zp-secondary-variant-background);
padding: 8px;
border-radius: 4px;
}
}

View File

@ -19,7 +19,6 @@ import { throttle, debounce } from 'lodash-es'
import { useLocalStorage } from '@vueuse/core'
import { prefix } from '@/util/const'
const globalStore = useGlobalStore()
const wsStore = useWorkspeaceSnapshot()
@ -71,7 +70,7 @@ const defaultInitinalPageOptions = computed(() => {
const shortCutsCountRec = computed(() => {
const rec = globalStore.shortcut
const res = {} as Dict<number>
Object.entries(rec).forEach(([_k, v]) => {
Object.values(rec).forEach((v) => {
res[v + ''] ??= 0
res[v + '']++
})
@ -96,6 +95,8 @@ const isShortcutConflict = (keyStr: string) => {
return keyStr && keyStr in shortCutsCountRec.value && shortCutsCountRec.value[keyStr] > 1
}
const disableMaximize = useLocalStorage(prefix+'disable_maximize', false)
// & TopicSearch
</script>
<template>
<div class="panel">
@ -172,6 +173,8 @@ const disableMaximize = useLocalStorage(prefix+'disable_maximize', false)
<a-switch v-model:checked="disableMaximize" />
<sub style="padding-left: 8px;color: #666;">{{ $t('takeEffectAfterReloadPage') }}</sub>
</a-form-item>
<h2>{{ t('shortcutKey') }}</h2>
<a-form-item :label="item.label" v-for="item in shortcutsList" :key="item.key">
<div class="col" :class="{ conflict: isShortcutConflict(globalStore.shortcut[item.key] + '') }"

View File

@ -23,7 +23,7 @@ interface TabPaneBase {
}
interface OtherTabPane extends TabPaneBase {
type: 'global-setting' | 'tag-search' | 'batch-download' | 'workspace-snapshot' | 'random-image'
type: 'global-setting' | 'tag-search' | 'batch-download' | 'workspace-snapshot' | 'random-image' | 'topic-search'
}
export interface EmptyStartTabPane extends TabPaneBase {
@ -90,6 +90,13 @@ interface TagSearchMatchedImageGridTabPane extends TabPaneBase {
selectedTagIds: MatchImageByTagsReq
id: string
}
interface TopicSearchMatchedImageGridTabPane extends TabPaneBase {
type: 'topic-search-matched-image-grid'
id: string
title: string
paths: string[]
}
export interface ImgSliTabPane extends TabPaneBase {
type: 'img-sli'
left: FileNodeInfo
@ -113,7 +120,16 @@ export interface FuzzySearchTabPane extends TabPaneBase {
searchScope?: string
}
export type TabPane = EmptyStartTabPane | FileTransferTabPane | OtherTabPane | TagSearchMatchedImageGridTabPane | ImgSliTabPane | TagSearchTabPane | FuzzySearchTabPane| GridViewTabPane
export type TabPane =
| EmptyStartTabPane
| FileTransferTabPane
| OtherTabPane
| TagSearchMatchedImageGridTabPane
| TopicSearchMatchedImageGridTabPane
| ImgSliTabPane
| TagSearchTabPane
| FuzzySearchTabPane
| GridViewTabPane
/**
* This interface represents a tab, which contains an array of panes, an ID, and a key

View File

@ -75,6 +75,19 @@ export function parse(parameters: string): ImageMeta {
const metadata: ImageMeta = {};
if (!parameters) return metadata;
// 提取 extraJsonMetaInfo 字段
const extraJsonMetaInfoMatch = parameters.match(/\nextraJsonMetaInfo:\s*(\{[\s\S]*\})\s*$/);
if (extraJsonMetaInfoMatch) {
try {
metadata.extraJsonMetaInfo = JSON.parse(extraJsonMetaInfoMatch[1]);
// 从原始参数中移除 extraJsonMetaInfo 部分
parameters = parameters.replace(/\nextraJsonMetaInfo:\s*\{[\s\S]*\}\s*$/, '');
} catch (e) {
// 解析失败,保留原始字符串
metadata.extraJsonMetaInfo = extraJsonMetaInfoMatch[1];
}
}
const metaLines = parameters.split('\n').filter((line) => {
return line.trim() !== '' && !stripKeys.some((key) => line.startsWith(key));
});
@ -117,10 +130,14 @@ export function parse(parameters: string): ImageMeta {
});
// Extract prompts
const [prompt, ...negativePrompt] = metaLines
let [prompt, ...negativePrompt] = metaLines
.join('\n')
.split('Negative prompt:')
.map((x) => x.trim());
// 确保 prompt 中不包含 extraJsonMetaInfo
prompt = prompt.replace(/\nextraJsonMetaInfo:\s*\{[\s\S]*\}\s*$/, '').trim();
metadata.prompt = prompt;
metadata.negativePrompt = negativePrompt.join(' ').trim();

View File

@ -40,7 +40,7 @@ export default defineConfig({
}
},
server: {
port: 3000,
port: 3002,
proxy: {
'/infinite_image_browsing/': {
target: 'http://127.0.0.1:7866/'