From 74a1464a25cde088464b7391ac5178da772b0c39 Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Mon, 25 May 2026 11:32:30 +0800 Subject: [PATCH 01/16] Add local Docker setup for NAS photos --- .gitignore | 3 +- docker-compose.local.yml | 76 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 docker-compose.local.yml diff --git a/.gitignore b/.gitignore index b6b1caa8..bb58c89c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,6 @@ dist .trae *uploads* data/* +pg_data/ .env -.claude/ \ No newline at end of file +.claude/ diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..949dca33 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,76 @@ +services: + postgres: + image: pgvector/pgvector:pg18-trixie + container_name: trailsnap_postgres + restart: unless-stopped + environment: + TZ: Asia/Shanghai + POSTGRES_DB: trailsnap + POSTGRES_USER: trailsnap + POSTGRES_PASSWORD: trailsnap + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C" + PGDATA: /var/lib/postgresql/data/pgdata + networks: + - app-network + ports: + - "5532:5432" + volumes: + - ./pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U trailsnap -d trailsnap -p 5432"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + + server: + image: siyuan044/trailsnap-server:latest + restart: unless-stopped + expose: + - "8000" + ports: + - "8800:8000" + networks: + - app-network + volumes: + - ./data:/app/data + - /Volumes/photo:/app/Photos/:ro + environment: + - TZ=Asia/Shanghai + - DB_URL=postgresql://trailsnap:trailsnap@postgres:5432/trailsnap + - RAILWAY_DB_URL=postgresql://trailsnap:trailsnap@postgres:5432/railway + - AI_API_URL=http://ai:8001 + depends_on: + postgres: + condition: service_healthy + restart: true + + ai: + image: siyuan044/trailsnap-ai:latest + restart: unless-stopped + expose: + - "8001" + ports: + - "8801:8001" + networks: + - app-network + volumes: + - ./data:/app/data + environment: + - TZ=Asia/Shanghai + + frontend: + image: siyuan044/trailsnap-frontend:latest + restart: unless-stopped + ports: + - "8082:80" + depends_on: + - server + networks: + - app-network + environment: + - TZ=Asia/Shanghai + +networks: + app-network: + driver: bridge From cbed31c0f18aa8ef90570f2416f1acceede35ee8 Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Mon, 25 May 2026 20:58:22 +0800 Subject: [PATCH 02/16] Stabilize local NAS task processing --- .gitignore | 1 + docker-compose.local.yml | 7 ++++++- package/server/app/service/task_worker.py | 21 +++++++++++++-------- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index bb58c89c..22e5878c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ dist *uploads* data/* pg_data/ +backups/ .env .claude/ diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 949dca33..1c9262af 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -15,7 +15,7 @@ services: ports: - "5532:5432" volumes: - - ./pg_data:/var/lib/postgresql/data + - trailsnap_pg_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U trailsnap -d trailsnap -p 5432"] interval: 5s @@ -34,6 +34,7 @@ services: - app-network volumes: - ./data:/app/data + - ./package/server/app:/app/app:ro - /Volumes/photo:/app/Photos/:ro environment: - TZ=Asia/Shanghai @@ -74,3 +75,7 @@ services: networks: app-network: driver: bridge + +volumes: + trailsnap_pg_data: + external: true diff --git a/package/server/app/service/task_worker.py b/package/server/app/service/task_worker.py index 0eb352e2..b48dd6b8 100644 --- a/package/server/app/service/task_worker.py +++ b/package/server/app/service/task_worker.py @@ -60,6 +60,8 @@ def task_done(self, category: str): def get_chunk_size(task_type): chunk_size = 8 + if task_type == TaskType.PROCESS_BASIC: + chunk_size = 1 if task_type == TaskType.VISUAL_DESCRIPTION: chunk_size = 2 elif task_type == TaskType.OCR: @@ -289,10 +291,12 @@ def _fetch_tasks_to_queues_sync(self, allowed_types: List[str], current_qsizes: tasks_by_type = {} try: # We will fetch up to max_batch_size per category if its queue is below threshold - # Max items in queue per category - QUEUE_THRESHOLD = 50 - # How many items to fetch in one DB query per category - FETCH_BATCH_SIZE = 48 + # Keep the local Docker setup gentle. The original queue sizes can + # mark hundreds of tasks as PROCESSING before they actually run, + # which is painful when NAS IO or Docker Desktop storage stalls. + max_tasks = max(1, system_config.config.task.max_concurrent_tasks) + QUEUE_THRESHOLD = max_tasks + FETCH_BATCH_SIZE = max_tasks * 8 # Filter allowed types based on current queue size types_to_fetch = [] @@ -310,7 +314,7 @@ def _fetch_tasks_to_queues_sync(self, allowed_types: List[str], current_qsizes: query = query.filter(Task.type.in_(types_to_fetch)) # Fetch tasks. We fetch a bit more to fill the queues up - tasks = query.order_by(Task.priority.desc(), Task.created_at.asc()).limit(FETCH_BATCH_SIZE * 3).all() + tasks = query.order_by(Task.priority.desc(), Task.created_at.asc()).limit(FETCH_BATCH_SIZE).all() if tasks: for task in tasks: @@ -347,10 +351,11 @@ async def consumer_loop(self, category: str): # Configure max concurrency per consumer category based on system settings # or Fast Mode. Using Semaphores to allow multiple batches to run concurrently. max_concurrency = 1 + configured_workers = max(1, system_config.config.task.max_concurrent_tasks) if category == 'CPU': - max_concurrency = os.cpu_count() or 4 + max_concurrency = configured_workers elif category == 'IO': - max_concurrency = 10 + max_concurrency = configured_workers elif category == 'AI': max_concurrency = 1 @@ -650,4 +655,4 @@ def add_task(self, db: Session, type: str, payload: dict, priority: int = 0, own def add_tasks(self, db: Session, tasks_data: List[Dict], owner_id: UUID = None): """Batch add tasks""" - crud_task.add_tasks(db, tasks_data, owner_id) \ No newline at end of file + crud_task.add_tasks(db, tasks_data, owner_id) From ee0ef18cbfd055b613ab66e7e198163f5f307b10 Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Mon, 25 May 2026 21:27:29 +0800 Subject: [PATCH 03/16] Enable Mimo visual description processing --- package/server/app/service/task_worker.py | 33 ++++++++++--------- .../app/service/tasks/visual_description.py | 23 ++++++++----- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/package/server/app/service/task_worker.py b/package/server/app/service/task_worker.py index b48dd6b8..1a636c17 100644 --- a/package/server/app/service/task_worker.py +++ b/package/server/app/service/task_worker.py @@ -296,37 +296,40 @@ def _fetch_tasks_to_queues_sync(self, allowed_types: List[str], current_qsizes: # which is painful when NAS IO or Docker Desktop storage stalls. max_tasks = max(1, system_config.config.task.max_concurrent_tasks) QUEUE_THRESHOLD = max_tasks - FETCH_BATCH_SIZE = max_tasks * 8 + FETCH_BATCH_SIZE = max_tasks - # Filter allowed types based on current queue size - types_to_fetch = [] + # Filter allowed types based on current queue size, then fetch per + # category. A single global priority query lets PROCESS_BASIC fill + # every fetch window and starve AI work such as VISUAL_DESCRIPTION. + types_by_category = {} for t in allowed_types: task_Factory = TaskStrategyFactory.get_strategy(t) if not task_Factory: continue cat = task_Factory.task_category if cat and current_qsizes.get(cat, 0) < QUEUE_THRESHOLD: - types_to_fetch.append(t) + types_by_category.setdefault(cat, []).append(t) - if not types_to_fetch: + if not types_by_category: return {} - query = db.query(Task).filter(Task.status == TaskStatus.PENDING) - query = query.filter(Task.type.in_(types_to_fetch)) - - # Fetch tasks. We fetch a bit more to fill the queues up - tasks = query.order_by(Task.priority.desc(), Task.created_at.asc()).limit(FETCH_BATCH_SIZE).all() - - if tasks: + for category, types_to_fetch in types_by_category.items(): + tasks = ( + db.query(Task) + .filter(Task.status == TaskStatus.PENDING) + .filter(Task.type.in_(types_to_fetch)) + .order_by(Task.priority.desc(), Task.created_at.asc()) + .limit(FETCH_BATCH_SIZE) + .all() + ) for task in tasks: - cat = TaskStrategyFactory.get_strategy(task.type).task_category - if not cat: continue - if task.type not in tasks_by_type: tasks_by_type[task.type] = [] task.status = TaskStatus.PROCESSING self.last_active_time[task.type] = datetime.now() tasks_by_type[task.type].append({'id': task.id, 'type': task.type, 'priority': task.priority}) + + if tasks_by_type: db.commit() # Split tasks into smaller batches of max 8 items diff --git a/package/server/app/service/tasks/visual_description.py b/package/server/app/service/tasks/visual_description.py index 08d2c878..40ea0e22 100644 --- a/package/server/app/service/tasks/visual_description.py +++ b/package/server/app/service/tasks/visual_description.py @@ -51,17 +51,22 @@ def create_client(self, settings): logger.error("Visual Model connection has no api_key: %s", settings.analysis_connection_id) raise ValueError(f"Visual Model connection has no api_key: {settings.analysis_connection_id}") - # 2. Call OpenAI API - client = ChatOpenAI( - api_key=connection.api_key, - model= settings.analysis_model_name, - base_url=connection.api_base if connection.api_base else None, - timeout=60, - extra_body={ + # 2. Call OpenAI-compatible API. Some providers reject + # reasoning_effort="none"; disabling thinking through extra_body is + # enough for Xiaomi Mimo while still working with LM Studio Qwen. + client_kwargs = { + "api_key": connection.api_key, + "model": settings.analysis_model_name, + "base_url": connection.api_base if connection.api_base else None, + "timeout": 60, + "extra_body": { "chat_template_kwargs": {"enable_thinking": False}, }, - reasoning_effort="none", - ) + } + if "xiaomimimo.com" not in (connection.api_base or ""): + client_kwargs["reasoning_effort"] = "none" + + client = ChatOpenAI(**client_kwargs) return client async def process(self, worker, task: Task, db: Session) -> Dict[str, Any]: From 42c5fe8e2467e9d65d61bc9487fbf001fdf2476b Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Mon, 25 May 2026 21:31:54 +0800 Subject: [PATCH 04/16] Increase local task concurrency cautiously --- package/server/app/service/task_worker.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package/server/app/service/task_worker.py b/package/server/app/service/task_worker.py index 1a636c17..77f42860 100644 --- a/package/server/app/service/task_worker.py +++ b/package/server/app/service/task_worker.py @@ -263,7 +263,8 @@ def _manage_pool_lifecycle(self): if active_count > 0: if active_cpu_count > 0 and self.process_pool is None: logging.info(f"Restarting process pool") - self.process_pool = concurrent.futures.ProcessPoolExecutor(max_workers=os.cpu_count()) + max_workers = max(1, system_config.config.task.max_concurrent_tasks) + self.process_pool = concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) if self.thread_pool is None and active_io_count > 0: max_workers = system_config.config.task.max_concurrent_tasks logging.info(f"Restarting thread pool") @@ -360,7 +361,7 @@ async def consumer_loop(self, category: str): elif category == 'IO': max_concurrency = configured_workers elif category == 'AI': - max_concurrency = 1 + max_concurrency = configured_workers semaphore = asyncio.Semaphore(max_concurrency) From 5bea199729265644c962fe7994bee3eb2c013628 Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Tue, 26 May 2026 02:34:32 +0800 Subject: [PATCH 05/16] Harden visual description JSON parsing --- .../app/service/tasks/visual_description.py | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/package/server/app/service/tasks/visual_description.py b/package/server/app/service/tasks/visual_description.py index 40ea0e22..21fe10fd 100644 --- a/package/server/app/service/tasks/visual_description.py +++ b/package/server/app/service/tasks/visual_description.py @@ -5,6 +5,7 @@ import os import json import base64 +import re from typing import Dict, Any, List from sqlalchemy.orm import Session from langchain_openai import ChatOpenAI @@ -19,6 +20,34 @@ logger = logging.getLogger(__name__) +def parse_json_response(content: str) -> Dict[str, Any]: + text = (content or "").strip() + if text.startswith("```"): + match = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL | re.IGNORECASE) + if match: + text = match.group(1).strip() + + candidates = [text] + brace_index = text.find("{") + if brace_index > 0: + candidates.append(text[brace_index:]) + + last_error = None + decoder = json.JSONDecoder(strict=False) + for candidate in candidates: + if not candidate: + continue + try: + result, _ = decoder.raw_decode(candidate.strip()) + if isinstance(result, dict): + return result + except json.JSONDecodeError as e: + last_error = e + + preview = text[:300].replace("\n", "\\n") + raise ValueError(f"Failed to parse model JSON response: {preview}") from last_error + + def encode_image(image_path): with open(image_path, "rb") as image_file: return base64.b64encode(image_file.read()).decode('utf-8') @@ -201,16 +230,10 @@ async def process_single_photo(self, worker, photo: Photo, db: Session, settings ] ) - eval_content = eval_response.content.strip().strip('`').strip().strip('json') - print(eval_content) - # Clean up code blocks if present - if eval_content.startswith("```"): - eval_content = eval_content.strip("`") - if eval_content.startswith("json"): - eval_content = eval_content[4:] + eval_content = eval_response.content try: - result_json = json.loads(eval_content.strip()) - except json.JSONDecodeError as e: + result_json = parse_json_response(eval_content) + except (json.JSONDecodeError, ValueError) as e: logger.error(f"Failed to parse evaluation JSON for photo {photo.id}: {eval_content}") raise e From c23788ac34f55d0c3b806322c30076015edd7af7 Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Tue, 26 May 2026 03:03:38 +0800 Subject: [PATCH 06/16] Skip high-risk visual description rejects --- .../app/service/tasks/visual_description.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/package/server/app/service/tasks/visual_description.py b/package/server/app/service/tasks/visual_description.py index 21fe10fd..d58243d6 100644 --- a/package/server/app/service/tasks/visual_description.py +++ b/package/server/app/service/tasks/visual_description.py @@ -53,6 +53,13 @@ def encode_image(image_path): return base64.b64encode(image_file.read()).decode('utf-8') +def mark_visual_description_processed(photo: Photo, db: Session) -> None: + tasks_status = dict(photo.processed_tasks or {}) + tasks_status['visual_description'] = True + photo.processed_tasks = tasks_status + db.commit() + + @TaskStrategyFactory.register(TaskType.VISUAL_DESCRIPTION) class VisualDescriptionStrategy(BaseTaskStrategy): @property @@ -231,6 +238,10 @@ async def process_single_photo(self, worker, photo: Photo, db: Session, settings ) eval_content = eval_response.content + if "considered high risk" in (eval_content or "").lower(): + mark_visual_description_processed(photo, db) + return {'status': 'skipped', 'reason': 'model rejected high risk image'} + try: result_json = parse_json_response(eval_content) except (json.JSONDecodeError, ValueError) as e: @@ -256,10 +267,7 @@ async def process_single_photo(self, worker, photo: Photo, db: Session, settings ) db.add(desc) # Update photo processed status - tasks_status = dict(photo.processed_tasks or {}) - tasks_status['visual_description'] = True - photo.processed_tasks = tasks_status - db.commit() + mark_visual_description_processed(photo, db) return { 'status': 'completed', 'description': desc.description, From 2962a98892594b14c31f7233edbfeeacf712e60c Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Tue, 26 May 2026 04:03:54 +0800 Subject: [PATCH 07/16] Recover partial visual description JSON --- .../app/service/tasks/visual_description.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/package/server/app/service/tasks/visual_description.py b/package/server/app/service/tasks/visual_description.py index d58243d6..17db6c98 100644 --- a/package/server/app/service/tasks/visual_description.py +++ b/package/server/app/service/tasks/visual_description.py @@ -20,6 +20,59 @@ logger = logging.getLogger(__name__) +def _extract_json_string_field(text: str, key: str) -> str | None: + match = re.search(rf'"{key}"\s*:\s*"', text) + if not match: + return None + + start = match.end() + next_key = re.search( + r',?\s*\n?\s*"(description|tags|memory_score|beauty_score|quality_score|reason|narrative)"\s*:', + text[start:], + ) + end = start + next_key.start() if next_key else len(text) + value = text[start:end].strip() + value = re.sub(r'"\s*,?\s*\}?\s*$', '', value, flags=re.DOTALL).strip() + return value.replace('\\"', '"') + + +def _extract_json_array_field(text: str, key: str) -> List[str] | None: + match = re.search(rf'"{key}"\s*:\s*(\[[^\]]*\])', text, re.DOTALL) + if not match: + return None + raw_value = match.group(1) + try: + value = json.loads(raw_value, strict=False) + if isinstance(value, list): + return value + except json.JSONDecodeError: + pass + return re.findall(r'"([^"]+)"', raw_value) + + +def _extract_json_number_field(text: str, key: str) -> float | None: + match = re.search(rf'"{key}"\s*:\s*(-?\d+(?:\.\d+)?)', text) + if not match: + return None + return float(match.group(1)) + + +def _parse_partial_json_response(text: str) -> Dict[str, Any]: + result = { + "description": _extract_json_string_field(text, "description"), + "tags": _extract_json_array_field(text, "tags") or [], + "memory_score": _extract_json_number_field(text, "memory_score"), + "beauty_score": _extract_json_number_field(text, "beauty_score"), + "quality_score": _extract_json_number_field(text, "quality_score"), + "reason": _extract_json_string_field(text, "reason"), + "narrative": _extract_json_string_field(text, "narrative"), + } + result = {key: value for key, value in result.items() if value not in (None, "")} + if "description" in result or "narrative" in result: + return result + raise ValueError("No usable fields found in partial JSON response") + + def parse_json_response(content: str) -> Dict[str, Any]: text = (content or "").strip() if text.startswith("```"): @@ -44,6 +97,14 @@ def parse_json_response(content: str) -> Dict[str, Any]: except json.JSONDecodeError as e: last_error = e + for candidate in candidates: + if not candidate: + continue + try: + return _parse_partial_json_response(candidate.strip()) + except ValueError: + pass + preview = text[:300].replace("\n", "\\n") raise ValueError(f"Failed to parse model JSON response: {preview}") from last_error From 521c6810f91d1508e9f2ea955fc9de0157ce5550 Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Tue, 26 May 2026 12:22:35 +0800 Subject: [PATCH 08/16] Add NAS Docker Compose setup --- docker-compose.nas.yml | 80 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docker-compose.nas.yml diff --git a/docker-compose.nas.yml b/docker-compose.nas.yml new file mode 100644 index 00000000..dda95237 --- /dev/null +++ b/docker-compose.nas.yml @@ -0,0 +1,80 @@ +services: + postgres: + image: pgvector/pgvector:pg18-trixie + container_name: trailsnap_postgres + restart: unless-stopped + environment: + TZ: Asia/Shanghai + POSTGRES_DB: trailsnap + POSTGRES_USER: trailsnap + POSTGRES_PASSWORD: trailsnap + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C" + PGDATA: /var/lib/postgresql/data/pgdata + networks: + - app-network + ports: + - "5532:5432" + volumes: + - trailsnap_pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U trailsnap -d trailsnap -p 5432"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + + server: + image: siyuan044/trailsnap-server:latest + restart: unless-stopped + expose: + - "8000" + ports: + - "8800:8000" + networks: + - app-network + volumes: + - ./data:/app/data + - ./package/server/app:/app/app:ro + - /volume1/photo:/app/Photos/:ro + environment: + - TZ=Asia/Shanghai + - DB_URL=postgresql://trailsnap:trailsnap@postgres:5432/trailsnap + - RAILWAY_DB_URL=postgresql://trailsnap:trailsnap@postgres:5432/railway + - AI_API_URL=http://ai:8001 + depends_on: + postgres: + condition: service_healthy + restart: true + + ai: + image: siyuan044/trailsnap-ai:latest + restart: unless-stopped + expose: + - "8001" + ports: + - "8801:8001" + networks: + - app-network + volumes: + - ./data:/app/data + environment: + - TZ=Asia/Shanghai + + frontend: + image: siyuan044/trailsnap-frontend:latest + restart: unless-stopped + ports: + - "8082:80" + depends_on: + - server + networks: + - app-network + environment: + - TZ=Asia/Shanghai + +networks: + app-network: + driver: bridge + +volumes: + trailsnap_pg_data: From 7aa755cb62790bcab5cecacb06a6f44af6ec0ade Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Tue, 26 May 2026 14:13:06 +0800 Subject: [PATCH 09/16] Point NAS server to Mac AI service --- docker-compose.nas.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.nas.yml b/docker-compose.nas.yml index dda95237..1ff903f9 100644 --- a/docker-compose.nas.yml +++ b/docker-compose.nas.yml @@ -40,7 +40,7 @@ services: - TZ=Asia/Shanghai - DB_URL=postgresql://trailsnap:trailsnap@postgres:5432/trailsnap - RAILWAY_DB_URL=postgresql://trailsnap:trailsnap@postgres:5432/railway - - AI_API_URL=http://ai:8001 + - AI_API_URL=http://192.168.10.12:8801 depends_on: postgres: condition: service_healthy From 06df1f7b928a9c6111b9b6b4befb25b6cd1f7254 Mon Sep 17 00:00:00 2001 From: rowankid <495872803@qq.com> Date: Wed, 27 May 2026 20:59:01 +0800 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20TrailSnap=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=8E=A5=E5=8F=A3=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.nas.yml | 3 + package/server/app/api/classification.py | 54 +++++++++++++ package/server/app/crud/tag.py | 75 ++++++++++++++++++- .../src/components/home/FaceSection.vue | 2 +- package/website/src/router/index.ts | 3 + 5 files changed, 135 insertions(+), 2 deletions(-) diff --git a/docker-compose.nas.yml b/docker-compose.nas.yml index 1ff903f9..6d98c93d 100644 --- a/docker-compose.nas.yml +++ b/docker-compose.nas.yml @@ -34,6 +34,7 @@ services: - app-network volumes: - ./data:/app/data + - ./package/server/main.py:/app/main.py:ro - ./package/server/app:/app/app:ro - /volume1/photo:/app/Photos/:ro environment: @@ -65,6 +66,8 @@ services: restart: unless-stopped ports: - "8082:80" + volumes: + - ./package/website/dist:/usr/share/nginx/html:ro depends_on: - server networks: diff --git a/package/server/app/api/classification.py b/package/server/app/api/classification.py index fc807867..d30f4f78 100644 --- a/package/server/app/api/classification.py +++ b/package/server/app/api/classification.py @@ -16,6 +16,13 @@ class RemovePhotosRequest(BaseModel): photo_ids: List[UUID] +class RenameTagRequest(BaseModel): + new_name: str + +class MergeTagsRequest(BaseModel): + target_name: str + source_names: List[str] + @router.get("", response_model=BaseResponse[List[schemas.TagStats]], summary="获取智能分类标签列表") def get_tags( skip: int = 0, @@ -29,6 +36,53 @@ def get_tags( data = crud.get_tags_with_stats(db, current_user.id, skip, limit) return BaseResponse(data=data) +@router.post("/merge", summary="合并分类标签") +def merge_tags( + payload: MergeTagsRequest = Body(..., description="合并标签请求"), + db: Session = Depends(get_db), + current_user: User = Depends(deps.get_current_user), +): + success, count, message = crud.merge_tags( + db, + current_user.id, + payload.target_name, + payload.source_names, + ) + if not success: + raise HTTPException(status_code=400, detail=message) + + from app.crud.album import trigger_conditional_albums_update + trigger_conditional_albums_update(db, current_user.id, None) + return BaseResponse(data={"status": "success", "count": count}) + +@router.put("/{path:path}", summary="重命名分类标签") +def rename_tag( + payload: RenameTagRequest = Body(..., description="标签重命名请求"), + path: str = Path(..., description="标签名称(支持多级/包含/)", path=True), + db: Session = Depends(get_db), + current_user: User = Depends(deps.get_current_user), +): + success, message = crud.rename_tag(db, current_user.id, path, payload.new_name) + if not success: + raise HTTPException(status_code=400, detail=message) + + from app.crud.album import trigger_conditional_albums_update + trigger_conditional_albums_update(db, current_user.id, None) + return BaseResponse(data={"status": "success"}) + +@router.delete("/{path:path}", summary="删除分类标签") +def delete_tag( + path: str = Path(..., description="标签名称(支持多级/包含/)", path=True), + db: Session = Depends(get_db), + current_user: User = Depends(deps.get_current_user), +): + if not crud.delete_tag(db, current_user.id, path): + raise HTTPException(status_code=404, detail="Tag not found") + + from app.crud.album import trigger_conditional_albums_update + trigger_conditional_albums_update(db, current_user.id, None) + return BaseResponse(data={"status": "success"}) + @router.get("/{path:path}/photos", response_model=BaseResponse[List[photo_schemas.Photo]], summary="获取分类照片列表") def get_tag_photos( # path=True 声明:匹配剩余的全部路径(支持包含/) diff --git a/package/server/app/crud/tag.py b/package/server/app/crud/tag.py index 7abfd59c..7882645f 100644 --- a/package/server/app/crud/tag.py +++ b/package/server/app/crud/tag.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List, Optional, Tuple from uuid import UUID from sqlalchemy.orm import Session from app.db.models.tag import PhotoTag, PhotoTagRelation @@ -184,3 +184,76 @@ def set_tag_cover(db: Session, owner_id: UUID, tag_name: str, photo_id: UUID) -> tag.cover_id = photo_id db.commit() return True + +def delete_tag(db: Session, owner_id: UUID, tag_name: str) -> bool: + tag = get_tag_by_name(db, tag_name, owner_id) + if not tag: + return False + + db.query(PhotoTagRelation).filter(PhotoTagRelation.tag_id == tag.id).delete(synchronize_session=False) + db.delete(tag) + db.commit() + return True + +def rename_tag(db: Session, owner_id: UUID, old_name: str, new_name: str) -> Tuple[bool, str]: + new_name = new_name.strip() + if not new_name: + return False, "New tag name is empty" + + tag = get_tag_by_name(db, old_name, owner_id) + if not tag: + return False, "Tag not found" + + existing = get_tag_by_name(db, new_name, owner_id) + if existing and existing.id != tag.id: + return False, "Target tag already exists" + + tag.tag_name = new_name + db.commit() + return True, "success" + +def merge_tags(db: Session, owner_id: UUID, target_name: str, source_names: List[str]) -> Tuple[bool, int, str]: + target_name = target_name.strip() + source_names = [name.strip() for name in source_names if name and name.strip()] + if not target_name: + return False, 0, "Target tag name is empty" + if not source_names: + return False, 0, "Source tags are empty" + + target = get_tag_by_name(db, target_name, owner_id) + if not target: + target = create_tag(db, target_name, "classification", owner_id) + + merged_count = 0 + for source_name in source_names: + if source_name == target_name: + continue + + source = get_tag_by_name(db, source_name, owner_id) + if not source: + continue + + if not target.cover_id and source.cover_id: + target.cover_id = source.cover_id + + source_relations = db.query(PhotoTagRelation).filter( + PhotoTagRelation.tag_id == source.id + ).all() + + for relation in source_relations: + target_relation = db.query(PhotoTagRelation).filter( + PhotoTagRelation.photo_id == relation.photo_id, + PhotoTagRelation.tag_id == target.id + ).first() + + if target_relation: + target_relation.confidence = max(target_relation.confidence or 0, relation.confidence or 0) + db.delete(relation) + else: + relation.tag_id = target.id + merged_count += 1 + + db.delete(source) + + db.commit() + return True, merged_count, "success" diff --git a/package/website/src/components/home/FaceSection.vue b/package/website/src/components/home/FaceSection.vue index 9ffa2807..30ffdbcc 100644 --- a/package/website/src/components/home/FaceSection.vue +++ b/package/website/src/components/home/FaceSection.vue @@ -5,7 +5,7 @@