Merge pull request #890 from zanllp/feature/tag-relationship-graph

Add tag relationship graph visualization for topic clusters
pull/894/head
zanllp 2026-01-18 01:38:46 +08:00 committed by GitHub
commit 1a5ceea746
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 2405 additions and 165 deletions

View File

@ -13,7 +13,7 @@ Promise.resolve().then(async () => {
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite Image Browsing</title>
<script type="module" crossorigin src="/infinite_image_browsing/fe-static/assets/index-632e7cf6.js"></script>
<script type="module" crossorigin src="/infinite_image_browsing/fe-static/assets/index-64cbe4df.js"></script>
<link rel="stylesheet" href="/infinite_image_browsing/fe-static/assets/index-d385cc4f.css">
</head>

View File

@ -62,6 +62,7 @@ from scripts.iib.db.datamodel import (
)
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.tag_graph import mount_tag_graph_routes
from scripts.iib.logger import logger
from scripts.iib.seq import seq
import urllib.parse
@ -1256,6 +1257,17 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
ai_model=AI_MODEL,
)
# ===== Tag 关系图 =====
mount_tag_graph_routes(
app=app,
db_api_base=db_api_base,
verify_secret=verify_secret,
embedding_model=EMBEDDING_MODEL,
ai_model=AI_MODEL,
openai_base_url=OPENAI_BASE_URL,
openai_api_key=OPENAI_API_KEY,
)
class ExtraPathModel(BaseModel):
path: str

View File

@ -66,6 +66,9 @@ class DataBase:
# 创建连接并打开数据库
conn = connect(clz.get_db_file_path())
# # 禁用 WAL 模式,使用传统的 DELETE 日志模式
# conn.execute("PRAGMA journal_mode=DELETE")
def regexp(expr, item):
if not isinstance(item, str):
return False
@ -513,6 +516,38 @@ class TopicTitleCache:
kw = []
return {"title": title, "keywords": kw, "model": model, "updated_at": updated_at}
@classmethod
def get_all_keywords_frequency(cls, conn: Connection, model: Optional[str] = None) -> Dict[str, int]:
"""
Get keyword frequency from all cached clusters.
Optionally filter by model.
Returns a dictionary mapping keyword -> frequency.
"""
with closing(conn.cursor()) as cur:
if model:
cur.execute(
"SELECT keywords FROM topic_title_cache WHERE model = ?",
(model,),
)
else:
cur.execute(
"SELECT keywords FROM topic_title_cache",
)
rows = cur.fetchall()
keyword_frequency: Dict[str, int] = {}
for row in rows:
keywords_str = row[0] if row else None
try:
keywords = json.loads(keywords_str) if isinstance(keywords_str, str) else []
except Exception:
keywords = []
if isinstance(keywords, list):
for kw in keywords:
if isinstance(kw, str) and kw.strip():
keyword_frequency[kw] = keyword_frequency.get(kw, 0) + 1
return keyword_frequency
@classmethod
def upsert(
cls,
@ -538,6 +573,25 @@ class TopicTitleCache:
(cluster_hash, title, kw, model, updated_at),
)
@classmethod
def update_keywords(
cls,
conn: Connection,
cluster_hash: str,
keywords: List[str],
updated_at: Optional[str] = None,
):
"""
Update only the keywords for an existing cluster cache entry.
"""
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(
"UPDATE topic_title_cache SET keywords = ?, updated_at = ? WHERE cluster_hash = ?",
(kw, updated_at, cluster_hash),
)
class TopicClusterCache:
"""
@ -1265,3 +1319,4 @@ class GlobalSetting:
for row in rows:
settings[row[1]] = json.loads(row[0])
return settings

669
scripts/iib/tag_graph.py Normal file
View File

@ -0,0 +1,669 @@
"""
Hierarchical tag graph generation for topic clusters.
Builds a multi-layer neural-network-style visualization with LLM-driven abstraction.
"""
import hashlib
import json
import os
import re
import time
from typing import Dict, List, Optional
from pydantic import BaseModel
from fastapi import Depends, FastAPI, HTTPException
from scripts.iib.db.datamodel import DataBase, GlobalSetting
from scripts.iib.tool import normalize_output_lang, accumulate_streaming_response
from scripts.iib.logger import logger
# Cache version for tag abstraction - increment to invalidate all caches
TAG_ABSTRACTION_CACHE_VERSION = 2.1
TAG_GRAPH_CACHE_VERSION = 1
_MAX_TAGS_FOR_LLM = int(os.getenv("IIB_TAG_GRAPH_MAX_TAGS_FOR_LLM", "500") or "500")
_TOPK_TAGS_FOR_LLM = int(os.getenv("IIB_TAG_GRAPH_TOPK_TAGS_FOR_LLM", "500") or "500")
_LLM_REQUEST_TIMEOUT_SEC = int(os.getenv("IIB_TAG_GRAPH_LLM_TIMEOUT_SEC", "180") or "180")
_LLM_MAX_ATTEMPTS = int(os.getenv("IIB_TAG_GRAPH_LLM_MAX_ATTEMPTS", "5") or "5")
class TagGraphReq(BaseModel):
folder_paths: List[str]
lang: Optional[str] = "en" # Language for LLM output
class LayerNode(BaseModel):
id: str
label: str
size: float # Weight/importance of this node
metadata: Optional[dict] = None
class GraphLayer(BaseModel):
level: int
name: str # Layer name: "Clusters", "Tags", "Abstract-1", "Abstract-2"
nodes: List[LayerNode]
class GraphLink(BaseModel):
source: str
target: str
weight: float
class TagGraphResp(BaseModel):
layers: List[GraphLayer]
links: List[GraphLink]
stats: dict
def mount_tag_graph_routes(
app: FastAPI,
db_api_base: str,
verify_secret,
embedding_model: str,
ai_model: str,
openai_base_url: str,
openai_api_key: str,
):
"""Mount hierarchical tag graph endpoints"""
async def _call_llm_for_abstraction(
tags: List[str],
lang: str,
model: str,
base_url: str,
api_key: str
) -> dict:
"""
Call LLM to create hierarchical abstraction of tags.
Returns a dict with layers and groupings.
"""
import asyncio
import requests
import re
def _normalize_base_url(url: str) -> str:
url = url.strip()
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
return url.rstrip("/")
def _call_sync():
if not api_key:
raise HTTPException(500, "OpenAI API Key not configured")
url = f"{_normalize_base_url(base_url)}/chat/completions"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
# Normalize language for consistent LLM output
normalized_lang = normalize_output_lang(lang)
print(f"tags length: {len(tags)}")
# Determine Level 2 requirement based on tag count
level2_requirement = ""
if len(tags) <= 8:
level2_requirement = "Level 2 is optional; only create if categories naturally form clear super-groups."
elif len(tags) <= 64:
level2_requirement = "Level 2 is recommended; create 2-5 super-categories to group Level 1 categories when meaningful."
else:
level2_requirement = "Level 2 is strongly recommended; create 2-5 super-categories to group the many Level 1 categories for better organization."
sys_prompt = f"""You are a tag categorization assistant. Organize tags into hierarchical categories.
GUIDELINES:
1. Create 5-15 Level 1 categories (broad groupings)
2. {level2_requirement}
3. Every tag must belong to exactly ONE Level 1 category
4. Use {normalized_lang} for all category labels
5. Category IDs must be simple lowercase (e.g., "style", "char", "scene1")
OUTPUT ONLY VALID JSON - NO markdown, NO explanations, NO extra text:
{{"layers":[{{"level":1,"groups":[{{"id":"cat1","label":"Label1","tags":["tag1"]}},{{"id":"cat2","label":"Label2","tags":["tag2"]}}]}},{{"level":2,"groups":[{{"id":"super1","label":"SuperLabel","categories":["cat1","cat2"]}}]}}]}}
If unsure about Level 2, OMIT it entirely. Start response with {{ and end with }}"""
user_prompt = f"Tags to categorize:\n{', '.join(tags)}"
payload = {
"model": model,
"messages": [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_prompt}
],
"temperature": 0.0,
"max_tokens": 32768, # Increased significantly for large keyword sets
"stream": True,
}
# Retry a few times then fallback quickly (to avoid frontend timeout on large datasets).
# Use streaming requests to avoid blocking too long on a single non-stream response.
last_error = ""
for attempt in range(1, _LLM_MAX_ATTEMPTS + 1):
try:
resp = requests.post(
url,
json=payload,
headers=headers,
timeout=_LLM_REQUEST_TIMEOUT_SEC,
stream=True,
)
# Check status early
if resp.status_code != 200:
body = (resp.text or "")[:400]
if resp.status_code == 429 or resp.status_code >= 500:
last_error = f"api_error_retriable: status={resp.status_code}"
logger.warning("[tag_graph] llm_http_error attempt=%s status=%s body=%s", attempt, resp.status_code, body)
continue
logger.error("[tag_graph] llm_http_client_error attempt=%s status=%s body=%s", attempt, resp.status_code, body)
raise Exception(f"API client error: {resp.status_code} {body}")
# Accumulate streamed content chunks
content = accumulate_streaming_response(resp)
print(f"[tag_graph] content: {content[:500]}...")
# Strategy 1: Direct parse (if response is pure JSON)
try:
result = json.loads(content)
if isinstance(result, dict) and 'layers' in result:
return result
except Exception:
pass
# Strategy 2: Extract JSON from markdown code blocks
json_str = None
code_block = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", content)
if code_block:
json_str = code_block.group(1)
else:
m = re.search(r"\{[\s\S]*\}", content)
if m:
json_str = m.group(0)
if not json_str:
last_error = f"no_json_found: {content}"
logger.warning("[tag_graph] llm_no_json attempt=%s err=%s", attempt, last_error, stack_info=True)
continue
# Clean up common JSON issues
json_str = json_str.strip()
json_str = re.sub(r',(\s*[}\]])', r'\1', json_str)
try:
result = json.loads(json_str)
except json.JSONDecodeError as e:
last_error = f"json_parse_error: {e}"
logger.warning("[tag_graph] llm_json_parse_error attempt=%s err=%s json=%s", attempt, last_error, json_str[:400], stack_info=True)
continue
if not isinstance(result, dict) or 'layers' not in result:
last_error = f"invalid_structure: {str(result)[:200]}"
logger.warning("[tag_graph] llm_invalid_structure attempt=%s err=%s", attempt, last_error, stack_info=True)
continue
return result
except requests.RequestException as e:
last_error = f"network_error: {type(e).__name__}: {e}"
logger.warning("[tag_graph] llm_request_error attempt=%s err=%s", attempt, last_error, stack_info=True)
continue
# No fallback: expose error to frontend, but log enough info for debugging.
logger.error(
"[tag_graph] llm_failed attempts=%s timeout_sec=%s last_error=%s",
_LLM_MAX_ATTEMPTS,
_LLM_REQUEST_TIMEOUT_SEC,
last_error,
)
raise HTTPException(
status_code=500,
detail={
"type": "tag_graph_llm_failed",
"attempts": _LLM_MAX_ATTEMPTS,
"timeout_sec": _LLM_REQUEST_TIMEOUT_SEC,
"last_error": last_error,
},
)
return await asyncio.to_thread(_call_sync)
@app.post(
f"{db_api_base}/cluster_tag_graph",
dependencies=[Depends(verify_secret)],
)
async def cluster_tag_graph(req: TagGraphReq):
"""
Build hierarchical tag graph from clustering results.
Returns multi-layer structure similar to neural network visualization.
Layer structure (bottom to top):
- Layer 0: Cluster nodes
- Layer 1: Tag nodes (deduplicated cluster keywords)
- Layer 2+: Abstract groupings (LLM-generated, max 2 layers)
"""
t0 = time.time()
# Validate
if not req.folder_paths:
raise HTTPException(400, "folder_paths is required")
folders = sorted(req.folder_paths)
# Get the latest cluster result for these folders
conn = DataBase.get_conn()
from contextlib import closing
with closing(conn.cursor()) as cur:
# Avoid full table scan on large DBs; we only need recent caches for matching.
cur.execute(
"""SELECT cache_key, folders, result FROM topic_cluster_cache
ORDER BY updated_at DESC
LIMIT 200"""
)
rows = cur.fetchall()
# Find a cache that matches the folders (order-independent)
folders_set = set(folders)
row = None
topic_cluster_cache_key: Optional[str] = None
for cache_row in rows:
try:
cached_folders = json.loads(cache_row[1]) if isinstance(cache_row[1], str) else cache_row[1]
if isinstance(cached_folders, list) and set(cached_folders) == folders_set:
topic_cluster_cache_key = str(cache_row[0] or "")
row = (topic_cluster_cache_key, cache_row[2])
break
except Exception:
continue
if not row:
raise HTTPException(
400,
f"No clustering result found for these {len(folders)} folders. Please run clustering first."
)
cached_result = json.loads(row[1]) if isinstance(row[1], str) else row[1]
if not cached_result or not isinstance(cached_result, dict):
raise HTTPException(400, "Invalid clustering result format.")
result = cached_result
clusters = result.get("clusters", [])
if not clusters:
raise HTTPException(400, "No clusters found in result")
logger.info(
"[tag_graph] start folders=%s clusters=%s lang=%s",
len(folders),
len(clusters) if isinstance(clusters, list) else "n/a",
str(req.lang or ""),
)
# Graph cache (best-effort): cache by topic_cluster_cache_key + lang + version.
graph_cache_key = None
if topic_cluster_cache_key:
try:
graph_cache_key_hash = hashlib.md5(
json.dumps(
{
"v": TAG_GRAPH_CACHE_VERSION,
"topic_cluster_cache_key": topic_cluster_cache_key,
"lang": str(req.lang or ""),
},
ensure_ascii=False,
sort_keys=True,
).encode("utf-8")
).hexdigest()
graph_cache_key = f"tag_graph_v{TAG_GRAPH_CACHE_VERSION}_{graph_cache_key_hash}"
cached_graph = GlobalSetting.get_setting(conn, graph_cache_key)
if cached_graph:
cached_graph_obj = json.loads(cached_graph) if isinstance(cached_graph, str) else cached_graph
if isinstance(cached_graph_obj, dict) and "layers" in cached_graph_obj and "links" in cached_graph_obj:
cached_graph_obj.setdefault("stats", {})
if isinstance(cached_graph_obj["stats"], dict):
cached_graph_obj["stats"].setdefault("topic_cluster_cache_key", topic_cluster_cache_key)
logger.info(
"[tag_graph] cache_hit topic_cluster_cache_key=%s cost_ms=%s",
topic_cluster_cache_key,
int((time.time() - t0) * 1000),
)
return cached_graph_obj
except Exception:
pass
# === Layer 0: Cluster Nodes ===
top_clusters = sorted(clusters, key=lambda c: c.get("size", 0), reverse=True)
cluster_nodes = []
cluster_to_tags_links = []
for cluster in top_clusters:
cluster_id = cluster.get("id", "")
cluster_title = cluster.get("title", "Untitled")
cluster_size = cluster.get("size", 0)
keywords = cluster.get("keywords", []) or []
keywords = list(dict.fromkeys(keywords)) # Deduplicate
node_id = f"cluster_{cluster_id}"
cluster_nodes.append(LayerNode(
id=node_id,
label=cluster_title,
size=float(cluster_size),
metadata={
"type": "cluster",
"image_count": cluster_size,
# Do NOT include full paths list here (can be huge); fetch on demand.
"cluster_id": cluster_id,
}
))
# Store links from clusters to their tags (will be created later)
for keyword in keywords:
if keyword:
cluster_to_tags_links.append({
"cluster_id": node_id,
"tag": str(keyword).strip(),
"weight": float(cluster_size)
})
# === Layer 1: Tag Nodes (deduplicated) ===
tag_stats: Dict[str, Dict] = {}
for cluster in clusters:
keywords = cluster.get("keywords", []) or []
cluster_size = cluster.get("size", 0)
cluster_id = cluster.get("id", "")
for keyword in keywords:
keyword = str(keyword).strip()
if not keyword:
continue
if keyword not in tag_stats:
tag_stats[keyword] = {
"cluster_ids": [],
"total_images": 0,
}
tag_stats[keyword]["cluster_ids"].append(cluster_id)
tag_stats[keyword]["total_images"] += cluster_size
# Filter and sort tags
sorted_tags = sorted(
tag_stats.items(),
key=lambda x: x[1]["total_images"],
reverse=True
)
tag_nodes = []
selected_tags = set()
for tag, stats in sorted_tags:
tag_id = f"tag_{tag}"
selected_tags.add(tag)
tag_nodes.append(LayerNode(
id=tag_id,
label=tag,
size=float(stats["total_images"]),
metadata={
"type": "tag",
"cluster_count": len(stats["cluster_ids"]),
"image_count": stats["total_images"]
}
))
# Filter cluster->tag links to only include selected tags
layer0_to_1_links = []
for link in cluster_to_tags_links:
if link["tag"] in selected_tags:
layer0_to_1_links.append(GraphLink(
source=link["cluster_id"],
target=f"tag_{link['tag']}",
weight=link["weight"]
))
# === Layer 2+: LLM-driven abstraction ===
abstract_layers = []
layer1_to_2_links = []
# LLM abstraction: for large datasets, do TopK by total_images (frequency/weight) instead of skipping.
# This keeps response useful while controlling LLM latency and prompt size.
llm_tags = [t for (t, _stats) in sorted_tags][: max(0, int(_TOPK_TAGS_FOR_LLM))]
llm_tags_set = set(llm_tags)
# IMPORTANT: Do not gate LLM by total tag count; only use TopK tags for LLM input.
# Otherwise, large datasets would "skip" abstraction even though we already limited llm_tags.
should_use_llm = (
len(llm_tags) > 3
and len(llm_tags) <= _MAX_TAGS_FOR_LLM
and bool(openai_api_key)
and bool(openai_base_url)
)
if not should_use_llm:
logger.info(
"[tag_graph] llm_disabled reasons={topk_tags:%s,max:%s,has_key:%s,has_base:%s}",
len(llm_tags),
_MAX_TAGS_FOR_LLM,
bool(openai_api_key),
bool(openai_base_url),
)
logger.info(
"[tag_graph] tags_total=%s tags_for_llm=%s llm_enabled=%s",
len(selected_tags),
len(llm_tags),
bool(should_use_llm),
)
if should_use_llm:
# Use language from request
lang = req.lang or "en"
# Generate cache key for this set of tags (with version)
import hashlib
tags_sorted = sorted(llm_tags_set)
cache_input = f"v{TAG_ABSTRACTION_CACHE_VERSION}|{ai_model}|{lang}|topk={len(tags_sorted)}|{','.join(tags_sorted)}"
cache_key_hash = hashlib.md5(cache_input.encode()).hexdigest()
cache_key = f"tag_abstraction_v{TAG_ABSTRACTION_CACHE_VERSION}_{cache_key_hash}"
# Try to get from cache
abstraction = None
try:
cached_data = GlobalSetting.get_setting(conn, cache_key)
if cached_data and isinstance(cached_data, dict):
abstraction = cached_data
except:
pass
# Call LLM if not cached
if not abstraction:
t_llm = time.time()
try:
abstraction = await _call_llm_for_abstraction(
list(llm_tags_set),
lang,
ai_model,
openai_base_url,
openai_api_key
)
except Exception as e:
logger.exception(
"[tag_graph] llm_call_failed cost_ms=%s err=%s",
int((time.time() - t_llm) * 1000),
f"{type(e).__name__}: {e}",
)
raise
logger.info("[tag_graph] llm_done cost_ms=%s cached=%s", int((time.time() - t_llm) * 1000), False)
# Save to cache if successful
if abstraction and isinstance(abstraction, dict) and abstraction.get("layers"):
try:
GlobalSetting.save_setting(conn, cache_key, json.dumps(abstraction, ensure_ascii=False))
except Exception:
pass
else:
logger.info("[tag_graph] llm_done cost_ms=%s cached=%s", 0, True)
# Build abstract layers from LLM response
llm_layers = abstraction.get("layers", [])
for layer_info in llm_layers:
level = layer_info.get("level", 1)
groups = layer_info.get("groups", [])
if not groups:
continue
abstract_nodes = []
# Process each group in this layer
for group in groups:
group_id = f"abstract_l{level}_{group.get('id', 'unknown')}"
group_label = group.get("label", "Unnamed")
# Calculate size based on contained tags/categories
if "tags" in group:
# Level 1: references tags directly
contained_tags = group.get("tags", [])
total_size = sum(
tag_stats.get(tag, {}).get("total_images", 0)
for tag in contained_tags
if tag in llm_tags_set
)
abstract_nodes.append(LayerNode(
id=group_id,
label=group_label,
size=float(total_size),
metadata={"type": "abstract", "level": level}
))
# Create links from tags to this abstract node
for tag in contained_tags:
if tag in llm_tags_set:
tag_id = f"tag_{tag}"
layer1_to_2_links.append(GraphLink(
source=tag_id,
target=group_id,
weight=float(tag_stats.get(tag, {}).get("total_images", 1))
))
elif "categories" in group and level == 2:
# Level 2: references Level 1 categories
# Calculate size from contained categories
contained_cats = group.get("categories", [])
total_size = 0.0
# Find size from level 1 nodes
level1_nodes = abstract_layers[0].nodes if abstract_layers else []
for cat_id in contained_cats:
full_cat_id = f"abstract_l1_{cat_id}"
for node in level1_nodes:
if node.id == full_cat_id:
total_size += node.size
break
abstract_nodes.append(LayerNode(
id=group_id,
label=group_label,
size=float(total_size),
metadata={"type": "abstract", "level": level}
))
# Create links from Level 1 categories to this Level 2 node
for cat_id in contained_cats:
full_cat_id = f"abstract_l1_{cat_id}"
# Verify the category exists
if any(n.id == full_cat_id for n in level1_nodes):
layer1_to_2_links.append(GraphLink(
source=full_cat_id,
target=group_id,
weight=total_size # Use aggregated weight
))
if abstract_nodes:
abstract_layers.append(GraphLayer(
level=level + 1, # +1 because Layer 0=clusters, Layer 1=tags
name=f"Abstract-{level}",
nodes=abstract_nodes
))
# === Build final response ===
layers = [
GraphLayer(level=0, name="Clusters", nodes=cluster_nodes),
GraphLayer(level=1, name="Tags", nodes=tag_nodes),
] + abstract_layers
all_links = [link.dict() for link in (layer0_to_1_links + layer1_to_2_links)]
stats = {
"total_clusters": len(clusters),
"selected_clusters": len(cluster_nodes),
"total_tags": len(tag_stats),
"selected_tags": len(tag_nodes),
"abstraction_layers": len(abstract_layers),
"total_links": len(all_links),
}
if topic_cluster_cache_key:
stats["topic_cluster_cache_key"] = topic_cluster_cache_key
resp_obj = TagGraphResp(
layers=layers,
links=all_links,
stats=stats,
).dict()
# Save graph cache (best-effort)
if graph_cache_key:
try:
GlobalSetting.save_setting(conn, graph_cache_key, json.dumps(resp_obj, ensure_ascii=False))
conn.commit()
except Exception:
pass
logger.info(
"[tag_graph] done nodes=%s links=%s abstraction_layers=%s cost_ms=%s",
sum(len(l.get("nodes") or []) for l in (resp_obj.get("layers") or []) if isinstance(l, dict)),
len(resp_obj.get("links") or []),
int((resp_obj.get("stats") or {}).get("abstraction_layers") or 0),
int((time.time() - t0) * 1000),
)
return resp_obj
class ClusterPathsReq(BaseModel):
topic_cluster_cache_key: str
cluster_id: str
@app.post(
f"{db_api_base}/cluster_tag_graph_cluster_paths",
dependencies=[Depends(verify_secret)],
)
async def cluster_tag_graph_cluster_paths(req: ClusterPathsReq):
"""
Fetch full paths for a specific cluster from cached clustering result.
This avoids returning huge "paths" arrays inside cluster_tag_graph response.
"""
ck = str(req.topic_cluster_cache_key or "").strip()
cid = str(req.cluster_id or "").strip()
if not ck or not cid:
raise HTTPException(400, "topic_cluster_cache_key and cluster_id are required")
conn = DataBase.get_conn()
from contextlib import closing
with closing(conn.cursor()) as cur:
cur.execute("SELECT result FROM topic_cluster_cache WHERE cache_key = ?", (ck,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "topic_cluster_cache not found")
try:
result_obj = json.loads(row[0]) if isinstance(row[0], str) else row[0]
except Exception:
result_obj = None
if not isinstance(result_obj, dict):
raise HTTPException(400, "invalid cached result")
clusters = result_obj.get("clusters", [])
if not isinstance(clusters, list):
clusters = []
for c in clusters:
if not isinstance(c, dict):
continue
if str(c.get("id", "")) == cid:
paths = c.get("paths", [])
if not isinstance(paths, list):
paths = []
return {"paths": [str(p) for p in paths if p]}
raise HTTPException(404, "cluster not found")

View File

@ -1,18 +1,19 @@
import ctypes
from datetime import datetime
import json
import os
import platform
import re
import tempfile
import subprocess
from typing import Dict, List
from typing import Dict, List, Optional, Any
import sys
import piexif
import piexif.helper
import json
import zipfile
from PIL import Image
import shutil
import requests
# import magic
sd_img_dirs = [
@ -36,6 +37,37 @@ cwd = os.getcwd() if is_exe_ver else os.path.normpath(os.path.join(__file__, "..
is_win = platform.system().lower().find("windows") != -1
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
Args:
lang: Language code from frontend (e.g., "zhHans", "en", "de")
Returns:
Human-readable language name for LLM instruction
"""
if not lang:
return "English"
l = str(lang).strip()
ll = l.lower()
# Simplified Chinese
if ll in ["zh", "zhhans", "zh-hans", "zh_cn", "zh-cn", "cn", "zh-hans-cn", "zhs"]:
return "Chinese (Simplified)"
# Traditional Chinese (Taiwan, Hong Kong, Macau)
if ll in ["zhhant", "zh-hant", "zh_tw", "zh-tw", "zh_hk", "zh-hk", "zh_mo", "zh-mo", "tw", "hk", "mo", "macau", "macao", "zht"]:
return "Chinese (Traditional)"
# German
if ll.startswith("de"):
return "German"
# English
if ll.startswith("en"):
return "English"
# fallback
return "English"
try:
@ -802,4 +834,46 @@ def get_data_file_path(filename):
# Running in a normal Python environment
base_path = os.path.join(os.path.dirname(__file__))
return os.path.normpath(os.path.join(base_path, "../../", filename))
return os.path.normpath(os.path.join(base_path, "../../", filename))
def accumulate_streaming_response(resp: requests.Response) -> str:
"""
Accumulate content from a streaming HTTP response.
Args:
resp: The response object from requests.post with stream=True
Returns:
Accumulated text content from the stream
"""
content_buffer = ""
for raw in resp.iter_lines(decode_unicode=False):
if not raw:
continue
# Ensure explicit UTF-8 decoding to avoid mojibake
try:
line = raw.decode('utf-8') if isinstance(raw, (bytes, bytearray)) else str(raw)
except Exception:
line = raw.decode('utf-8', errors='replace') if isinstance(raw, (bytes, bytearray)) else str(raw)
line = line.strip()
if line.startswith('data: '):
line = line[6:].strip()
if line == '[DONE]':
break
try:
obj = json.loads(line)
except Exception:
# Some providers may return partial JSON or non-JSON lines; skip
continue
# Try to extract incremental content (compat with OpenAI-style streaming)
delta = (obj.get('choices') or [{}])[0].get('delta') or {}
chunk_text = delta.get('content') or ''
if chunk_text:
# try:
# print(f"[streaming] chunk_received len={len(chunk_text)} snippet={chunk_text[:200]}")
# except Exception:
# pass
content_buffer += chunk_text
return content_buffer.strip()

View File

@ -16,7 +16,7 @@ from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from scripts.iib.db.datamodel import DataBase, ImageEmbedding, ImageEmbeddingFail, TopicClusterCache, TopicTitleCache
from scripts.iib.tool import cwd
from scripts.iib.tool import cwd, accumulate_streaming_response
# Perf deps (required for this feature)
_np = None
@ -448,6 +448,7 @@ def _call_chat_title_sync(
model: str,
prompt_samples: List[str],
output_lang: str,
existing_keywords: Optional[List[str]] = None,
) -> Optional[Dict]:
"""
Ask LLM to generate a short topic title and a few keywords. Returns dict or None.
@ -477,34 +478,58 @@ def _call_chat_title_sync(
"- 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
)
if existing_keywords:
# Dynamic keyword selection based on total unique count
unique_count = len(existing_keywords)
top_keywords = existing_keywords[:500] # Relaxed to 500
# Tiered conditions based on keyword count
if unique_count <= 200:
# First 200: Not very strict, can create new keywords if not highly relevant
strictness_msg = (
"TIP: Prioritize selecting from the existing list if they fit reasonably well. "
"Creating new keywords is ACCEPTABLE when existing ones don't capture the essence well.\n"
)
elif unique_count <= 500:
# 200-500: Moderate strictness
strictness_msg = (
"IMPORTANT: Try to select from the existing list when reasonably applicable. "
"Only create new keywords when existing ones clearly don't match.\n"
)
else:
# 500+: Very strict, only create if completely unrelated
strictness_msg = (
"CRITICAL: You MUST select from the existing list unless the theme is COMPLETELY UNRELATED to all existing keywords. "
"In almost all cases, use existing keywords.\n"
)
sys += strictness_msg
sys += f"Existing keywords ({len(top_keywords)} of {unique_count} total): {', '.join(top_keywords)}\n\n"
sys += "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,
"max_tokens": 4096,
"stream": True, # Enable streaming
}
# 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"]
# Parse JSON from message.content only (regex extraction).
# Retry up to 5 times for: network errors, API errors (non 4xx client errors), parsing failures.
# Parse JSON from streaming response.
# Retry up to 5 times for: network errors, API errors, parsing failures.
attempt_debug: List[Dict] = []
last_err = ""
for attempt in range(1, 6):
try:
resp = requests.post(url, json=payload, headers=headers, timeout=60)
resp = requests.post(url, json=payload, headers=headers, timeout=120)
except requests.RequestException as e:
last_err = f"network_error: {type(e).__name__}: {e}"
attempt_debug.append({"attempt": attempt, "reason": "network_error", "error": str(e)[:400]})
@ -522,30 +547,21 @@ def _call_chat_title_sync(
continue
# 4xx (except 429): fail immediately (client error, not retriable)
raise HTTPException(status_code=status, detail=body)
try:
data = resp.json()
# Use streaming response accumulation
content = accumulate_streaming_response(resp)
except Exception as e:
txt = (resp.text or "")[:600]
last_err = f"response_not_json: {type(e).__name__}: {e}; body={txt}"
attempt_debug.append({"attempt": attempt, "reason": "response_not_json", "error": str(e)[:200], "body": txt[:200]})
last_err = f"streaming_error: {type(e).__name__}: {e}"
attempt_debug.append({"attempt": attempt, "reason": "streaming_error", "error": str(e)[:400]})
continue
choice0 = (data.get("choices") or [{}])[0] if isinstance(data.get("choices"), list) else {}
msg = (choice0 or {}).get("message") or {}
finish_reason = (choice0.get("finish_reason") if isinstance(choice0, dict) else None) or ""
content = (msg.get("content") if isinstance(msg, dict) else "") or ""
raw = content.strip() if isinstance(content, str) else ""
if not raw and isinstance(choice0, dict):
txt = (choice0.get("text") or "") # legacy
raw = txt.strip() if isinstance(txt, str) else ""
m = re.search(r"\{[\s\S]*\}", raw)
# Extract JSON from content
m = re.search(r"\{[\s\S]*\}", content)
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]
last_err = f"no_json_object; finish_reason={finish_reason}; content_snippet={snippet}; choice0={choice_dump}"
attempt_debug.append({"attempt": attempt, "reason": "no_json_object", "finish_reason": finish_reason, "snippet": snippet})
snippet = (content or "")[:400].replace("\n", "\\n")
last_err = f"no_json_object; content_snippet={snippet}"
attempt_debug.append({"attempt": attempt, "reason": "no_json_object", "snippet": snippet})
continue
json_str = m.group(0)
@ -553,22 +569,22 @@ def _call_chat_title_sync(
obj = json.loads(json_str)
except Exception as e:
snippet = (json_str or "")[:400].replace("\n", "\\n")
last_err = f"json_parse_failed: {type(e).__name__}: {e}; finish_reason={finish_reason}; json_snippet={snippet}"
attempt_debug.append({"attempt": attempt, "reason": "json_parse_failed", "finish_reason": finish_reason, "snippet": snippet})
last_err = f"json_parse_failed: {type(e).__name__}: {e}; json_snippet={snippet}"
attempt_debug.append({"attempt": attempt, "reason": "json_parse_failed", "snippet": snippet})
continue
if not isinstance(obj, dict):
snippet = (json_str or "")[:200].replace("\n", "\\n")
last_err = f"json_not_object; finish_reason={finish_reason}; json_snippet={snippet}"
attempt_debug.append({"attempt": attempt, "reason": "json_not_object", "finish_reason": finish_reason, "snippet": snippet})
last_err = f"json_not_object; json_snippet={snippet}"
attempt_debug.append({"attempt": attempt, "reason": "json_not_object", "snippet": snippet})
continue
title = str(obj.get("title") or "").strip()
keywords = obj.get("keywords") or []
if not title:
snippet = (json_str or "")[:200].replace("\n", "\\n")
last_err = f"missing_title; finish_reason={finish_reason}; json_snippet={snippet}"
attempt_debug.append({"attempt": attempt, "reason": "missing_title", "finish_reason": finish_reason, "snippet": snippet})
last_err = f"missing_title; json_snippet={snippet}"
attempt_debug.append({"attempt": attempt, "reason": "missing_title", "snippet": snippet})
continue
if not isinstance(keywords, list):
keywords = []
@ -590,6 +606,7 @@ async def _call_chat_title(
model: str,
prompt_samples: List[str],
output_lang: str,
existing_keywords: Optional[List[str]] = None,
) -> Dict:
"""
Same rationale as embeddings:
@ -603,6 +620,7 @@ async def _call_chat_title(
model=model,
prompt_samples=prompt_samples,
output_lang=output_lang,
existing_keywords=existing_keywords,
)
if not isinstance(ret, dict):
raise HTTPException(status_code=502, detail="Chat API returned empty title payload")
@ -1412,6 +1430,15 @@ def mount_topic_cluster_routes(
if progress_cb:
progress_cb({"stage": "titling", "clusters_total": len(clusters)})
existing_keywords: List[str] = []
keyword_frequency: Dict[str, int] = TopicTitleCache.get_all_keywords_frequency(conn, model)
def _get_top_keywords() -> List[str]:
if not keyword_frequency:
return []
sorted_keywords = sorted(keyword_frequency.items(), key=lambda x: x[1], reverse=True)
return [k for k, v in sorted_keywords[:100]]
for cidx, c in enumerate(clusters):
if len(c["members"]) < min_cluster_size:
for mi in c["members"]:
@ -1441,12 +1468,14 @@ def mount_topic_cluster_routes(
title = str(cached.get("title"))
keywords = cached.get("keywords") or []
else:
top_keywords = _get_top_keywords()
llm = await _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,
existing_keywords=top_keywords,
)
title = (llm or {}).get("title")
keywords = (llm or {}).get("keywords", [])
@ -1459,6 +1488,9 @@ def mount_topic_cluster_routes(
except Exception:
pass
for kw in keywords or []:
keyword_frequency[kw] = keyword_frequency.get(kw, 0) + 1
out_clusters.append(
{
"id": f"topic_{cidx}",

1
vue/components.d.ts vendored
View File

@ -39,6 +39,7 @@ declare module '@vue/runtime-core' {
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASlider: typeof import('ant-design-vue/es')['Slider']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']

View File

@ -1 +1 @@
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-632e7cf6.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-64cbe4df.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};

File diff suppressed because one or more lines are too long

View File

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

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

File diff suppressed because one or more lines are too long

72
vue/dist/assets/TopicSearch-52e0968f.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

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{bV as i,b1 as t,e5 as f,bM as n}from"./index-632e7cf6.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,e7 as f,bM as n}from"./index-64cbe4df.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};

View File

@ -0,0 +1 @@
import{d as F,a1 as B,cS as S,cc as $,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,cT as T,cU as y,z as U,B as V,ak as x,a0 as E}from"./index-64cbe4df.js";import{_ as N}from"./index-f0ba7b9c.js";import{u as L,a as H,f as O,F as W,d as j}from"./FileItem-2b09179d.js";import"./numInput.vue_vue_type_style_index_0_scoped_55978858_lang-4748a0d9.js";/* empty css */import"./index-53055c61.js";import"./_isIterateeCall-e4f71c4d.js";import"./index-8c941714.js";import"./index-01c239de.js";const q={class:"actions-panel actions"},G={class:"item"},P={key:0,class:"file-list"},Q={class:"hint"},X=F({__name:"batchDownload",props:{tabIdx:{},paneIdx:{},id:{}},setup(Y){const{stackViewEl:D}=L().toRefs(),{itemSize:h,gridItems:b,cellWidth:g}=H(),i=B(),m=O(),{selectdFiles:a}=S(m),r=$(),v=async e=>{const t=T(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"}),U.success(V("success"))})},z=e=>{a.value.splice(e,1)};return(e,t)=>{const o=x,u=N;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:J=>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",P,[f("p",Q,c(e.$t("batchDownloaDDragAndDropHint")),1)]))],544)}}});const le=E(X,[["__scopeId","data-v-a2642a17"]]);export{le as default};

View File

@ -1 +0,0 @@
import{d as F,a1 as B,cO 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,cP as V,cQ as y,z as x,B as E,ak as T,a0 as U}from"./index-632e7cf6.js";import{_ as N}from"./index-1bd869eb.js";import{u as L,a as O,f as H,F as P,d as Q}from"./FileItem-70868dea.js";import"./numInput.vue_vue_type_style_index_0_scoped_55978858_lang-221425da.js";/* empty css */import"./index-a2a27adc.js";import"./_isIterateeCall-582f579a.js";import"./index-9d95a206.js";import"./index-a44b2ffa.js";const W={class:"actions-panel actions"},j={class:"item"},q={key:0,class:"file-list"},G={class:"hint"},X=F({__name:"batchDownload",props:{tabIdx:{},paneIdx:{},id:{}},setup(Y){const{stackViewEl:D}=L().toRefs(),{itemSize:h,gridItems:b,cellWidth:g}=O(),i=B(),m=H(),{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=N;return _(),w("div",{class:"container",ref_key:"stackViewEl",ref:D,onDrop:v},[f("div",W,[l(o,{onClick:t[0]||(t[0]=n=>s(m).selectdFiles=[])},{default:d(()=>[p(c(e.$t("clear")),1)]),_:1}),f("div",j,[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(Q),{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(P,{idx:k,file:n,"cell-width":s(g),"enable-close-icon":"",onCloseIconClick:J=>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",q,[f("p",G,c(e.$t("batchDownloaDDragAndDropHint")),1)]))],544)}}});const le=U(X,[["__scopeId","data-v-a2642a17"]]);export{le as default};

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

@ -1 +1 @@
import{u as w,a as y,F as k,d as x}from"./FileItem-70868dea.js";import{d as h,a1 as F,c9 as b,r as D,bh as I,bl as C,U as V,V as E,c,a3 as z,a4 as e,af as S,cP as B,cR as R,a0 as A}from"./index-632e7cf6.js";import"./numInput.vue_vue_type_style_index_0_scoped_55978858_lang-221425da.js";/* empty css */import"./index-a2a27adc.js";import"./_isIterateeCall-582f579a.js";import"./index-9d95a206.js";import"./index-a44b2ffa.js";const K=h({__name:"gridView",props:{tabIdx:{},paneIdx:{},id:{},removable:{type:Boolean},allowDragAndDrop:{type:Boolean},files:{},paneKey:{}},setup(p){const o=p,m=F(),{stackViewEl:d}=w().toRefs(),{itemSize:i,gridItems:u,cellWidth:f}=y(),g=b(),a=D(o.files??[]),_=async s=>{const l=B(s);o.allowDragAndDrop&&l&&(a.value=R([...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:_},[c(e(x),{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 n;return[c(k,{idx:r,file:t,"cell-width":e(f),"enable-close-icon":o.removable,onCloseIconClick:T=>v(r),"full-screen-preview-image-url":e(S)(t),"extra-tags":(n=t==null?void 0:t.tags)==null?void 0:n.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 W=A(K,[["__scopeId","data-v-f35f4802"]]);export{W as default};
import{u as w,a as y,F as k,d as x}from"./FileItem-2b09179d.js";import{d as h,a1 as F,c9 as b,r as D,bh as I,bl as C,U as V,V as E,c,a3 as z,a4 as e,af as S,cT as B,cV as R,a0 as T}from"./index-64cbe4df.js";import"./numInput.vue_vue_type_style_index_0_scoped_55978858_lang-4748a0d9.js";/* empty css */import"./index-53055c61.js";import"./_isIterateeCall-e4f71c4d.js";import"./index-8c941714.js";import"./index-01c239de.js";const A=h({__name:"gridView",props:{tabIdx:{},paneIdx:{},id:{},removable:{type:Boolean},allowDragAndDrop:{type:Boolean},files:{},paneKey:{}},setup(p){const o=p,m=F(),{stackViewEl:d}=w().toRefs(),{itemSize:i,gridItems:u,cellWidth:f}=y(),g=b(),a=D(o.files??[]),_=async s=>{const l=B(s);o.allowDragAndDrop&&l&&(a.value=R([...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:_},[c(e(x),{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 n;return[c(k,{idx:r,file:t,"cell-width":e(f),"enable-close-icon":o.removable,onCloseIconClick:K=>v(r),"full-screen-preview-image-url":e(S)(t),"extra-tags":(n=t==null?void 0:t.tags)==null?void 0:n.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=T(A,[["__scopeId","data-v-f35f4802"]]);export{j as default};

View File

@ -1 +1 @@
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-632e7cf6.js";import{u as L,a as Q,b as j,e as H}from"./FileItem-70868dea.js";import{a as T,b as U,c as W}from"./MultiSelectKeep-a8fa9049.js";import{u as B}from"./useGenInfoDiff-021d05e1.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-64cbe4df.js";import{u as L,a as Q,b as j,e as H}from"./FileItem-2b09179d.js";import{a as T,b as U,c as W}from"./MultiSelectKeep-e2324426.js";import{u as B}from"./useGenInfoDiff-bfe60e2e.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};

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-56137fc5.js vendored Normal file
View File

@ -0,0 +1 @@
import{r as o,o as t,cM as n}from"./index-64cbe4df.js";const a=function(){var e=o(!1);return t(function(){e.value=n()}),e};export{a as u};

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{d as F,u as S,G as k,an as j,h as d,c as s,aq as U,e6 as W,r as q,bh as G,Z as V,dv as Z,P as N,cb as z}from"./index-632e7cf6.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(U.join("|"),")(-inverse)?$")),L=new RegExp("^(".concat(W.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=q(!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),A=I?s(V,null,[I,s("span",null,[T])]):T,D="onClick"in g,$=s("span",{class:E.value,style:O},[A,B()]);return D?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 _};
import{d as F,u as S,G as k,an as j,h as d,c as s,aq as U,e8 as W,r as q,bh as G,Z as V,dx as Z,P as N,cb as z}from"./index-64cbe4df.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(U.join("|"),")(-inverse)?$")),L=new RegExp("^(".concat(W.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=q(!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),A=I?s(V,null,[I,s("span",null,[T])]):T,D="onClick"in g,$=s("span",{class:E.value,style:O},[A,B()]);return D?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 _};

View File

@ -1 +0,0 @@
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-632e7cf6.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};

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

@ -0,0 +1 @@
import{av as M,G as l,ax as D,ay as P,d as F,u as I,r as K,o as L,cx as _,b as y,bk as T,cy as A,an as $,h as o,c as B,a as G}from"./index-64cbe4df.js";import{u as V}from"./index-56137fc5.js";var E=Symbol("rowContextKey"),W=function(r){D(E,r)},k=function(){return M(E,{gutter:l(function(){}),wrap:l(function(){}),supportFlexGap:l(function(){})})};P("top","middle","bottom","stretch");P("start","end","center","space-around","space-between");var U=function(){return{align:String,justify:String,prefixCls:String,gutter:{type:[Number,Array,Object],default:0},wrap:{type:Boolean,default:void 0}}},q=F({compatConfig:{MODE:3},name:"ARow",props:U(),setup:function(r,N){var g=N.slots,v=I("row",r),d=v.prefixCls,h=v.direction,j,x=K({xs:!0,sm:!0,md:!0,lg:!0,xl:!0,xxl:!0,xxxl:!0}),w=V();L(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)})}),T(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});W({gutter:S,supportFlexGap:w,wrap:l(function(){return r.wrap})});var R=l(function(){var e;return $(d.value,(e={},o(e,"".concat(d.value,"-no-wrap"),r.wrap===!1),o(e,"".concat(d.value,"-").concat(r.justify),r.justify),o(e,"".concat(d.value,"-").concat(r.align),r.align),o(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 B("div",{class:R.value,style:O.value},[(e=g.default)===null||e===void 0?void 0:e.call(g)])}}});const Y=q;function H(c){return typeof c=="number"?"".concat(c," ").concat(c," auto"):/^\d+(\.\d+)?(px|em|rem|%)$/.test(c)?"0 0 ".concat(c):c}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 Z=F({compatConfig:{MODE:3},name:"ACol",props:J(),setup:function(r,N){var g=N.slots,v=k(),d=v.gutter,h=v.supportFlexGap,j=v.wrap,x=I("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(m){var f,u={},C=r[m];typeof C=="number"?u.span=C:y(C)==="object"&&(u=C||{}),p=G(G({},p),{},(f={},o(f,"".concat(a,"-").concat(m,"-").concat(u.span),u.span!==void 0),o(f,"".concat(a,"-").concat(m,"-order-").concat(u.order),u.order||u.order===0),o(f,"".concat(a,"-").concat(m,"-offset-").concat(u.offset),u.offset||u.offset===0),o(f,"".concat(a,"-").concat(m,"-push-").concat(u.push),u.push||u.push===0),o(f,"".concat(a,"-").concat(m,"-pull-").concat(u.pull),u.pull||u.pull===0),o(f,"".concat(a,"-rtl"),S.value==="rtl"),f))}),$(a,(e={},o(e,"".concat(a,"-").concat(t),t!==void 0),o(e,"".concat(a,"-order-").concat(n),n),o(e,"".concat(a,"-offset-").concat(s),s),o(e,"".concat(a,"-push-").concat(i),i),o(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=H(e),j.value===!1&&!n.minWidth&&(n.minWidth=0)),n});return function(){var e;return B("div",{class:R.value,style:O.value},[(e=g.default)===null||e===void 0?void 0:e.call(g)])}}});export{Z as C,Y as R};

View File

@ -1 +1 @@
import{d as x,a1 as $,aK as g,cS 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,cT as N,a0 as R}from"./index-632e7cf6.js";/* empty css */const F={class:"container"},K={class:"actions"},L={class:"uni-desc"},T={class:"snapshot"},U=x({__name:"index",props:{tabIdx:{},paneIdx:{},id:{},paneKey:{}},setup(j){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",T,[(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 A=R(U,[["__scopeId","data-v-2c44013c"]]);export{A as default};
import{d as x,a1 as $,aK as g,cW 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 W,a4 as m,y as I,z as _,B as v,aj as V,ak as D,cX as N,a0 as R}from"./index-64cbe4df.js";/* empty css */const F={class:"container"},K={class:"actions"},L={class:"uni-desc"},U={class:"snapshot"},X=x({__name:"index",props:{tabIdx:{},paneIdx:{},id:{},paneKey:{}},setup(j){const h=$(),t=g(),f=e=>{h.tabList=I(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=V,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,W(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 A=R(X,[["__scopeId","data-v-2c44013c"]]);export{A as default};

View File

@ -1 +1 @@
import{d as w,bC as D,av as A,cJ as j,az as k,n as V,cK as B,cL as y,e as $,c as a,_ as M,h as r,a as P,cM as T,P as b}from"./index-632e7cf6.js";var J=["class","style"],K=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 L(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(K(),{size:"default",spinning:!0,wrapperClassName:""}),setup:function(){return{originalUpdateSpinning:null,configProvider:A("configProvider",j)}},data:function(){var n=this.spinning,e=this.delay,i=L(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=M(l,J),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({},C),{},{style:_,class:E}),[this.renderIndicator(s),d?a("div",{class:"".concat(s,"-text")},[d]):null]),g=T(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};
import{d as w,bC as D,av as A,cN as j,az as k,n as V,cO as B,cP as y,e as $,c as a,_ as O,h as r,a as P,cQ as T,P as b}from"./index-64cbe4df.js";var M=["class","style"],Q=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 W(t,n){return!!t&&!!n&&!isNaN(Number(n))}function F(t){var n=t.indicator;p=typeof n=="function"?n:function(){return a(n,null,null)}}const G=w({compatConfig:{MODE:3},name:"ASpin",inheritAttrs:!1,props:D(Q(),{size:"default",spinning:!0,wrapperClassName:""}),setup:function(){return{originalUpdateSpinning:null,configProvider:A("configProvider",j)}},data:function(){var n=this.spinning,e=this.delay,i=W(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,N=o.prefixCls,h=o.tip,d=h===void 0?(n=(e=this.$slots).tip)===null||n===void 0?void 0:n.call(e):h,x=o.wrapperClassName,l=this.$attrs,v=l.class,_=l.style,C=O(l,M),S=this.configProvider,U=S.getPrefixCls,z=S.direction,s=U("spin",N),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({},C),{},{style:_,class:E}),[this.renderIndicator(s),d?a("div",{class:"".concat(s,"-text")},[d]):null]),g=T(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"),x]},[u&&a("div",{key:"loading"},[m]),a("div",{class:I,key:"container"},[g])])}return m}});export{G as S,F as s};

View File

@ -1 +1 @@
import{cw as j,ay as z,d as K,j as U,du 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 _,dv as W,P as u,dw as x}from"./index-632e7cf6.js";var R=z("small","default"),q=function(){return{id:String,prefixCls:String,size:u.oneOf(R),disabled:{type:Boolean,default:void 0},checkedChildren:u.any,unCheckedChildren:u.any,tabindex:u.oneOfType([u.string,u.number]),autofocus:{type:Boolean,default:void 0},loading:{type:Boolean,default:void 0},checked:u.oneOfType([u.string,u.number,u.looseBool]),checkedValue:u.oneOfType([u.string,u.number,u.looseBool]).def(!0),unCheckedValue:u.oneOfType([u.string,u.number,u.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:q(),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),c=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(c.value,"-small"),T.value==="small"),d(a,"".concat(c.value,"-loading"),n.loading),d(a,"".concat(c.value,"-checked"),f.value),d(a,"".concat(c.value,"-disabled"),n.disabled),d(a,c.value,!0),d(a,"".concat(c.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(c.value,"-handle")},[n.loading?s(L,{class:"".concat(c.value,"-loading-icon")},null):null]),s("span",{class:"".concat(c.value,"-inner")},[f.value?_(y,n,"checkedChildren"):_(y,n,"unCheckedChildren")])])]}})}}});const X=j(J);export{X as _};
import{cw as j,ay as z,d as K,j as U,dw 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 x,dx as W,P as c,dy as _}from"./index-64cbe4df.js";var R=z("small","default"),q=function(){return{id:String,prefixCls:String,size:c.oneOf(R),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:q(),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===_.LEFT?k(n.unCheckedValue,e):e.keyCode===_.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?x(y,n,"checkedChildren"):x(y,n,"unCheckedChildren")])])]}})}}});const X=j(J);export{X as _};

View File

@ -0,0 +1 @@
import{cl as e,cm as i,cn as r,co as a,b1 as n}from"./index-64cbe4df.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};

View File

@ -1 +0,0 @@
import{cl as e,cm as i,cn as r,co as a,b1 as n}from"./index-632e7cf6.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};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{d as j,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,cY as ce,cZ as de,ak as ue,ai as me,T as fe,a0 as pe}from"./index-64cbe4df.js";import{u as ve,c as ge,a as ke,F as we,d as he}from"./FileItem-2b09179d.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-e2324426.js";import"./numInput.vue_vue_type_style_index_0_scoped_55978858_lang-4748a0d9.js";/* empty css */import"./index-53055c61.js";import"./_isIterateeCall-e4f71c4d.js";import"./index-8c941714.js";import"./index-01c239de.js";import"./shortcut-86575428.js";import"./Checkbox-65a2741e.js";import"./index-f0ba7b9c.js";const Ve={class:"refresh-button"},Me={class:"hint"},Te={key:0,class:"preview-switch"},Fe=j({__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:Y,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,Q=me,X=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(X,{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(Q,{active:"",loading:!e(Y).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,Z)=>e(z)(l.value,Z)},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-49082269"]]);export{We as default};

View File

@ -1 +0,0 @@
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,cU as ce,cV as de,ak as ue,ai as me,T as fe,a0 as pe}from"./index-632e7cf6.js";import{u as ve,c as ge,a as ke,F as we,d as he}from"./FileItem-70868dea.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-a8fa9049.js";import"./numInput.vue_vue_type_style_index_0_scoped_55978858_lang-221425da.js";/* empty css */import"./index-a2a27adc.js";import"./_isIterateeCall-582f579a.js";import"./index-9d95a206.js";import"./index-a44b2ffa.js";import"./shortcut-5c51118c.js";import"./Checkbox-2af1c4fc.js";import"./index-1bd869eb.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:U,multiSelectedIdxs:p,stack:K,scroller:L}=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,onContextMenuClick:H,onFileItemClick:J}=Se({openNext:de}),{previewIdx:o,previewing:y,onPreviewVisibleChange:Q,previewImgMove:x,canPreview:b}=_e(),V=async(s,t,d)=>{K.value=[{curr:"",files:l.value}],await H(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:U},[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:L,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(Q),"is-selected-mutil-files":e(p).length>1,selected:e(p).includes(T),onFileItemClick:e(J),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-49082269"]]);export{We as default};

View File

@ -1 +1 @@
import{R as y,C as v}from"./index-ab5ead62.js";import{cw as f,c as d,A as w,d as P,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-632e7cf6.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 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-d2c56e4b.js";import{cw as f,c as d,A as w,d as P,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-64cbe4df.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 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{u as G,g as d}from"./FileItem-70868dea.js";import{r as b,t as j,ct as m,cu as y,cv as D}from"./index-632e7cf6.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};
import{u as G,g as d}from"./FileItem-2b09179d.js";import{r as b,t as j,ct as m,cu as y,cv as D}from"./index-64cbe4df.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};

2
vue/dist/index.html vendored
View File

@ -7,7 +7,7 @@
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite Image Browsing</title>
<script type="module" crossorigin src="/infinite_image_browsing/fe-static/assets/index-632e7cf6.js"></script>
<script type="module" crossorigin src="/infinite_image_browsing/fe-static/assets/index-64cbe4df.js"></script>
<link rel="stylesheet" href="/infinite_image_browsing/fe-static/assets/index-d385cc4f.css">
</head>

View File

@ -19,6 +19,7 @@
"@zanllp/vue-virtual-scroller": "^2.0.0-beta.7",
"ant-design-vue": "^3.2.20",
"axios": "^1.4.0",
"echarts": "^6.0.0",
"jian-pinyin": "^0.2.3",
"js-cookie": "^3.0.5",
"multi-nprogress": "^0.3.5",

View File

@ -308,4 +308,68 @@ export interface PromptSearchResp {
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
}
// ===== Hierarchical Tag Graph =====
export interface TagGraphReq {
folder_paths: string[]
lang?: string
}
export interface LayerNode {
id: string
label: string
size: number
metadata?: {
type: string
image_count?: number
cluster_count?: number
level?: number
}
}
export interface GraphLayer {
level: number
name: string
nodes: LayerNode[]
}
export interface GraphLink {
source: string
target: string
weight: number
}
export interface TagGraphResp {
layers: GraphLayer[]
links: GraphLink[]
stats: {
total_clusters: number
selected_clusters: number
total_tags: number
selected_tags: number
abstraction_layers: number
total_links: number
topic_cluster_cache_key?: string
}
}
export const getClusterTagGraph = async (req: TagGraphReq) => {
// Large datasets can take longer to build / transfer; keep a generous timeout.
const resp = await axiosInst.value.post('/db/cluster_tag_graph', req, { timeout: 300000 })
return resp.data as TagGraphResp
}
export interface TagGraphClusterPathsReq {
topic_cluster_cache_key: string
cluster_id: string
}
export interface TagGraphClusterPathsResp {
paths: string[]
}
export const getClusterTagGraphClusterPaths = async (req: TagGraphClusterPathsReq) => {
const resp = await axiosInst.value.post('/db/cluster_tag_graph_cluster_paths', req, { timeout: 300000 })
return resp.data as TagGraphClusterPathsResp
}

View File

@ -12,6 +12,28 @@ export const de: Partial<IIBI18nMap> = {
'fuzzy-search': 'Schnellsuche',
autoUpdate: 'Erkannte Änderungen, automatische Aktualisierung wird ausgeführt',
faq: 'FAQ',
helpFeedback: 'Hilfe / Feedback',
helpFeedbackWay1: 'FAQ ansehen / ähnliche Issues suchen',
helpFeedbackSearchIssues: 'Issues durchsuchen',
helpFeedbackWay2: 'Neues Issue erstellen',
helpFeedbackNewIssue: 'Auf GitHub erstellen',
helpFeedbackWay3: 'Dem Maintainer eine E-Mail senden',
// ===== Tag Graph (Topic Search) =====
tagGraphGenerating: 'Diagramm wird erzeugt…',
tagGraphStatLayers: 'Ebenen',
tagGraphStatNodes: 'Knoten',
tagGraphStatLinks: 'Verbindungen',
tagGraphAllLayers: 'Alle Ebenen',
tagGraphFilterPlaceholder: 'Stichwort-Filter (Treffer + Nachbarn)',
tagGraphFilterHopsTitle: 'Erweitern (N Hops)',
tagGraphKeywordLimitTitle: 'Maximale Keywords in der Tag-Ebene',
tagGraphFilterApply: 'Filtern',
tagGraphFilterReset: 'Zurücksetzen',
tagGraphTooltipFilter: 'Filtern',
tagGraphTooltipOpenCluster: 'Cluster öffnen',
tagGraphFullscreenUnsupported: 'Vollbild wird in dieser Umgebung nicht unterstützt',
tagGraphFullscreenFailed: 'Vollbild konnte nicht gestartet werden',
selectExactMatchTag: 'Wähle Tags für exakte Übereinstimmung aus',
selectAnyMatchTag: '(Optional) Wähle Tags für beliebige Übereinstimmung aus',
selectExcludeTag: '(Optional) Wähle Tags zum Ausschliessen aus',
@ -40,8 +62,12 @@ export const de: Partial<IIBI18nMap> = {
topicSearchScopePlaceholder: 'Ordner auswählen (Mehrfachauswahl)',
topicSearchRecallMsg: '{0} / {1} abgerufen (TopK={2})',
topicSearchCacheStale: 'Zwischengespeichertes Ergebnis wird angezeigt (Update verfügbar)',
topicSearchCacheStaleDesc: 'Ordner im ausgewählten Bereich könnten geändert worden sein. Der Cache könnte veraltet sein. Klicken Sie auf „Update, um Themen neu zu erzeugen (Index wird zuerst aktualisiert).',
topicSearchCacheStaleDesc: 'Ordner im ausgewählten Bereich könnten geändert worden sein. Der Cache könnte veraltet sein. Klicken Sie auf „Update", um Themen neu zu erzeugen (Index wird zuerst aktualisiert).',
topicSearchCacheUpdate: 'Cache aktualisieren',
topicSearchCacheHit: 'Zwischengespeichertes Ergebnis',
topicSearchCacheHitDesc: 'Zeigt zwischengespeicherte Themen aus früheren Analysen an. Klicken Sie auf Update, um neu zu erzeugen.',
topicSearchCollapse: 'Einklappen',
topicSearchExpand: 'Ausklappen',
topicSearchGuideTitle: 'Schnellstart (Experimentell)',
topicSearchGuideStep1: 'Wählen Sie die Ordner (Bereich) zur Analyse aus (Mehrfachauswahl)',

View File

@ -42,6 +42,10 @@ export const en: IIBI18nMap = {
topicSearchCacheStale: 'Showing cached result (update available)',
topicSearchCacheStaleDesc: 'Folders in the selected scope may have changed. The cache may be stale. Click Update to regenerate topics (will update index first).',
topicSearchCacheUpdate: 'Update cache',
topicSearchCacheHit: 'Showing cached result',
topicSearchCacheHitDesc: 'Displaying cached topics from previous analysis. Click Update to regenerate.',
topicSearchCollapse: 'Collapse',
topicSearchExpand: 'Expand',
topicSearchGuideTitle: 'Quick Start (Experimental)',
topicSearchGuideStep1: 'Select the scope folders to analyze (multi-select)',
@ -249,6 +253,28 @@ You can specify which snapshot to restore to when starting IIB in the global set
'fuzzy-search': 'Fuzzy search',
autoUpdate: 'Detected changes, automatically updating',
faq: 'FAQ',
helpFeedback: 'Help / Feedback',
helpFeedbackWay1: 'Check FAQ / search related issues',
helpFeedbackSearchIssues: 'Search issues',
helpFeedbackWay2: 'Open a new issue',
helpFeedbackNewIssue: 'Create on GitHub',
helpFeedbackWay3: 'Email the maintainer',
// ===== Tag Graph (Topic Search) =====
tagGraphGenerating: 'Generating graph...',
tagGraphStatLayers: 'Layers',
tagGraphStatNodes: 'Nodes',
tagGraphStatLinks: 'Links',
tagGraphAllLayers: 'All layers',
tagGraphFilterPlaceholder: 'Keyword filter (match + neighbors)',
tagGraphFilterHopsTitle: 'Expand hops (N)',
tagGraphKeywordLimitTitle: 'Maximum keywords to display in Tag layer',
tagGraphFilterApply: 'Filter',
tagGraphFilterReset: 'Reset',
tagGraphTooltipFilter: 'Filter',
tagGraphTooltipOpenCluster: 'Open cluster',
tagGraphFullscreenUnsupported: 'Fullscreen is not supported in this environment',
tagGraphFullscreenFailed: 'Failed to enter fullscreen',
selectExactMatchTag: 'Select Exact Match Tags. You can search by entering partial characters',
selectAnyMatchTag: 'Optional, Select Any Match Tags. You can search by entering partial characters',
selectExcludeTag: 'Optional, Select Exclude Tags. You can search by entering partial characters',

View File

@ -40,6 +40,10 @@ export const zhHans = {
topicSearchCacheStale: '已显示缓存结果(可更新)',
topicSearchCacheStaleDesc: '检测到范围内文件夹可能有变更,缓存可能已过期。可点击更新重新生成主题(会先更新索引)。',
topicSearchCacheUpdate: '更新缓存',
topicSearchCacheHit: '已显示缓存结果',
topicSearchCacheHitDesc: '显示之前的缓存主题。点击更新重新生成。',
topicSearchCollapse: '收起',
topicSearchExpand: '展开',
topicSearchGuideTitle: '快速上手(实验性)',
topicSearchGuideStep1: '选择要分析的文件夹范围(可多选)',
@ -266,6 +270,28 @@ export const zhHans = {
selectAnyMatchTag: '可选,选择匹配其中一个或多个的 Tag。 您可以输入部分字符进行搜索',
selectExcludeTag: '可选,选择需要排除掉的 Tag。 您可以输入部分字符进行搜索',
faq: '常见问题',
helpFeedback: '寻求帮助/反馈',
helpFeedbackWay1: '先看看常见问题 / 找找相关 issue',
helpFeedbackSearchIssues: '搜索 issues',
helpFeedbackWay2: '提一个新的 issue',
helpFeedbackNewIssue: '去 GitHub 创建',
helpFeedbackWay3: '直接发邮件给维护者',
// ===== Tag Graph (Topic Search) =====
tagGraphGenerating: '正在生成关系图...',
tagGraphStatLayers: '层级',
tagGraphStatNodes: '节点',
tagGraphStatLinks: '连线',
tagGraphAllLayers: '全部层级',
tagGraphFilterPlaceholder: '关键字过滤(命中 + 上下游)',
tagGraphFilterHopsTitle: '扩展层数N 跳)',
tagGraphKeywordLimitTitle: '标签层最大显示关键词数',
tagGraphFilterApply: '筛选',
tagGraphFilterReset: '重置',
tagGraphTooltipFilter: '过滤',
tagGraphTooltipOpenCluster: '打开聚类',
tagGraphFullscreenUnsupported: '当前环境不支持全屏',
tagGraphFullscreenFailed: '全屏失败',
autoUpdate: '检测到发生改变自动更新',
'fuzzy-search': '模糊搜索',
'fuzzy-search-placeholder': '输入图像信息或者文件名的一部分来进行搜索',

View File

@ -42,6 +42,10 @@ export const zhHant: Partial<IIBI18nMap> = {
topicSearchCacheStale: '已顯示快取結果(可更新)',
topicSearchCacheStaleDesc: '偵測到範圍內資料夾可能有變更,快取可能已過期。可點擊更新重新生成主題(會先更新索引)。',
topicSearchCacheUpdate: '更新快取',
topicSearchCacheHit: '已顯示快取結果',
topicSearchCacheHitDesc: '顯示之前的快取主題。點擊更新重新生成。',
topicSearchCollapse: '收合',
topicSearchExpand: '展開',
topicSearchGuideTitle: '快速上手(實驗性)',
topicSearchGuideStep1: '選擇要分析的資料夾範圍(可多選)',
@ -273,6 +277,28 @@ export const zhHant: Partial<IIBI18nMap> = {
selectAnyMatchTag: '可選,選擇匹配其中一個或多個的 Tag。 您可以輸入部分字符進行搜索',
selectExcludeTag: '可選,選擇需要排除掉的 Tag。 您可以輸入部分字符進行搜索',
faq: '常見問題',
helpFeedback: '尋求幫助/回饋',
helpFeedbackWay1: '先看看常見問題 / 找找相關 issue',
helpFeedbackSearchIssues: '搜尋 issues',
helpFeedbackWay2: '提一個新的 issue',
helpFeedbackNewIssue: '去 GitHub 建立',
helpFeedbackWay3: '直接發郵件給維護者',
// ===== Tag Graph (Topic Search) =====
tagGraphGenerating: '正在生成關係圖...',
tagGraphStatLayers: '層級',
tagGraphStatNodes: '節點',
tagGraphStatLinks: '連線',
tagGraphAllLayers: '全部層級',
tagGraphFilterPlaceholder: '關鍵字過濾(命中 + 上下游)',
tagGraphFilterHopsTitle: '擴展層數N 跳)',
tagGraphKeywordLimitTitle: '標籤層最大顯示關鍵詞數',
tagGraphFilterApply: '篩選',
tagGraphFilterReset: '重置',
tagGraphTooltipFilter: '過濾',
tagGraphTooltipOpenCluster: '打開聚類',
tagGraphFullscreenUnsupported: '目前環境不支援全螢幕',
tagGraphFullscreenFailed: '全螢幕失敗',
autoUpdate: '檢測到發生改變自動更新',
'fuzzy-search': '模糊搜尋',
'fuzzy-search-placeholder': '輸入圖片信息或者文件名的一部分來進行搜尋',

View File

@ -2,9 +2,9 @@
import { useGlobalStore, type TabPane } from '@/store/useGlobalStore'
import { Snapshot, useWorkspeaceSnapshot } from '@/store/useWorkspeaceSnapshot'
import { uniqueId } from 'lodash-es'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { ok } from 'vue3-ts-util'
import { FileDoneOutlined, LockOutlined, PlusOutlined, QuestionCircleOutlined } from '@/icon'
import { FileDoneOutlined, GithubOutlined, LockOutlined, MailOutlined, PlusOutlined, QuestionCircleOutlined } from '@/icon'
import { t } from '@/i18n'
import { cloneDeep } from 'lodash-es'
import { useImgSliStore } from '@/store/useImgSli'
@ -35,6 +35,12 @@ onMounted(() => {
const sync = useSettingSync()
const helpModalOpen = ref(false)
const FAQ_URL = 'https://github.com/zanllp/sd-webui-infinite-image-browsing/issues/90'
const ISSUES_SEARCH_URL = 'https://github.com/zanllp/sd-webui-infinite-image-browsing/issues?q='
const NEW_ISSUE_URL = 'https://github.com/zanllp/sd-webui-infinite-image-browsing/issues/new'
const FEEDBACK_MAIL = 'mailto:qc@zanllp.cn'
const compCnMap: Partial<Record<TabPane['type'], string>> = {
local: t('local'),
@ -198,8 +204,7 @@ const modes = computed(() => {
</a-badge>
<a href="https://github.com/zanllp/sd-webui-infinite-image-browsing/wiki/Change-log" target="_blank"
class="quick-action">{{ $t('changlog') }}</a>
<a href="https://github.com/zanllp/sd-webui-infinite-image-browsing/issues/90" target="_blank"
class="quick-action">{{ $t('faq') }}</a>
<a href="#" class="quick-action" @click.prevent="helpModalOpen = true">{{ $t('helpFeedback') }}</a>
<div class="quick-action" v-if="!isTauri">
{{ $t('sync') }} <a-tooltip :title="$t('syncDesc')">
<QuestionCircleOutlined/>
@ -211,6 +216,48 @@ const modes = computed(() => {
<a-radio-button value="dark">Dark</a-radio-button>
</a-radio-group>
</div>
<a-modal
v-model:visible="helpModalOpen"
:title="$t('helpFeedback')"
:footer="null"
:mask-closable="true"
width="520px"
>
<div style="display: grid; gap: 10px;">
<div style="display: flex; gap: 10px; align-items: flex-start;">
<QuestionCircleOutlined style="margin-top: 2px; opacity: 0.85;" />
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600;">{{ $t('helpFeedbackWay1') }}</div>
<div style="margin-top: 6px; display: flex; gap: 10px; flex-wrap: wrap;">
<a :href="FAQ_URL" target="_blank" rel="noopener noreferrer">{{ $t('faq') }}</a>
<a :href="ISSUES_SEARCH_URL" target="_blank" rel="noopener noreferrer">{{ $t('helpFeedbackSearchIssues') }}</a>
</div>
</div>
</div>
<div style="display: flex; gap: 10px; align-items: flex-start;">
<GithubOutlined style="margin-top: 2px; opacity: 0.85;" />
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600;">{{ $t('helpFeedbackWay2') }}</div>
<div style="margin-top: 6px;">
<a :href="NEW_ISSUE_URL" target="_blank" rel="noopener noreferrer">{{ $t('helpFeedbackNewIssue') }}</a>
</div>
</div>
</div>
<div style="display: flex; gap: 10px; align-items: flex-start;">
<MailOutlined style="margin-top: 2px; opacity: 0.85;" />
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600;">{{ $t('helpFeedbackWay3') }}</div>
<div style="margin-top: 6px;">
<a :href="FEEDBACK_MAIL">qc@zanllp.cn</a>
</div>
</div>
</div>
</div>
</a-modal>
<a-alert show-icon v-if="global.conf?.enable_access_control && !global.dontShowAgain">
<template #message>
<div class="access-mode-message">

View File

@ -0,0 +1,968 @@
<template>
<div ref="fullscreenRef" class="tag-hierarchy-graph">
<div v-if="loading" class="loading-container">
<a-spin size="large" />
<div class="loading-text">{{ t('tagGraphGenerating') }}</div>
</div>
<div v-else-if="error" class="error-container">
<a-alert type="error" :message="error" show-icon />
</div>
<div v-else-if="graphData" class="graph-container">
<!-- Control Panel -->
<div class="control-panel">
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
<a-space>
<a-tag>{{ t('tagGraphStatLayers') }}: {{ displayGraphData?.layers?.length ?? 0 }}</a-tag>
<a-tag>{{ t('tagGraphStatNodes') }}: {{ displayNodeCount }}</a-tag>
<a-tag>{{ t('tagGraphStatLinks') }}: {{ displayLinkCount }}</a-tag>
</a-space>
<div style="flex: 1 1 auto;"></div>
<!-- Filter Form (compact) -->
<a-space :size="8">
<a-select
v-model:value="filterLayer"
:options="layerOptions"
style="width: 140px;"
:getPopupContainer="(trigger: HTMLElement) => trigger.parentElement || trigger"
/>
<a-input
v-model:value="filterKeyword"
style="width: 200px;"
allow-clear
:placeholder="t('tagGraphFilterPlaceholder')"
@keydown.enter="applyFilterManual"
/>
<a-input-number
v-model:value="filterHops"
size="small"
:min="1"
:max="20"
:step="1"
style="width: 92px;"
:title="t('tagGraphFilterHopsTitle')"
/>
<a-input-number
v-model:value="filterKeywordLimit"
size="small"
:min="10"
:max="1000"
:step="10"
style="width: 100px;"
:title="t('tagGraphKeywordLimitTitle')"
/>
<a-button size="small" @click="applyFilterManual">{{ t('tagGraphFilterApply') }}</a-button>
<a-button size="small" @click="resetFilter">{{ t('tagGraphFilterReset') }}</a-button>
<a-button
size="small"
:title="isFullscreen ? t('exitFullscreen') : t('fullscreen')"
@click="toggleFullscreen"
>
<FullscreenExitOutlined v-if="isFullscreen" />
<FullscreenOutlined v-else />
</a-button>
</a-space>
</div>
</div>
<!-- ECharts Container -->
<div ref="chartRef" class="chart-container"></div>
</div>
</div>
</template>
<script setup lang="ts">
/* eslint-disable */
import { computed, ref, onMounted, watch, nextTick, onUnmounted } from 'vue'
import { getClusterTagGraph, getClusterTagGraphClusterPaths, type TagGraphReq, type TagGraphResp } from '@/api/db'
import { message } from 'ant-design-vue'
import * as echarts from 'echarts'
import { t } from '@/i18n'
import { FullscreenExitOutlined, FullscreenOutlined } from '@/icon'
interface Props {
folders: string[]
lang?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
searchTag: [tag: string]
openCluster: [cluster: { title: string; paths: string[]; size: number }]
}>()
const loading = ref(false)
const error = ref<string>('')
const graphData = ref<TagGraphResp | null>(null)
const displayGraphData = ref<TagGraphResp | null>(null)
const chartRef = ref<HTMLDivElement>()
const fullscreenRef = ref<HTMLElement>()
let chartInstance: echarts.ECharts | null = null
let _handleResize: (() => void) | null = null
let _docClickHandler: ((e: MouseEvent) => void) | null = null
let _fsChangeHandler: (() => void) | null = null
let _indexById: Record<string, number> = {}
type LayerOption = { label: string; value: string }
const filterLayer = ref<string>('__all__')
const filterKeyword = ref<string>('')
// Expand matched nodes by N hops to keep chains connected.
const filterHops = ref<number>(3)
// When set, filter is anchored to this exact node id (so same-layer only keeps that node itself)
const filterExactNodeId = ref<string>('')
// Maximum number of keywords to display in Tag layer (prevents ECharts performance issues)
// Higher = more keywords but slower rendering. 200 is a good balance for typical image datasets.
const filterKeywordLimit = ref<number>(200)
// Temporary storage for expansion hops when filtering by node
const _tempHopsUp = ref<number>(0)
const _tempHopsDown = ref<number>(0)
const isFullscreen = ref(false)
const toggleFullscreen = async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen()
return
}
const el = fullscreenRef.value
if (!el || !(el as any).requestFullscreen) {
message.warning(t('tagGraphFullscreenUnsupported'))
return
}
await (el as any).requestFullscreen()
} catch (e: any) {
message.error(e?.message || t('tagGraphFullscreenFailed'))
}
}
const layerOptions = computed<LayerOption[]>(() => {
const layers = graphData.value?.layers ?? []
const names = layers.map((l) => String((l as any).name ?? '')).filter(Boolean)
const uniq = Array.from(new Set(names))
return [{ label: t('tagGraphAllLayers'), value: '__all__' }, ...uniq.map((n) => ({ label: n, value: n }))]
})
const displayNodeCount = computed(() => {
const layers = displayGraphData.value?.layers ?? []
return layers.reduce((acc: number, l: any) => acc + (l?.nodes?.length ?? 0), 0)
})
const displayLinkCount = computed(() => (displayGraphData.value?.links?.length ?? 0))
// Layer colors (different for each layer)
const layerColors = [
'#4A90E2', // Layer 0: Clusters - Blue
'#7B68EE', // Layer 1: Tags - Purple
'#50C878', // Layer 2: Abstract-1 - Green
'#FF6B6B', // Layer 3: Abstract-2 - Red
'#FFD700', // Layer 4: Abstract-3 - Yellow
'#FF8C00', // Layer 5: Abstract-4 - Dark Orange
]
const getLayerColor = (layer: number): string => {
return layerColors[layer % layerColors.length]
}
const fetchGraphData = async () => {
if (!props.folders || props.folders.length === 0) {
error.value = 'No folders selected'
return
}
loading.value = true
error.value = ''
try {
const req: TagGraphReq = {
folder_paths: props.folders,
lang: props.lang || 'en',
}
graphData.value = await getClusterTagGraph(req)
displayGraphData.value = {
...graphData.value,
layers: limitTagLayer(graphData.value.layers as any[], filterKeywordLimit.value || 200)
}
} catch (err: any) {
const detail = err.response?.data?.detail
error.value =
typeof detail === 'string'
? detail
: detail
? JSON.stringify(detail)
: (err.message || 'Failed to load graph')
message.error(error.value)
} finally {
loading.value = false
}
}
const refreshChart = () => {
if (!chartRef.value) return
nextTick(() => {
renderChart()
})
}
const limitTagLayer = (layers: any[], keywordLimit: number): any[] => {
const result: any[] = []
for (const l of layers) {
let nodes = l?.nodes ?? []
const layerLevel = Number(l?.level ?? 0)
if (layerLevel === 1 && nodes.length > keywordLimit) {
nodes = nodes.sort((a: any, b: any) => (b.size || 0) - (a.size || 0)).slice(0, keywordLimit)
}
if (nodes.length) {
result.push({ ...l, nodes })
}
}
return result
}
const applyFilterCore = () => {
const raw = graphData.value
if (!raw) return
const keyword = (filterKeyword.value || '').trim().toLowerCase()
const layer = filterLayer.value
const keywordLimit = filterKeywordLimit.value || 200
// No filter -> show all but still apply keyword limit
if (!filterExactNodeId.value && !keyword && (layer === '__all__' || !layer)) {
displayGraphData.value = {
...raw,
layers: limitTagLayer(raw.layers as any[], keywordLimit)
}
refreshChart()
return
}
// Flatten nodes with layer context
const nodeById = new Map<string, { id: string; label: string; layerName: string; layerLevel: number; metadata?: any }>()
for (const l of raw.layers as any[]) {
const layerName = String(l?.name ?? '')
const layerLevel = Number(l?.level ?? 0)
for (const n of (l?.nodes ?? []) as any[]) {
const id = String(n?.id ?? '')
if (!id) continue
nodeById.set(id, { id, label: String(n?.label ?? ''), layerName, layerLevel, metadata: n?.metadata })
}
}
const matched = new Set<string>()
// 1) match set
if (filterExactNodeId.value) {
if (nodeById.has(filterExactNodeId.value)) {
matched.add(filterExactNodeId.value)
}
} else {
// manual mode: layer + keyword contains
for (const [id, n] of nodeById) {
if (layer && layer !== '__all__' && n.layerName !== layer) continue
if (keyword) {
const hay = `${n.label} ${id}`.toLowerCase()
if (!hay.includes(keyword)) continue
}
matched.add(id)
}
}
const links = raw.links as any[]
// 2) Build adjacency for direction-aware filtering
const adj = new Map<string, Set<string>>()
const addEdge = (a: string, b: string) => {
if (!a || !b) return
if (!adj.has(a)) adj.set(a, new Set())
adj.get(a)!.add(b)
}
for (const lk of links) {
const s = String(lk?.source ?? '')
const t2 = String(lk?.target ?? '')
if (!s || !t2) continue
addEdge(s, t2)
addEdge(t2, s)
}
const include = new Set<string>()
if (filterExactNodeId.value) {
// 3) Direction-aware BFS for exact node filtering
const startNode = nodeById.get(filterExactNodeId.value)
if (!startNode) return
const hopsUp = _tempHopsUp.value
const hopsDown = _tempHopsDown.value
// Separate upward expansion (to higher levels)
const expandUpward = (startId: string, maxHops: number) => {
const visited = new Set<string>([startId])
const q: Array<{ id: string; d: number }> = [{ id: startId, d: 0 }]
while (q.length) {
const cur = q.shift()!
if (cur.d >= maxHops) continue
const ns = adj.get(cur.id)
if (!ns) continue
const curNode = nodeById.get(cur.id)
const curLevel = curNode?.layerLevel ?? 0
for (const nxt of ns) {
if (visited.has(nxt)) continue
const nxtNode = nodeById.get(nxt)
const nxtLevel = nxtNode?.layerLevel ?? 0
// Only allow upward moves (to higher levels)
if (nxtLevel > curLevel) {
visited.add(nxt)
include.add(nxt)
q.push({ id: nxt, d: cur.d + 1 })
}
}
}
}
// Separate downward expansion (to lower levels)
const expandDownward = (startId: string, maxHops: number) => {
const visited = new Set<string>([startId])
const q: Array<{ id: string; d: number }> = [{ id: startId, d: 0 }]
while (q.length) {
const cur = q.shift()!
if (cur.d >= maxHops) continue
const ns = adj.get(cur.id)
if (!ns) continue
const curNode = nodeById.get(cur.id)
const curLevel = curNode?.layerLevel ?? 0
for (const nxt of ns) {
if (visited.has(nxt)) continue
const nxtNode = nodeById.get(nxt)
const nxtLevel = nxtNode?.layerLevel ?? 0
// Only allow downward moves (to lower levels)
if (nxtLevel < curLevel) {
visited.add(nxt)
include.add(nxt)
q.push({ id: nxt, d: cur.d + 1 })
}
}
}
}
// Add start node
include.add(filterExactNodeId.value)
// Expand upward
if (hopsUp > 0) {
expandUpward(filterExactNodeId.value, hopsUp)
}
// Expand downward
if (hopsDown > 0) {
expandDownward(filterExactNodeId.value, hopsDown)
}
} else {
// Manual mode: include all matched nodes (no hops limit)
for (const id of matched) {
include.add(id)
}
}
// 4) filter layers/nodes
const filteredLayers: any[] = []
for (const l of raw.layers as any[]) {
let nodes = (l?.nodes ?? []).filter((n: any) => include.has(String(n?.id ?? '')))
const layerLevel = Number(l?.level ?? 0)
if (layerLevel === 1 && nodes.length > keywordLimit) {
nodes = nodes.sort((a: any, b: any) => (b.size || 0) - (a.size || 0)).slice(0, keywordLimit)
}
if (nodes.length) {
filteredLayers.push({ ...l, nodes })
}
}
// 4) filter links among included nodes
const filteredLinks = links.filter((lk) => include.has(String(lk?.source ?? '')) && include.has(String(lk?.target ?? '')))
displayGraphData.value = {
layers: filteredLayers,
links: filteredLinks,
stats: {
...(raw as any).stats,
total_links: filteredLinks.length
}
} as any
refreshChart()
}
const applyFilterManual = () => {
// switching to manual mode clears exact-anchor
filterExactNodeId.value = ''
applyFilterCore()
}
const applyFilterByNode = (nodeId: string, layerName: string, nodeName: string, nodeType: string) => {
// Keep UI in sync, but anchor by exact id to avoid matching multiple same-layer nodes
filterLayer.value = layerName || '__all__'
filterKeyword.value = nodeName || ''
filterExactNodeId.value = nodeId
// Calculate hops based on node type to show full hierarchy path
// cluster (0) -> needs hopsUp=3 to reach abstract-2 (3)
// tag (1) -> needs hopsUp=2 to reach abstract-2 (3), hopsDown=1 to reach clusters (0)
// abstract-1 (2) -> needs hopsUp=1 to reach abstract-2 (3), hopsDown=2 to reach clusters (0)
// abstract-2 (3) -> needs hopsDown=3 to reach clusters (0)
if (nodeType === 'cluster') {
_tempHopsUp.value = 3
_tempHopsDown.value = 0
} else if (nodeType === 'tag') {
_tempHopsUp.value = 2
_tempHopsDown.value = 1
} else if (nodeType === 'abstract') {
_tempHopsUp.value = 1
_tempHopsDown.value = 2
} else {
_tempHopsUp.value = 2
_tempHopsDown.value = 2
}
applyFilterCore()
}
const resetFilter = () => {
filterLayer.value = '__all__'
filterKeyword.value = ''
filterHops.value = 3
filterExactNodeId.value = ''
if (graphData.value) {
displayGraphData.value = {
...graphData.value,
layers: limitTagLayer(graphData.value.layers as any[], filterKeywordLimit.value || 200)
}
}
refreshChart()
}
const renderChart = () => {
if (!displayGraphData.value || !chartRef.value) {
return
}
// Dispose old instance
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(chartRef.value)
const layers = displayGraphData.value.layers
// Reverse to show abstract on left (Cluster layer on rightmost after reverse)
const reversedLayers = [...layers].reverse()
// Build nodes with improved layer-based layout
const nodes: any[] = []
// Canvas height for node placement
const graphHeight = 2400
// Minimum/maximum horizontal distance between nodes within the same layer
// Adjusted dynamically based on number of nodes to fit more in limited space
const minNodeSpacing = 120
const maxNodeSpacing = 180
_indexById = {}
// First pass: calculate layer widths for spacing
const layerWidths: number[] = []
for (const layer of reversedLayers) {
const numNodes = layer.nodes.length
if (numNodes === 0) {
layerWidths.push(0)
continue
}
const spacingFactor = Math.max(0.3, 1 - numNodes / 500)
const nodeSpacing = minNodeSpacing + (maxNodeSpacing - minNodeSpacing) * spacingFactor
const gridSize = Math.ceil(Math.sqrt(numNodes))
const cols = gridSize
layerWidths.push(cols * nodeSpacing)
}
// Second pass: calculate positions with uniform gap based on adjacent layers
const layerPositions: Array<{ startX: number; width: number }> = []
let currentX = 300
reversedLayers.forEach((_, idx) => {
const layerWidth = layerWidths[idx]
if (layerWidth === 0) return
// Uniform gap: average of current and next layer width, multiplied by spacing factor
// This ensures consistent visual gap regardless of layer size differences
const nextLayerWidth = layerWidths[idx + 1] ?? layerWidth
const gapFactor = 0.5
const actualLayerSpacing = (layerWidth + nextLayerWidth) * gapFactor
layerPositions.push({ startX: currentX, width: layerWidth })
currentX += layerWidth + actualLayerSpacing
})
// Second pass: place nodes
reversedLayers.forEach((layer, layerIdx) => {
const numNodes = layer.nodes.length
if (numNodes === 0) return
const layerInfo = layerPositions[layerIdx]
const startX = layerInfo.startX
const spacingFactor = Math.max(0.3, 1 - numNodes / 500) * (layer.name.toLowerCase().includes("abstract") ? 2 : 1)
const nodeSpacing = minNodeSpacing + (maxNodeSpacing - minNodeSpacing) * spacingFactor
const gridSize = Math.ceil(Math.sqrt(numNodes))
const cols = gridSize
const rows = Math.ceil(numNodes / cols)
const layerHeight = rows * nodeSpacing
// Add vertical randomness to avoid perfect centering
const layerSeed = layer.nodes.reduce((acc, n) => acc + n.id.charCodeAt(0), 0)
const verticalOffset = ((layerSeed * 7) % 500) - 250 // -250 to +250
const startY = (graphHeight - layerHeight) / 2 + verticalOffset
// Place nodes with natural randomness to avoid perfect grid appearance
layer.nodes.forEach((node, nodeIdx) => {
const row = Math.floor(nodeIdx / cols)
const col = nodeIdx % cols
// Base grid position
let gridX = (col - (cols - 1) / 2) * nodeSpacing
let gridY = row * nodeSpacing
// Deterministic random based on node ID
const seed = node.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
// Add larger random offset (up to 80% of node spacing)
const randomAngle = (seed * 37.5) % (Math.PI * 2)
const randomRadius = nodeSpacing * 0.8 * ((seed * 13) % 100) / 100
// Also perturb grid position directly for more irregular layout
const gridPerturbX = nodeSpacing * 0.4 * ((seed * 23) % 100) / 100 - nodeSpacing * 0.2
const gridPerturbY = nodeSpacing * 0.4 * ((seed * 41) % 100) / 100 - nodeSpacing * 0.2
gridX += gridPerturbX
gridY += gridPerturbY
const offsetX = gridX + Math.cos(randomAngle) * randomRadius
const offsetY = gridY + Math.sin(randomAngle) * randomRadius
const x = startX + nodeSpacing / 2 + offsetX
const y = startY + offsetY
// Node size based on frequency (size = sqrt(frequency) scaled)
const minSize = 20
const maxSize = 60
const normalizedSize = Math.sqrt(node.size) / 5
const clampedSize = Math.max(minSize, Math.min(maxSize, minSize + normalizedSize))
nodes.push({
id: node.id,
name: node.label,
x: x,
y: y,
fixed: true,
symbolSize: clampedSize,
value: node.size,
category: layer.level,
itemStyle: {
color: getLayerColor(layer.level)
},
label: {
show: true,
fontSize: 11,
color: '#fff',
formatter: (params: any) => {
const maxLen = 10
const name = params.name
return name.length > maxLen ? name.substring(0, maxLen) + '...' : name
}
},
metadata: node.metadata,
layerName: layer.name,
})
_indexById[node.id] = nodes.length - 1
})
})
// Build links
const links = displayGraphData.value.links.map(link => ({
source: link.source,
target: link.target,
value: link.weight,
lineStyle: {
width: Math.max(0.5, Math.min(2, link.weight / 100)),
opacity: 0.3,
curveness: 0.1
}
}))
// Build categories for legend
const categories = reversedLayers.map(layer => ({
name: layer.name
}))
const option: echarts.EChartsOption = {
tooltip: {
renderMode: 'html',
// In fullscreen mode, only the fullscreen element subtree is rendered.
// If tooltip is appended to document.body, it will not be visible.
appendToBody: false,
className: 'iib-tg-tooltip',
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderColor: 'rgba(255,255,255,0.14)',
borderWidth: 1,
padding: [10, 12],
textStyle: {
color: '#fff'
},
extraCssText: 'border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.35); z-index: 9999;',
triggerOn: 'none',
enterable: true,
formatter: (params: any) => {
if (params.dataType === 'node') {
const node = params.data
const nodeId = String(node.id || '')
const meta = node.metadata || {}
const imgCount = Number(meta.image_count ?? node.value ?? 0)
const clusterCount = meta.cluster_count != null ? Number(meta.cluster_count) : null
const level = meta.level != null ? Number(meta.level) : null
const type = String(meta.type || '')
const chips: string[] = []
if (!Number.isNaN(imgCount) && imgCount > 0) chips.push(`🖼️ ${imgCount}`)
if (clusterCount != null && !Number.isNaN(clusterCount)) chips.push(`🧩 ${clusterCount}`)
if (level != null && !Number.isNaN(level)) chips.push(`🏷️ L${level}`)
const canSearch = type === 'tag'
const canFilter = type === 'tag' || type === 'abstract' || type === 'cluster'
const canOpenCluster = type === 'cluster'
const actionBtns = [
canSearch
? `<button data-action="search" data-nodeid="${nodeId}" style="border:1px solid rgba(255,255,255,0.35); background: rgba(24,144,255,0.18); color:#fff; padding:4px 10px; border-radius:999px; cursor:pointer;">${t('search')}</button>`
: '',
canFilter
? `<button data-action="filter" data-nodeid="${nodeId}" style="border:1px solid rgba(255,255,255,0.30); background: rgba(255,255,255,0.10); color:#fff; padding:4px 10px; border-radius:999px; cursor:pointer;">${t('tagGraphTooltipFilter')}</button>`
: '',
canOpenCluster
? `<button data-action="openCluster" data-nodeid="${nodeId}" style="border:1px solid rgba(255,255,255,0.35); background: rgba(255,255,255,0.12); color:#fff; padding:4px 10px; border-radius:999px; cursor:pointer;">${t('tagGraphTooltipOpenCluster')}</button>`
: '',
`<button data-action="close" data-nodeid="${nodeId}" style="border:1px solid rgba(255,255,255,0.25); background: rgba(255,255,255,0.10); color:#fff; padding:4px 10px; border-radius:999px; cursor:pointer;">${t('close')}</button>`
].filter(Boolean).join('')
return `
<div class="iib-tg-tip" data-nodeid="${nodeId}" style="min-width: 180px;">
<div style="font-weight: 700; margin-bottom: 6px; color:#fff;">${params.name}</div>
<div style="display:flex; gap:10px; flex-wrap:wrap; font-size:12px; opacity:.9; color:#fff;">
${chips.map((c) => `<span>${c}</span>`).join('')}
</div>
<div style="margin-top: 10px; display:flex; gap:8px; flex-wrap:wrap;">
${actionBtns}
</div>
</div>
`
}
return ''
}
},
legend: [{
data: categories.map(c => c.name),
orient: 'vertical',
right: 10,
bottom: 10,
textStyle: {
color: '#fff'
}
}],
series: [{
type: 'graph',
layout: 'none',
data: nodes,
links: links,
categories: categories,
roam: true, // Enable zoom and pan
scaleLimit: {
min: 0.2,
max: 5
},
draggable: false,
blur: {
itemStyle: {
opacity: 0.15
},
lineStyle: {
opacity: 0.08
},
label: {
opacity: 0.25
}
},
label: {
show: true,
position: 'inside'
},
lineStyle: {
color: 'source',
curveness: 0.1
},
emphasis: {
focus: 'adjacency',
lineStyle: {
width: 3
}
},
zoom: 1 // Initial zoom level
}]
}
chartInstance.setOption(option)
const hideTipOnly = () => {
if (!chartInstance) return
chartInstance.dispatchAction({ type: 'hideTip' })
}
// Handle click events
chartInstance.on('click', (params: any) => {
if (params.dataType === 'node') {
const node = params.data
if (node.metadata?.type === 'tag') {
// Show tooltip; search is triggered from tooltip button
const idx = _indexById[String(node.id || '')]
if (idx != null) chartInstance?.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: idx })
} else if (node.metadata?.type === 'cluster') {
// Show tooltip; open is triggered from tooltip button
const idx = _indexById[String(node.id || '')]
if (idx != null) chartInstance?.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: idx })
} else if (node.metadata?.type === 'abstract') {
const idx = _indexById[String(node.id || '')]
if (idx != null) chartInstance?.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: idx })
} else {
message.info(`Abstract category: ${params.name}`)
}
}
})
// Click blank area: hide tooltip
chartInstance.getZr().on('click', (e: any) => {
if (!e?.target) {
hideTipOnly()
}
})
// Tooltip action buttons (search / close)
if (_docClickHandler) {
document.removeEventListener('click', _docClickHandler, true)
_docClickHandler = null
}
_docClickHandler = (ev: MouseEvent) => {
const target = ev.target as HTMLElement | null
if (!target) return
// Click on tooltip button
const btn = target.closest?.('button[data-action]') as HTMLElement | null
if (btn) {
const action = btn.getAttribute('data-action') || ''
const nodeId = btn.getAttribute('data-nodeid') || ''
const idx = _indexById[nodeId]
if (action === 'search' && idx != null) {
// Only tags have search button
const nodeName = (nodes[idx] && nodes[idx].name) || ''
if (nodeName) {
emit('searchTag', nodeName)
message.info(`${t('search')}: ${nodeName}`)
}
hideTipOnly()
} else if (action === 'filter' && idx != null) {
// Filter by this node (layer + keyword), keep 1-hop neighbors
const layerName = (nodes[idx] && nodes[idx].layerName) || '__all__'
const nodeName = (nodes[idx] && nodes[idx].name) || ''
const targetNodeId = String((nodes[idx] && nodes[idx].id) || nodeId)
const nodeType = String((nodes[idx] && nodes[idx].metadata && nodes[idx].metadata.type) || '')
filterLayer.value = layerName || '__all__'
filterKeyword.value = nodeName
applyFilterByNode(targetNodeId, String(layerName || '__all__'), String(nodeName || ''), nodeType)
// best-effort hide tip after applying
hideTipOnly()
} else if (action === 'openCluster' && idx != null) {
const meta = (nodes[idx] && nodes[idx].metadata) || {}
const title = (nodes[idx] && nodes[idx].name) || ''
const size = Number(meta.image_count || 0)
const topicClusterCacheKey = String(displayGraphData.value?.stats?.topic_cluster_cache_key || '')
const clusterId = String(meta.cluster_id || '')
if (!topicClusterCacheKey || !clusterId) {
message.warning('Cluster data is incomplete, please re-generate clustering result')
hideTipOnly()
return
}
void (async () => {
const hide = message.loading('Loading cluster images...', 0)
try {
const resp = await getClusterTagGraphClusterPaths({
topic_cluster_cache_key: topicClusterCacheKey,
cluster_id: clusterId
})
const paths: string[] = Array.isArray(resp?.paths) ? resp.paths : []
if (paths.length) {
emit('openCluster', { title, paths, size: size || paths.length })
} else {
message.warning('No images found in this cluster')
}
hideTipOnly()
} finally {
hide?.()
}
})()
} else if (action === 'close') {
hideTipOnly()
}
ev.preventDefault()
ev.stopPropagation()
return
}
// Click elsewhere (outside chart and tooltip): hide tooltip
const tip = target.closest?.('.iib-tg-tip')
if (tip) return
const inChart = chartRef.value?.contains(target) ?? false
if (!inChart) {
hideTipOnly()
}
}
document.addEventListener('click', _docClickHandler, true)
}
// Watch for folder changes
watch(
() => props.folders,
() => {
fetchGraphData()
},
{ immediate: true }
)
// Watch for graphData and chartRef to trigger rendering
watch(
[displayGraphData, chartRef],
() => {
if (displayGraphData.value && chartRef.value) {
nextTick(() => {
renderChart()
})
}
}
)
// Watch for keyword limit changes and apply filter immediately
watch(
filterKeywordLimit,
() => {
if (displayGraphData.value) {
applyFilterCore()
}
}
)
onMounted(() => {
// Rerender on window resize
_handleResize = () => {
if (chartInstance) {
chartInstance.resize()
}
}
window.addEventListener('resize', _handleResize)
_fsChangeHandler = () => {
isFullscreen.value = !!document.fullscreenElement
nextTick(() => {
chartInstance?.resize()
})
}
document.addEventListener('fullscreenchange', _fsChangeHandler)
})
onUnmounted(() => {
if (_handleResize) {
window.removeEventListener('resize', _handleResize)
_handleResize = null
}
if (_fsChangeHandler) {
document.removeEventListener('fullscreenchange', _fsChangeHandler)
_fsChangeHandler = null
}
if (_docClickHandler) {
document.removeEventListener('click', _docClickHandler, true)
_docClickHandler = null
}
if (chartInstance) {
chartInstance.dispose()
}
})
</script>
<style scoped lang="scss">
.tag-hierarchy-graph {
width: 100%;
height: calc(100vh - 200px);
min-height: 600px;
position: relative;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 8px;
overflow: hidden;
}
.loading-container,
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #fff;
}
.loading-text {
margin-top: 16px;
font-size: 14px;
color: #aaa;
}
.graph-container {
width: 100%;
height: 100%;
position: relative;
}
.control-panel {
position: absolute;
top: 16px;
left: 16px;
z-index: 10;
background: rgba(0, 0, 0, 0.7);
padding: 8px 12px;
border-radius: 6px;
backdrop-filter: blur(10px);
}
.chart-container {
width: 100%;
height: 100%;
}
</style>

View File

@ -18,6 +18,7 @@ import { uniqueId } from 'lodash-es'
import { message } from 'ant-design-vue'
import { isTauri } from '@/util/env'
import { useLocalStorage } from '@vueuse/core'
import TagRelationGraph from './TagRelationGraph.vue'
const props = defineProps<{ tabIdx: number; paneIdx: number }>()
const g = useGlobalStore()
@ -28,6 +29,7 @@ const threshold = ref(0.9)
const minClusterSize = ref(2)
const result = ref<ClusterIibOutputResp | null>(null)
const cacheInfo = ref<ClusterIibOutputCachedResp | null>(null)
const embeddingBuilt = ref(false) // Track if embeddings are already built
const _REQS_LS_KEY = 'iib_topic_search_hide_requirements_v1'
// true = show requirements; false = hidden
@ -36,6 +38,16 @@ const hideRequirements = () => {
showRequirements.value = false
}
// Cache result collapsed state
const _CACHE_COLLAPSED_KEY = 'iib_topic_search_cache_collapsed_v1'
const cacheResultCollapsed = useLocalStorage<boolean>(_CACHE_COLLAPSED_KEY, false)
const expandCacheResult = () => {
cacheResultCollapsed.value = false
}
const collapseCacheResult = () => {
cacheResultCollapsed.value = true
}
const job = ref<ClusterIibOutputJobStatusResp | null>(null)
const jobId = ref<string>('')
let _jobTimer: any = null
@ -90,6 +102,9 @@ const query = ref('')
const qLoading = ref(false)
const qResult = ref<PromptSearchResp | null>(null)
// Tab control
const activeTab = ref<'clusters' | 'graph'>('clusters')
const scopeOpen = ref(false)
const selectedFolders = ref<string[]>([])
const _scopeInitDone = ref(false)
@ -258,6 +273,7 @@ const refresh = async () => {
const runQuery = async () => {
const q = (query.value || '').trim()
if (!q) return
if (qLoading.value) return
if (!scopeCount.value) {
message.warning(t('topicSearchNeedScope'))
scopeOpen.value = true
@ -268,9 +284,13 @@ const runQuery = async () => {
qResult.value = await searchIibOutputByPrompt({
query: q,
top_k: 80,
ensure_embed: true,
ensure_embed: !embeddingBuilt.value, // Only build on first search
folder_paths: scopeFolders.value
})
// Mark embeddings as built after first successful search
if (!embeddingBuilt.value) {
embeddingBuilt.value = true
}
//
openQueryResult()
} finally {
@ -309,6 +329,26 @@ const openCluster = (item: ClusterIibOutputResp['clusters'][0]) => {
tab.key = (pane as any).key
}
const openClusterFromGraph = (cluster: { title: string; paths: string[]; size: number }) => {
const pane = {
type: 'topic-search-matched-image-grid' as const,
name: `${cluster.title}${cluster.size}`,
key: Date.now() + uniqueId(),
id: uniqueId(),
title: cluster.title,
paths: cluster.paths
}
const tab = g.tabList[props.tabIdx]
tab.panes.push(pane as any)
tab.key = (pane as any).key
}
const handleSearchTag = (tag: string) => {
// Search by tag name
query.value = tag
runQuery()
}
onMounted(() => {
//
void (async () => {
@ -323,6 +363,11 @@ onBeforeUnmount(() => {
stopJobPoll()
})
// Reset embedding built flag when scope changes
watch(scopeFolders, () => {
embeddingBuilt.value = false
}, { deep: true })
watch(
() => scopeFolders.value,
() => {
@ -411,47 +456,114 @@ watch(
</template>
</a-alert>
<a-alert
v-if="cacheInfo?.cache_hit && cacheInfo?.stale"
type="warning"
show-icon
style="margin: 10px 0 0 0;"
:message="$t('topicSearchCacheStale')"
<!-- Stale cache banner -->
<div
v-if="cacheInfo?.cache_hit && cacheInfo?.stale && !cacheResultCollapsed"
style="margin: 10px 0 0 0; position: relative;"
>
<template #description>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span style="opacity: 0.85;">{{ $t('topicSearchCacheStaleDesc') }}</span>
<a-alert
type="warning"
show-icon
:message="$t('topicSearchCacheStale')"
:description="$t('topicSearchCacheStaleDesc')"
>
<template #action>
<a-button size="small" :loading="loading || jobRunning" :disabled="g.conf?.is_readonly" @click="refresh">
{{ $t('topicSearchCacheUpdate') }}
</a-button>
</div>
</template>
</a-alert>
</template>
</a-alert>
<a-button
size="small"
style="position: absolute; top: 8px; right: 8px; z-index: 1;"
@click="collapseCacheResult"
>
^
</a-button>
</div>
<!-- Collapsed stale cache -->
<div
v-if="cacheInfo?.cache_hit && cacheInfo?.stale && cacheResultCollapsed"
style="margin: 10px 0 0 0; padding: 8px 12px; background: #fffbe6; border: 1px solid #ffe58f; border-radius: 8px; display: flex; align-items: center; gap: 10px;"
>
<span>💾</span>
<a-button size="small" :loading="loading || jobRunning" :disabled="g.conf?.is_readonly" @click="refresh">
{{ $t('topicSearchCacheUpdate') }}
</a-button>
<a-button size="small" @click="expandCacheResult">
v
</a-button>
</div>
<!-- Fresh cache banner -->
<div
v-if="cacheInfo?.cache_hit && !cacheInfo?.stale && !cacheResultCollapsed"
style="margin: 10px 0 0 0; position: relative;"
>
<a-alert
type="success"
show-icon
:message="$t('topicSearchCacheHit')"
/>
<a-button
size="small"
style="position: absolute; top: 8px; right: 8px; z-index: 1;"
@click="collapseCacheResult"
>
^
</a-button>
</div>
<!-- Collapsed fresh cache -->
<div
v-if="cacheInfo?.cache_hit && !cacheInfo?.stale && cacheResultCollapsed"
style="margin: 10px 0 0 0; padding: 8px 12px; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 8px; display: flex; align-items: center; gap: 10px;"
>
<span></span>
<a-button size="small" @click="expandCacheResult">
v
</a-button>
</div>
<div v-if="jobRunning" style="margin: 10px 0 0 0;">
<a-alert type="info" show-icon :message="jobStageText" :description="jobDesc" />
<a-progress :percent="jobPercent" size="small" style="margin-top: 8px;" />
</div>
<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>
<!-- View Switcher -->
<div style="margin : 10px 0; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 13px; color: #666;">View:</span>
<a-switch
v-model:checked="activeTab"
:checked-value="'graph'"
:un-checked-value="'clusters'"
checked-children="Tag Graph"
un-checked-children="Clusters"
/>
</div>
<div class="empty" v-else>
<!-- Cluster Cards View -->
<div v-if="activeTab === 'clusters'">
<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
@ -491,7 +603,18 @@ watch(
</div>
</div>
</div>
</a-spin>
</a-spin>
</div>
<!-- Tag Graph View -->
<div v-else-if="activeTab === 'graph'" style="height: calc(100vh - 300px); min-height: 600px;">
<TagRelationGraph
:folders="scopeFolders"
:lang="g.lang"
@search-tag="handleSearchTag"
@open-cluster="openClusterFromGraph"
/>
</div>
<a-modal
v-model:visible="scopeOpen"

View File

@ -1535,6 +1535,14 @@ dom-scroll-into-view@^2.0.0:
resolved "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz"
integrity sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==
echarts@^6.0.0:
version "6.0.0"
resolved "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz#2935aa7751c282d1abbbf7d719d397199a15b9e7"
integrity sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==
dependencies:
tslib "2.3.0"
zrender "6.0.0"
electron-to-chromium@^1.4.431:
version "1.4.451"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.451.tgz#12b63ee5c82cbbc7b4ddd91e90f5a0dfc10de26e"
@ -2640,6 +2648,11 @@ to-regex-range@^5.0.1:
dependencies:
is-number "^7.0.0"
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tslib@^1.8.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
@ -2924,3 +2937,10 @@ yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zrender@6.0.0:
version "6.0.0"
resolved "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz#947077bc69cdea744134984927f132f3727f8079"
integrity sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==
dependencies:
tslib "2.3.0"