Merge pull request #890 from zanllp/feature/tag-relationship-graph
Add tag relationship graph visualization for topic clusterspull/894/head
commit
1a5ceea746
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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
|
|
@ -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};
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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 _};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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 _};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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
File diff suppressed because one or more lines are too long
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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
|
|
@ -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};
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '输入图像信息或者文件名的一部分来进行搜索',
|
||||
|
|
|
|||
|
|
@ -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': '輸入圖片信息或者文件名的一部分來進行搜尋',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue