diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..d89a616 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "fam-ui-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "fam-ui", "run", "dev", "--", "--host"], + "port": 5173 + } + ] +} diff --git a/fam-core/src/fam_core/app.py b/fam-core/src/fam_core/app.py index b5df50f..5b310e4 100644 --- a/fam-core/src/fam_core/app.py +++ b/fam-core/src/fam_core/app.py @@ -20,15 +20,20 @@ from .oracle_sync import get_sync from .chat_handler.chat_handler import chat_bp from .member_manager.member_manager import member_bp from .img_proxy import img_bp +from .ui_api import ui_bp +from .static_app import static_bp logger = setup_logger('fam-core.app') app = Flask(__name__) -# 注册蓝图 +# 注册蓝图:/api/* 系列必须先于 static_bp 注册——static_bp 是通配兜底路由 +# (Vue Router history 模式回退 index.html),排在前面会吞掉 API 请求。 app.register_blueprint(chat_bp) app.register_blueprint(member_bp) app.register_blueprint(img_bp) +app.register_blueprint(ui_bp) +app.register_blueprint(static_bp) # 健康检查 @app.route('/health', methods=['GET']) diff --git a/fam-core/src/fam_core/chat_handler/chat_handler.py b/fam-core/src/fam_core/chat_handler/chat_handler.py index 19cba30..c9b8a51 100644 --- a/fam-core/src/fam_core/chat_handler/chat_handler.py +++ b/fam-core/src/fam_core/chat_handler/chat_handler.py @@ -121,8 +121,9 @@ def chat_history(): date = request.args.get('date') person = request.args.get('person') limit = int(request.args.get('limit', 20)) + offset = int(request.args.get('offset', 0)) - history = db_layer.get_chat_history(limit=limit, date_filter=date, person_filter=person) + history = db_layer.get_chat_history(limit=limit, offset=offset, date_filter=date, person_filter=person) for h in history: for k, v in h.items(): if hasattr(v, 'isoformat'): diff --git a/fam-core/src/fam_core/db_layer.py b/fam-core/src/fam_core/db_layer.py index 7c9d7c6..2c301e2 100644 --- a/fam-core/src/fam_core/db_layer.py +++ b/fam-core/src/fam_core/db_layer.py @@ -417,6 +417,23 @@ def set_sync_cursor(value: str): # 统计 # ============================================================ +def get_attention_events(limit: int = 200) -> List[Dict]: + """需关注事件列表(供统计图表页展示日期 + 涉及人物),按录制日期倒序。""" + conn = get_conn() + try: + cur = conn.cursor(pymysql.cursors.DictCursor) + cur.execute( + """SELECT COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date, + e.person_list_json + FROM sync_events e JOIN sync_videos v ON e.video_id=v.id + WHERE e.is_attention_event = 1 + ORDER BY ev_date DESC LIMIT %s""", + (limit,)) + return cur.fetchall() + finally: + conn.close() + + def get_sync_stats(date_str: str = None) -> Dict: """概览统计:视频数 / 事件数 / 关注事件数 / 出现人物数(按 date 可选过滤)。 @@ -488,8 +505,8 @@ def insert_chat_history(user_question: str, ai_answer: str, conn.close() -def get_chat_history(limit=20, date_filter=None, person_filter=None) -> List[Dict]: - """获取对话历史""" +def get_chat_history(limit=20, offset=0, date_filter=None, person_filter=None) -> List[Dict]: + """获取对话历史(分页)""" conn = get_conn() try: cur = conn.cursor(pymysql.cursors.DictCursor) @@ -502,9 +519,9 @@ def get_chat_history(limit=20, date_filter=None, person_filter=None) -> List[Dic conditions.append("queried_person = %s") params.append(person_filter) where = f"WHERE {' AND '.join(conditions)}" if conditions else "" - params.append(limit) + params.extend([limit, offset]) cur.execute( - f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT %s", + f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT %s OFFSET %s", params ) return cur.fetchall() diff --git a/fam-core/src/fam_core/static_app.py b/fam-core/src/fam_core/static_app.py new file mode 100644 index 0000000..5f1b276 --- /dev/null +++ b/fam-core/src/fam_core/static_app.py @@ -0,0 +1,32 @@ +""" +Static-App - 提供 Vue 前端构建产物(新架构 v3:fam-ui 不再单独起 Streamlit 进程) + +fam-ui/dist/ 是本地 `npm run build` 出的静态文件,部署时整个目录拷到 NAS。 +本模块只做两件事: 命中真实静态文件(如 /assets/xxx.js)直接下发;其余任何路径 +(Vue Router history 模式的前端路由)一律回退到 index.html,由浏览器端路由接管。 + +必须最后注册(app.py 里排在 chat_bp/member_bp/img_bp/ui_bp 之后),否则这里的 +通配路由会先于 /api/* 匹配,把 API 请求也吞成 index.html。 +""" +import os + +from flask import Blueprint, send_from_directory, abort + +DIST_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + '..', 'fam-ui', 'dist' +) +DIST_DIR = os.path.normpath(DIST_DIR) + +static_bp = Blueprint('static_app', __name__) + + +@static_bp.route('/', defaults={'path': ''}) +@static_bp.route('/') +def spa(path): + if not os.path.isdir(DIST_DIR): + abort(404, "前端构建产物不存在,请先 npm run build 并部署 fam-ui/dist") + full = os.path.join(DIST_DIR, path) + if path and os.path.isfile(full): + return send_from_directory(DIST_DIR, path) + return send_from_directory(DIST_DIR, 'index.html') diff --git a/fam-core/src/fam_core/ui_api.py b/fam-core/src/fam_core/ui_api.py new file mode 100644 index 0000000..3a70b65 --- /dev/null +++ b/fam-core/src/fam_core/ui_api.py @@ -0,0 +1,167 @@ +""" +UI-API - Vue 前端只读数据接口(新架构 v3:Vue SPA 取代 Streamlit) + +全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑。人物按 canonical_name +聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管渲染,不重复业务规则)。 + +/api/ui/service-status 需要代理 Oracle 的 /api/oracle/activity(浏览器不直连 +Oracle,避免 token 暴露),写法照抄 img_proxy.py 的模式:复用 oracle_sync.get_sync() +已解析好的 base_url/token,不再单独存一份配置。 +""" +import re +from datetime import datetime +from decimal import Decimal + +from flask import Blueprint, request, jsonify + +from .logger import setup_logger +from . import db_layer +from .oracle_sync import get_sync + +logger = setup_logger('fam-core.ui_api') + +ui_bp = Blueprint('ui_api', __name__) + +_BRACKET_RE = re.compile(r'[((][^()()]*[))]') + + +def _ser(v): + """递归序列化供 jsonify 用: datetime -> isoformat;Decimal -> int/float + (MariaDB 的 SUM()/AVG() 聚合返回 Decimal,Flask 默认编码器会把它偷偷转成 + 字符串而不是数字,前端拿到 "284" 这种字符串做加法会变成字符串拼接出乱码)。 + """ + if isinstance(v, dict): + return {k: _ser(x) for k, x in v.items()} + if isinstance(v, list): + return [_ser(x) for x in v] + if isinstance(v, Decimal): + return int(v) if v == v.to_integral_value() else float(v) + if hasattr(v, 'isoformat'): + return v.isoformat() + return v + + +@ui_bp.route('/api/ui/videos', methods=['GET']) +def videos(): + """视频会话列表(事件时间轴左侧),支持 date + page 分页。""" + date_filter = request.args.get('date') or None + page = max(0, request.args.get('page', 0, type=int)) + page_size = 15 + rows = db_layer.get_sync_videos(limit=page_size, offset=page * page_size, + date_filter=date_filter) + return jsonify({"videos": _ser(rows), "page": page, "page_size": page_size}), 200 + + +@ui_bp.route('/api/ui/videos/', methods=['GET']) +def video_detail(video_id): + """单个视频会话详情 + 时间线事件列表(事件时间轴右侧)。""" + video = db_layer.get_sync_video(video_id) + if not video: + return jsonify({"error": "视频不存在"}), 404 + events = db_layer.get_sync_events_for_video(video_id) + return jsonify({"video": _ser(video), "events": _ser(events)}), 200 + + +@ui_bp.route('/api/ui/stats', methods=['GET']) +def stats(): + """统计卡:视频/事件/人物/关注数(可选按日期过滤)。""" + date_filter = request.args.get('date') or None + return jsonify(_ser(db_layer.get_sync_stats(date_filter))), 200 + + +def _clean_person(s: str) -> str: + """剥离全角/半角括号备注(如 '人物A(别名:人物B)' -> '人物A')。""" + return _BRACKET_RE.sub('', str(s or '')).strip() + + +@ui_bp.route('/api/ui/people', methods=['GET']) +def people(): + """人物列表:按 canonical_name 聚合(未命名按 label 自身聚合)。 + + 对应旧 Streamlit 版本 app.py 里的分组逻辑,原样搬到服务端。 + """ + rows = db_layer.get_sync_people() + groups = {} + for p in rows: + key = p.get('canonical_name') or p['label'] + g = groups.setdefault(key, { + 'display': key, 'is_named': bool(p.get('canonical_name')), + 'labels': [], 'appearances': 0, 'first_seen': None, + 'features_json': None, 'display_uid': None, + }) + g['labels'].append(p['label']) + g['appearances'] += (p.get('appearances') or 0) + fs = p.get('first_seen') + if fs and (g['first_seen'] is None or str(fs) < str(g['first_seen'])): + g['first_seen'] = fs + if not g['features_json'] and p.get('features_json'): + g['features_json'] = p['features_json'] + if not g['display_uid'] and p.get('display_uid'): + g['display_uid'] = p['display_uid'] + + out = sorted(groups.values(), key=lambda g: -g['appearances']) + all_labels = [p['label'] for p in rows] + return jsonify({"groups": _ser(out), "all_labels": all_labels}), 200 + + +@ui_bp.route('/api/ui/attention-events', methods=['GET']) +def attention_events(): + """需关注事件列表(统计图表页),已按人物去重规则清洗好 people 字段。""" + rows = db_layer.get_attention_events() + out = [] + for r in rows: + persons = r.get('person_list_json') + if persons: + import json + try: + items = json.loads(persons) if isinstance(persons, str) else persons + except (ValueError, TypeError): + items = [] + names = sorted({_clean_person(p) for p in (items or []) if _clean_person(p) and _clean_person(p) != '无人'}) + else: + names = [] + out.append({"date": r.get('ev_date'), "persons": names or ['无人']}) + return jsonify({"events": _ser(out)}), 200 + + +@ui_bp.route('/api/ui/named-members', methods=['GET']) +def named_members(): + """已命名成员真名列表(AI 对话页快捷选择)。""" + return jsonify({"members": db_layer.get_sync_named_members()}), 200 + + +@ui_bp.route('/api/ui/model-stats', methods=['GET']) +def model_stats(): + """云端模型调用统计:按模型聚合 + 最近调用明细。""" + agg = db_layer.get_sync_model_calls_stats() + calls = db_layer.get_sync_model_calls(limit=100) + return jsonify({"aggregate": _ser(agg), "recent_calls": _ser(calls)}), 200 + + +@ui_bp.route('/api/ui/service-status', methods=['GET']) +def service_status(): + """服务状态页:NAS 同步状态 + Oracle 实时活动(代理,token 不下发浏览器)。""" + sync = get_sync() + nas_status = sync.status() + + oracle_data = None + oracle_error = None + if sync.base_url and sync.token: + import requests + try: + r = requests.get(f"{sync.base_url}/api/oracle/activity", + params={'token': sync.token}, timeout=15) + if r.status_code == 200: + oracle_data = r.json() + else: + oracle_error = f"Oracle activity HTTP {r.status_code}" + except Exception as e: + oracle_error = f"连接 Oracle 失败: {e}" + else: + oracle_error = "未配置 oracle_sync.base_url/token" + + return jsonify({ + "nas_sync": nas_status, + "oracle": oracle_data, + "oracle_error": oracle_error, + }), 200 diff --git a/fam-ui/.gitignore b/fam-ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/fam-ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/fam-ui/README.md b/fam-ui/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/fam-ui/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/fam-ui/package-lock.json b/fam-ui/package-lock.json new file mode 100644 index 0000000..4c7f02f --- /dev/null +++ b/fam-ui/package-lock.json @@ -0,0 +1,1671 @@ +{ + "name": "fam-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fam-ui", + "version": "0.0.0", + "dependencies": { + "vue": "^3.5.40", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@vitejs/plugin-vue": "^6.0.8", + "tailwindcss": "^4.3.3", + "vite": "^8.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/fam-ui/package.json b/fam-ui/package.json new file mode 100644 index 0000000..ef55240 --- /dev/null +++ b/fam-ui/package.json @@ -0,0 +1,21 @@ +{ + "name": "fam-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.40", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@vitejs/plugin-vue": "^6.0.8", + "tailwindcss": "^4.3.3", + "vite": "^8.2.0" + } +} diff --git a/fam-ui/requirements.txt b/fam-ui/requirements.txt deleted file mode 100644 index 35222d2..0000000 --- a/fam-ui/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -streamlit>=1.30.0 -PyMySQL>=1.1.0 -pandas>=2.1.0 -requests>=2.31.0 -PyYAML>=6.0 diff --git a/fam-ui/src/App.vue b/fam-ui/src/App.vue new file mode 100644 index 0000000..572b9b2 --- /dev/null +++ b/fam-ui/src/App.vue @@ -0,0 +1,65 @@ + + + diff --git a/fam-ui/src/api.js b/fam-ui/src/api.js new file mode 100644 index 0000000..5fbd4ae --- /dev/null +++ b/fam-ui/src/api.js @@ -0,0 +1,112 @@ +// API 薄封装:生产环境同源相对路径;开发环境走 vite.config.js 的 /api 代理。 + +async function request(path, options = {}) { + const res = await fetch(path, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }) + let data = null + try { + data = await res.json() + } catch { + // 非 JSON 响应(如 404 空 body),保持 data=null + } + if (!res.ok) { + const msg = (data && (data.error || data.message)) || `HTTP ${res.status}` + throw new Error(msg) + } + return data +} + +/** 过滤掉 null/undefined/空字符串再拼 query string,避免 URLSearchParams 把 + * undefined 字面量转成字符串 "undefined" 传给后端。 */ +function qs(params) { + const clean = Object.fromEntries( + Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')) + const s = new URLSearchParams(clean).toString() + return s ? '?' + s : '' +} + +export const api = { + get: (path) => request(path), + post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }), + + videos: (params = {}) => request(`/api/ui/videos${qs(params)}`), + videoDetail: (id) => request(`/api/ui/videos/${id}`), + stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`), + people: () => request('/api/ui/people'), + namedMembers: () => request('/api/ui/named-members'), + modelStats: () => request('/api/ui/model-stats'), + attentionEvents: () => request('/api/ui/attention-events'), + serviceStatus: () => request('/api/ui/service-status'), + + chatAsk: (question, queried_person, queried_date) => + request('/api/chat/ask', { method: 'POST', body: JSON.stringify({ question, queried_person, queried_date }) }), + chatHistory: (params = {}) => request(`/api/chat/history${qs(params)}`), + + nameMember: (label, canonical_name) => + request('/api/member/name', { method: 'POST', body: JSON.stringify({ label, canonical_name, named_by: 'UI管理员' }) }), + mergeMember: (source, target) => + request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }), + + status: () => request('/api/status'), +} + +/** 剥离全角/半角括号备注(如 '人物A(别名:人物B)' -> '人物A'),与后端归一化一致 */ +export function cleanPerson(s) { + return String(s || '').replace(/[((][^()()]*[))]/g, '').trim() +} + +/** person_list_json(字符串或数组)-> 去重、去括号备注、去"无人"的名字数组 */ +export function parsePersons(personListJson) { + if (!personListJson) return [] + let items = personListJson + if (typeof items === 'string') { + try { items = JSON.parse(items) } catch { items = [items] } + } + if (!Array.isArray(items)) items = [items] + const out = new Set() + for (const it of items) { + const s = cleanPerson(it) + if (s && s !== '无人') out.add(s) + } + return [...out].sort() +} + +/** 'YYYY-MM-DD HH:MM:SS' / 相对时间 'HH:MM:SS' -> Date 对象,失败返回 null */ +export function parseTs(ts) { + if (!ts) return null + const s = String(ts) + let m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/) + if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]) + m = s.match(/^(\d{1,2}):(\d{2}):(\d{2})/) + if (m) { + const d = new Date(0) + d.setHours(+m[1], +m[2], +m[3]) + return d + } + return null +} + +const pad = (n) => String(n).padStart(2, '0') + +export function fmtTime(ts) { + const d = parseTs(ts) + return d ? `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` : (String(ts || '').slice(0, 8) || '--:--') +} + +export function fmtDateTime(ts) { + const d = parseTs(ts) + if (!d) return '—' + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +export function fmtMonthDayTime(ts) { + const d = parseTs(ts) + return d ? `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` : '--:--' +} + +export function fmtDateOnly(ts) { + const d = parseTs(ts) + return d ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : '' +} diff --git a/fam-ui/src/app.py b/fam-ui/src/app.py deleted file mode 100644 index 4241eca..0000000 --- a/fam-ui/src/app.py +++ /dev/null @@ -1,1239 +0,0 @@ -""" -FAM-UI - 家庭多模态智能监控系统前端 v2(新架构 v2,2026-08-21) - -管理后台:仅从甲骨文同步镜像 (sync_videos / sync_events / sync_people) 读取展示, -不处理任何视频。页面: -- 🕒 事件时间轴: 视频会话列表 + 事件时间线(纯文本摘要,无帧图) -- 💬 AI 对话: 基于同步事件上下文问答(走核心 /api/chat/ask -> 甲骨文编排) -- 📝 对话历史 -- 👤 人物管理: 命名 / 合并(回推甲骨文,不再有帧照片) -- 📈 统计图表: 模型来源 / 关注事件 / 同步状态 -""" -import os -import sys -import json -import html as _html -import requests -import streamlit as st -import pymysql -import pymysql.cursors -from datetime import datetime, date - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - -from config_loader import load_config - -_cfg = load_config() -_core_url = _cfg.get('core_url', 'http://127.0.0.1:8000') -_oracle_url = (_cfg.get('oracle_url') or '').rstrip('/') -_oracle_token = _cfg.get('oracle_token') or '' -_db_cfg = _cfg.get('database', {}) - -esc = _html.escape - - -def get_db_conn(): - """获取数据库连接""" - return pymysql.connect( - host=_db_cfg.get('host', '127.0.0.1'), - port=_db_cfg.get('port', 3306), - user=_db_cfg.get('user', 'root'), - password=_db_cfg.get('password', ''), - database=_db_cfg.get('database', 'sentinel_home_ai'), - charset='utf8mb4', - cursorclass=pymysql.cursors.DictCursor - ) - - -def serialize_datetime(obj): - if hasattr(obj, 'isoformat'): - return obj.isoformat() - return str(obj) - - -def parse_ts(ts): - """'2026-08-14 22:31:15' / datetime -> datetime(失败返回 None)""" - if isinstance(ts, datetime): - return ts - if not ts: - return None - try: - return datetime.strptime(str(ts)[:19], '%Y-%m-%d %H:%M:%S') - except ValueError: - # 兼容仅相对时间 '00:01:23' - try: - return datetime.strptime(str(ts)[:8], '%H:%M:%S') - except ValueError: - return None - - -def parse_persons(person_list_json): - """解析 person_list_json(字符串或数组)为名字集合""" - if not person_list_json: - return set() - if isinstance(person_list_json, (list, tuple)): - items = person_list_json - else: - try: - items = json.loads(person_list_json) - except (ValueError, TypeError): - items = [person_list_json] - out = set() - for it in items: - s = str(it).strip() - if s and s != '无人': - out.add(s) - return out - - -# ============================================================ -# 页面配置 & 全局样式 -# ============================================================ -st.set_page_config( - page_title="家庭智能监控", - page_icon="🏠", - layout="wide", - initial_sidebar_state="expanded" -) - -GLOBAL_CSS = """ - -""" - - -def page_header(icon: str, title: str, sub: str = ''): - st.markdown( - f'
' - f'
{icon}
' - f'
{esc(title)}
' - f'{f"
{esc(sub)}
" if sub else ""}' - f'
', - unsafe_allow_html=True) - - -def render_event_list(events: list): - """渲染事件时间线:左相对时间 + 右事件卡(人物徽章 + 人物特征摘要 + 描述)""" - items = [] - for e in events: - ts = parse_ts(e.get('ts')) - time_label = ts.strftime('%H:%M:%S') if ts else (str(e.get('ts') or '')[:8] or '--:--') - camera = e.get('camera_name') or '' - persons = parse_persons(e.get('person_list_json')) - attention = bool(e.get('is_attention_event')) - desc = e.get('description') or '(无描述)' - - # 人物出现明细:从 person_appearances_json 渲染 uid + 简短特征 + action - appearances_html = '' - pa_raw = e.get('person_appearances_json') - appearances = [] - if pa_raw: - try: - appearances = json.loads(pa_raw) if isinstance(pa_raw, str) else pa_raw - except (ValueError, TypeError): - appearances = [] - if appearances and isinstance(appearances, list): - cells = [] - for pa in appearances: - if not isinstance(pa, dict): - continue - uid = str(pa.get('uid') or '').strip() - if not uid: - continue - feats = pa.get('features') or {} - feat_bits = [] - for fk in ('gender', 'clothing', 'face'): - fv = (feats.get(fk) or '').strip() if isinstance(feats, dict) else '' - if fv and fv.lower() != 'unknown': - feat_bits.append(fv) - feat_str = ' · '.join(feat_bits) if feat_bits else '无特征' - action = str(pa.get('action') or '').strip() - cells.append( - f'
' - f'{esc(uid)}' - f'{esc(feat_str)}' - f'{f"
{esc(action)}
" if action else ""}' - f'
') - if cells: - appearances_html = '
' + ''.join(cells) + '
' - - badges = ''.join( - f'{esc(p)}' for p in sorted(persons)) - if attention: - badges = '⚠ 需关注' + badges - - items.append( - f'
' - f'
{esc(time_label)}' - f'{f"{esc(camera)}" if camera else ""}
' - f'
{badges}{appearances_html}' - f'
{esc(desc)}
' - f'
' - ) - st.markdown(f'
{"".join(items)}
', unsafe_allow_html=True) - - -# ============================================================ -# 侧边栏 -# ============================================================ -st.markdown(GLOBAL_CSS, unsafe_allow_html=True) - -st.sidebar.markdown( - '
' - '
🏠 家庭智能监控
' - '
SENTINEL HOME AI · 管理后台
' - '
', unsafe_allow_html=True) - -# 顶部横向导航 -page = st.segmented_control( - "功能页面", - ["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 人物管理", "📈 统计图表", "🤖 模型统计", "🖥 服务状态"], - default="🕒 事件时间轴", - label_visibility="collapsed" -) or "🕒 事件时间轴" - -# 侧边栏底部:同步状态 -try: - resp = requests.get(f"{_core_url}/api/status", timeout=10) - if resp.status_code == 200: - sdata = resp.json().get('sync', {}) - last = sdata.get('last_sync_at') - err = sdata.get('last_error') - cursor = sdata.get('cursor') - cnt = sdata.get('last_count') - cnt_str = f"视频+{cnt[0]} / 事件+{cnt[1]} / 人物+{cnt[2]}" if cnt else "—" - status_cls = 'ok' if sdata.get('running') else 'err' - status_txt = '同步中' if sdata.get('running') else '未运行' - err_line = f'
⚠ {esc(err)}
' if err else '' - st.sidebar.markdown('---') - st.sidebar.markdown( - f'
' - f'同步状态
' - f'状态 {esc(status_txt)}
' - f'最近 {esc(str(last)[:19]) if last else "—"}
' - f'本次增量 {esc(cnt_str)}
' - f'游标 {esc(str(cursor)[:19]) if cursor else "(全量)"}' - f'{err_line}' - f'
', unsafe_allow_html=True) -except Exception: - pass - - -# ============================================================ -# 事件时间轴页 -# ============================================================ -if page == "🕒 事件时间轴": - page_header('🕒', '事件时间轴', '视频会话 · 时间点 · 信息摘要') - - with st.container(): - col_date, col_sp = st.columns([1, 3]) - with col_date: - date_filter = st.date_input("日期", value=None) - - date_str = date_filter.isoformat() if date_filter else None - - # 统计卡 - try: - conn = get_db_conn() - try: - cursor = conn.cursor() - # 日期维度统一用视频实际录制时间 event_start_time(文件名解析), - # 为空时回退 processed_at(分析时间),避免"处理时间=8/21"把旧视频全堆同一天 - _DATE_EXPR = "COALESCE(NULLIF(event_start_time,''), processed_at)" - if date_str: - cursor.execute( - f"SELECT COUNT(*) v FROM sync_videos WHERE status='done' AND {_DATE_EXPR} LIKE %s", - (f'{date_str}%',)) - v = cursor.fetchone().get('v', 0) - cursor.execute( - f"SELECT COUNT(*) e FROM sync_events se JOIN sync_videos sv ON se.video_id=sv.id WHERE {_DATE_EXPR} LIKE %s", - (f'{date_str}%',)) - e = cursor.fetchone().get('e', 0) - cursor.execute( - f"""SELECT COALESCE(SUM(se.is_attention_event),0) AS att - FROM sync_events se JOIN sync_videos sv ON se.video_id=sv.id - WHERE {_DATE_EXPR} LIKE %s""", (f'{date_str}%',)) - att = cursor.fetchone().get('att', 0) - else: - cursor.execute("SELECT COUNT(*) v FROM sync_videos WHERE status='done'") - v = cursor.fetchone().get('v', 0) - cursor.execute("SELECT COUNT(*) e FROM sync_events") - e = cursor.fetchone().get('e', 0) - cursor.execute("SELECT COALESCE(SUM(is_attention_event),0) att FROM sync_events") - att = cursor.fetchone().get('att', 0) - # 出现人物数:distinct label 命中 sync_events - cursor.execute("SELECT label, canonical_name FROM sync_people") - people = cursor.fetchall() - ps = 0 - for p in people: - name = p['canonical_name'] or p['label'] - cursor.execute("SELECT COUNT(*) c FROM sync_events WHERE person_list_json LIKE %s", - (f'%{name}%',)) - if cursor.fetchone()['c'] > 0: - ps += 1 - finally: - conn.close() - st.markdown( - f'
' - f'
{v}
视频会话
' - f'
{e}
事件数
' - f'
{ps}
出现人物
' - f'
{att}
需关注
' - f'
', unsafe_allow_html=True) - except Exception: - pass - - # 视频会话列表 - page_size = 15 - if 'event_page' not in st.session_state: - st.session_state.event_page = 0 - offset = st.session_state.event_page * page_size - - videos = [] - try: - conn = get_db_conn() - try: - cursor = conn.cursor() - if date_str: - cursor.execute( - f"""SELECT id, filename, camera_name, event_start_time, summary_json, - compute_provider, processed_at, - (SELECT COUNT(*) FROM sync_events se WHERE se.video_id=sync_videos.id) AS event_count - FROM sync_videos - WHERE status='done' AND {_DATE_EXPR} LIKE %s - ORDER BY {_DATE_EXPR} DESC - LIMIT %s OFFSET %s""", - (f'{date_str}%', page_size, offset)) - else: - cursor.execute( - f"""SELECT id, filename, camera_name, event_start_time, summary_json, - compute_provider, processed_at, - (SELECT COUNT(*) FROM sync_events se WHERE se.video_id=sync_videos.id) AS event_count - FROM sync_videos - WHERE status='done' - ORDER BY {_DATE_EXPR} DESC - LIMIT %s OFFSET %s""", - (page_size, offset)) - videos = cursor.fetchall() - finally: - conn.close() - except Exception as e: - st.error(f"读取同步数据失败: {e}") - - if not videos: - st.markdown( - '
🗓' - '该日期暂无监控会话(甲骨文尚未同步数据?)
', unsafe_allow_html=True) - else: - if 'selected_video_id' not in st.session_state: - st.session_state.selected_video_id = videos[0]['id'] - valid_ids = {v['id'] for v in videos} - if st.session_state.selected_video_id not in valid_ids: - st.session_state.selected_video_id = videos[0]['id'] - selected_id = st.session_state.selected_video_id - - col_list, col_detail = st.columns([1, 2.35], gap='large') - - with col_list: - st.markdown( - f'
视频会话 · {len(videos)} 条
', - unsafe_allow_html=True) - for vid in videos: - # 日期标签/时间用视频实际录制时间(event_start_time),为空回退处理时间 - ev_time = parse_ts(vid.get('event_start_time')) or parse_ts(vid.get('processed_at')) - time_label = ev_time.strftime('%m-%d %H:%M') if ev_time else '--:--' - date_label = ev_time.strftime('%Y-%m-%d') if ev_time else '' - summary = (vid.get('summary_json') or '').strip() - summary_short = summary if len(summary) <= 28 else summary[:28] + '…' - if not summary_short: - summary_short = '暂无摘要' - is_selected = vid['id'] == selected_id - if st.button( - f"{'▶ ' if is_selected else ''}{time_label} · {vid.get('camera_name') or '未知'} · {vid['event_count']}事件", - key=f"vidbtn_{vid['id']}", - type="primary" if is_selected else "secondary", - use_container_width=True - ): - st.session_state.selected_video_id = vid['id'] - st.rerun() - st.markdown( - f'
{esc(date_label)} · {esc(summary_short)}
', - unsafe_allow_html=True) - - nav1, nav2, nav3 = st.columns(3) - with nav1: - if st.button("← 上一页", use_container_width=True, - disabled=st.session_state.event_page == 0): - st.session_state.event_page -= 1 - st.rerun() - with nav3: - if st.button("下一页 →", use_container_width=True, - disabled=len(videos) < page_size): - st.session_state.event_page += 1 - st.rerun() - - with col_detail: - vid = next(v for v in videos if v['id'] == selected_id) - start = parse_ts(vid.get('event_start_time')) - proc = parse_ts(vid.get('processed_at')) - # 主时间 = 录制时间(event_start_time),回退分析时间(processed_at) - main_t = start or proc - range_str = '' - if main_t: - range_str = main_t.strftime('%Y-%m-%d %H:%M:%S') - if start and proc and (proc - start).total_seconds() > 60: - range_str += f"(分析于 {proc.strftime('%m-%d %H:%M')})" - - provider = vid.get('compute_provider') or '' - model_badges = '' - if provider: - model_badges = ''.join( - f'{esc(p)}' for p in str(provider).split(',')) - - summary = vid.get('summary_json') or '暂无全局摘要' - # 不再展示视频首帧缩略图(Oracle 端不再生成 jpg); - # 摘要文本 + 下方事件时间线已足够定位画面内容 - st.markdown( - f'
' - f'
' - f'{esc(vid.get("camera_name") or "未知摄像头")}' - f'会话 #{vid["id"]}' - f'{model_badges}
' - f'
⏱ {esc(range_str)} · 文件 {esc(vid.get("filename") or "")}
' - f'
{esc(summary)}
' - f'
', unsafe_allow_html=True) - - events = [] - try: - conn = get_db_conn() - try: - cursor = conn.cursor() - cursor.execute( - """SELECT e.id, e.ts, e.description, e.person_list_json, - e.person_appearances_json, e.is_attention_event, - v.camera_name - FROM sync_events e - JOIN sync_videos v ON e.video_id = v.id - WHERE e.video_id=%s ORDER BY e.ts ASC""", - (selected_id,)) - events = cursor.fetchall() - finally: - conn.close() - except Exception as e: - st.error(f"读取事件失败: {e}") - - if events: - render_event_list(events) - else: - st.markdown( - '
🎞' - '该会话暂无时间点事件
', unsafe_allow_html=True) - - -# ============================================================ -# AI 对话页 -# ============================================================ -elif page == "💬 AI 对话": - page_header('💬', 'AI 对话', '基于同步事件上下文的智能问答') - - named = [] - try: - conn = get_db_conn() - try: - cursor = conn.cursor() - cursor.execute( - "SELECT DISTINCT canonical_name FROM sync_people " - "WHERE canonical_name IS NOT NULL AND canonical_name != ''") - named = [r['canonical_name'] for r in cursor.fetchall()] - finally: - conn.close() - except Exception: - pass - - col1, col2 = st.columns(2) - with col1: - queried_person = st.text_input("查询人物", value=named[0] if named else "") - with col2: - queried_date = st.date_input("查询日期", value=date.today()) - - if named: - quick_person = st.selectbox("快捷选择成员", [""] + named) - if quick_person: - queried_person = quick_person - - quick_questions = [ - f"{queried_person}今天干嘛了?", - f"{queried_person}有没有发生什么需要注意的事情?", - f"今天{queried_person}的活动时间线是什么?", - ] - selected_quick = st.selectbox("快捷提问", ["自定义"] + quick_questions) - user_question = st.text_area("你的问题", value=selected_quick if selected_quick != "自定义" else "") - - if st.button("提问", type="primary"): - if not user_question.strip(): - st.warning("请输入问题") - elif not queried_person.strip(): - st.warning("请输入查询人物") - else: - with st.spinner("AI 正在思考..."): - try: - resp = requests.post( - f"{_core_url}/api/chat/ask", - json={ - "question": user_question, - "queried_person": queried_person, - "queried_date": queried_date.isoformat() - }, - timeout=120 - ) - if resp.status_code == 200: - data = resp.json() - st.markdown( - f'
❓ 提问
' - f'{esc(user_question)}
', unsafe_allow_html=True) - st.markdown( - f'
🤖 回答
', - unsafe_allow_html=True) - st.markdown(data['answer']) - if data.get('context_summary'): - st.caption(f"上下文: {data['context_summary']}") - else: - st.error(f"请求失败: {resp.status_code} {resp.text}") - except requests.ConnectionError: - st.error(f"无法连接 FAM-Core ({_core_url})") - except Exception as e: - st.error(f"异常: {e}") - - -# ============================================================ -# 对话历史页 -# ============================================================ -elif page == "📝 对话历史": - page_header('📝', '对话历史', '历史问答记录') - - if 'chat_page' not in st.session_state: - st.session_state.chat_page = 0 - - page_size = 20 - offset = st.session_state.chat_page * page_size - - history = [] - try: - conn = get_db_conn() - try: - cursor = conn.cursor() - cursor.execute( - """SELECT chat_id, user_question, ai_answer, context_summary, - queried_date, queried_person, created_at - FROM chat_history - ORDER BY created_at DESC - LIMIT %s OFFSET %s""", - (page_size, offset)) - history = cursor.fetchall() - finally: - conn.close() - except Exception as e: - st.error(f"读取对话历史失败: {e}") - - if not history: - st.markdown( - '
💬' - '暂无对话记录
', unsafe_allow_html=True) - else: - for h in history: - created = serialize_datetime(h['created_at']) - st.markdown( - f'
' - f'👤 {esc(h.get("queried_person") or "未知")} · {esc(created)}
' - f'{esc(h["user_question"])}
', unsafe_allow_html=True) - st.markdown( - f'
🤖 回答
', - unsafe_allow_html=True) - st.markdown(h['ai_answer'] or '') - st.markdown('
', unsafe_allow_html=True) - - nav1, nav2, nav3 = st.columns([1, 1, 1]) - with nav1: - if st.button("← 上一页", disabled=st.session_state.chat_page == 0): - st.session_state.chat_page -= 1 - st.rerun() - with nav2: - st.markdown( - f'
第 {st.session_state.chat_page + 1} 页
', - unsafe_allow_html=True) - with nav3: - if st.button("下一页 →", disabled=len(history) < page_size): - st.session_state.chat_page += 1 - st.rerun() - - -# ============================================================ -# 人物管理页 -# ============================================================ -elif page == "👤 人物管理": - page_header('👤', '人物管理', '所有出现的人物 · 命名与合并(回推甲骨文)') - - people = [] - try: - conn = get_db_conn() - try: - cursor = conn.cursor() - cursor.execute( - "SELECT id, label, canonical_name, first_seen, appearances, source, " - "features_json, display_uid " - "FROM sync_people ORDER BY id ASC") - people = cursor.fetchall() - finally: - conn.close() - except Exception as e: - st.error(f"读取人物失败: {e}") - people = [] - - # 按规范名聚合(未命名按 label) - groups = {} - for p in people: - key = p['canonical_name'] or p['label'] - groups.setdefault(key, {'display': key, 'is_named': bool(p['canonical_name']), - 'labels': [], 'appearances': 0, 'first_seen': None, - 'features_json': None, 'display_uid': None}) - g = groups[key] - g['labels'].append(p['label']) - g['appearances'] += (p.get('appearances') or 0) - fs = p.get('first_seen') - if fs and (g['first_seen'] is None or str(fs) < str(g['first_seen'])): - g['first_seen'] = fs - # 特征:取该聚合下任一 label 的 features_json(非空优先) - if not g['features_json'] and p.get('features_json'): - g['features_json'] = p['features_json'] - if not g['display_uid'] and p.get('display_uid'): - g['display_uid'] = p['display_uid'] - - if not groups: - st.markdown( - '
👤' - '暂未发现任何人物(甲骨文尚未同步)
', unsafe_allow_html=True) - else: - st.markdown( - f'
' - f'共 {len(groups)} 个身份 · ' - f'已命名 {sum(1 for g in groups.values() if g["is_named"])} · ' - f'未命名 {sum(1 for g in groups.values() if not g["is_named"])}' - f'
', unsafe_allow_html=True) - - # 命名/合并操作 - def do_name(label, new_name): - try: - resp = requests.post( - f"{_core_url}/api/member/name", - json={"label": label, "canonical_name": new_name, "named_by": "UI管理员"}, - timeout=30) - if resp.status_code == 200: - st.success(f"已保存:{label} → {new_name}(已回推甲骨文并即时同步)") - st.rerun() - else: - st.error(f"失败: {resp.status_code} {resp.text[:150]}") - except Exception as e: - st.error(f"异常: {e}") - - def do_merge(src_label, tgt_key): - try: - resp = requests.post( - f"{_core_url}/api/member/merge", - json={"source": src_label, "target": tgt_key}, - timeout=30) - if resp.status_code == 200: - st.success(f"已合并:{src_label} → {tgt_key}") - st.rerun() - else: - st.error(f"合并失败: {resp.status_code} {resp.text[:150]}") - except Exception as e: - st.error(f"合并异常: {e}") - - label_options = [p['label'] for p in people] - for key in sorted(groups, key=lambda k: -groups[k]['appearances']): - g = groups[key] - is_named = g['is_named'] - fs = parse_ts(g['first_seen']) - first_str = fs.strftime('%m-%d %H:%M') if fs else '--' - tag = (f'已命名') if is_named else \ - (f'未命名') - label_str = ' · '.join(g['labels']) - # 人物特征卡:从 sync_people.features_json 渲染结构化特征 - # (不依赖任何 jpg 文件;大模型每次分析视频时落库的特征值) - feats_html = '' - feats_raw = g.get('features_json') or '{}' - try: - feats = json.loads(feats_raw) if feats_raw else {} - except (ValueError, TypeError): - feats = {} - if feats: - # 结构化特征网格 - feat_rows = [ - ('性别', feats.get('gender')), - ('年龄段', feats.get('age_band')), - ('身材', feats.get('build')), - ('发型', feats.get('hair')), - ('衣着', feats.get('clothing')), - ('面部', feats.get('face')), - ('辨识点', feats.get('distinguishing')), - ] - feat_cells = [] - for label_name, val in feat_rows: - v = (val or '').strip() if val else '' - if not v: - continue - cls = '' if v.lower() != 'unknown' else 'style="color:#475569;"' - feat_cells.append( - f'' - f'{esc(label_name)} ' - f'{esc(v)}') - if feat_cells: - feats_html = ( - f'
' - + ''.join(feat_cells) + '
') - else: - feats_html = ( - '
' - '特征待大模型补充(下段视频分析时由 VLM 落库)
') - uid_html = '' - if g.get('display_uid') and g['display_uid'] != key: - uid_html = (f'UID: {esc(g["display_uid"])}') - st.markdown( - f'
' - f'{esc(key)}{tag}{uid_html}
' - f'
' - f'标识: {esc(label_str)}
' - f'
' - f'出现 {g["appearances"]} 次 · 首次 {esc(first_str)}
' - f'{feats_html}', - unsafe_allow_html=True) - if not is_named: - # 未命名身份:每个 label 都给一个命名框 - for lb in g['labels']: - col_in, col_btn, col_mg = st.columns([2, 1, 1.4]) - with col_in: - new_name = st.text_input( - "名字", key=f"nm_{lb}", placeholder=f'为「{lb}」起名', - label_visibility="collapsed") - with col_btn: - if st.button("命名", key=f"btn_{lb}", type="primary", - use_container_width=True): - if new_name.strip(): - do_name(lb, new_name.strip()) - else: - st.warning("请输入名字") - with col_mg: - merge_to = st.selectbox( - "合并到", options=[""] + [o for o in label_options if o != lb], - key=f"mg_{lb}", label_visibility="collapsed", - placeholder="合并到其他…") - if merge_to: - if st.button("合并", key=f"mbtn_{lb}", use_container_width=True): - do_merge(lb, merge_to) - else: - # 已命名:仅提供合并到其他身份 - merge_to = st.selectbox( - "合并到", options=[""] + [o for o in label_options if o != key], - key=f"mg2_{key}", label_visibility="collapsed", - placeholder="合并到其他身份…") - if merge_to: - if st.button("合并", key=f"mbtn2_{key}", use_container_width=True): - # 用该身份下任一 label 作为 source - do_merge(g['labels'][0], merge_to) - st.markdown('
', unsafe_allow_html=True) - - -# ============================================================ -# 统计图表页 -# ============================================================ -elif page == "📈 统计图表": - page_header('📈', '统计图表', '模型来源 / 关注事件 / 同步状态') - - conn = None - try: - conn = get_db_conn() - cursor = conn.cursor() - - st.markdown('
模型来源分布
', unsafe_allow_html=True) - cursor.execute( - "SELECT compute_provider, COUNT(*) AS count FROM sync_videos " - "WHERE status='done' GROUP BY compute_provider") - stats = cursor.fetchall() - if stats: - chart = {} - for r in stats: - prov = (r['compute_provider'] or 'unknown') - # 可能是逗号分隔多个 - for p in str(prov).split(','): - p = p.strip() - if p: - chart[p] = chart.get(p, 0) + r['count'] - st.bar_chart(chart) - else: - st.markdown('
暂无统计数据
', unsafe_allow_html=True) - - st.markdown('
关注事件统计
', unsafe_allow_html=True) - cursor.execute( - """SELECT COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date, - e.person_list_json - FROM sync_events e JOIN sync_videos v ON e.video_id=v.id - WHERE e.is_attention_event = 1 - ORDER BY ev_date DESC""") - attention = cursor.fetchall() - if attention: - import pandas as pd - rows = [] - for a in attention: - persons = parse_persons(a['person_list_json']) - ptime = parse_ts(a['ev_date']) - rows.append({ - "日期": ptime.strftime('%Y-%m-%d') if ptime else '?', - "人物": ','.join(sorted(persons)) or '无人', - }) - df_att = pd.DataFrame(rows) - st.dataframe(df_att, use_container_width=True, hide_index=True) - else: - st.markdown('
暂无关注事件
', unsafe_allow_html=True) - - st.markdown('
同步状态
', unsafe_allow_html=True) - try: - resp = requests.get(f"{_core_url}/api/status", timeout=10) - if resp.status_code == 200: - sdata = resp.json().get('sync', {}) - cnt = sdata.get('last_count') - cnt_str = f"视频+{cnt[0]} / 事件+{cnt[1]} / 人物+{cnt[2]}" if cnt else "—" - err = sdata.get('last_error') - cls = 'ok' if sdata.get('running') else 'err' - err_line = f'
⚠ {esc(err)}
' if err else '' - st.markdown( - f'
' - f'运行状态 {"同步中" if sdata.get("running") else "未运行"}
' - f'最近同步 {esc(str(sdata.get("last_sync_at") or "—")[:19])}
' - f'本次增量 {esc(cnt_str)}
' - f'游标 {esc(str(sdata.get("cursor") or "(全量)")[:19])}
' - f'周期 {esc(str(sdata.get("interval_sec")))}s' - f'{err_line}
', unsafe_allow_html=True) - except Exception: - st.markdown('
无法获取同步状态
', unsafe_allow_html=True) - - except Exception as e: - st.error(f"统计查询失败: {e}") - finally: - if conn: - conn.close() - -# ============================================================ -# 模型统计页(云端模型识别成功/失败统计) -# ============================================================ -elif page == "🤖 模型统计": - page_header('🤖', '云端模型统计', '请求时间 · 耗时 · 成功/失败 · 失败原因') - - conn = None - try: - conn = get_db_conn() - cursor = conn.cursor() - - # ---- 按模型聚合:成功/失败/成功率/平均耗时 ---- - st.markdown('
按模型聚合
', unsafe_allow_html=True) - cursor.execute( - """SELECT provider, model, - SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) AS ok_cnt, - SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS fail_cnt, - ROUND(AVG(duration_sec),1) AS avg_dur, - COUNT(*) AS total, - MAX(created_at) AS last_call - FROM sync_model_calls GROUP BY provider, model - ORDER BY total DESC""") - agg = cursor.fetchall() - if agg: - rows = [] - for r in agg: - total = r['total'] or 0 - ok = r['ok_cnt'] or 0 - rate = (ok / total * 100) if total else 0 - rows.append({ - "模型": f"{r['provider']} / {r['model']}", - "成功": ok, - "失败": r['fail_cnt'] or 0, - "成功率%": round(rate, 1), - "平均耗时s": r['avg_dur'], - "最后调用": str(r['last_call'] or '—')[:19], - }) - st.dataframe(rows, use_container_width=True, hide_index=True) - else: - st.markdown('
暂无模型调用记录(视频处理中或尚未同步)
', - unsafe_allow_html=True) - - # ---- 最近调用明细 ---- - st.markdown('
最近调用明细
', unsafe_allow_html=True) - cursor.execute( - """SELECT provider, model, started_at, duration_sec, success, error, - filename, video_id - FROM sync_model_calls ORDER BY id DESC LIMIT 100""") - calls = cursor.fetchall() - if calls: - import pandas as pd - rows = [] - for c in calls: - rows.append({ - "请求时间": str(c['started_at'] or '—')[:19], - "模型": f"{c['provider']} / {c['model']}", - "耗时s": round(c['duration_sec'] or 0, 1), - "状态": "✅ 成功" if c['success'] else "❌ 失败", - "失败原因": (c['error'] or '')[:60], - "视频": (c['filename'] or '')[:40], - }) - st.dataframe(rows, use_container_width=True, hide_index=True) - else: - st.markdown('
暂无调用明细
', unsafe_allow_html=True) - - # ---- 说明 ---- - st.markdown( - '
' - '统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。' - '失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。' - '
', unsafe_allow_html=True) - - except Exception as e: - st.error(f"模型统计查询失败: {e}") - finally: - if conn: - conn.close() - - -# ============================================================ -# 服务状态页(实时查看各服务在做什么:队列/模型/rclone/人物/NAS 同步) -# ============================================================ -elif page == "🖥 服务状态": - page_header('🖥', '服务状态', '各服务实时动态 · 最近 7 天活动记录') - - c1, c2, c3 = st.columns([1, 1, 5]) - with c1: - if st.button("🔄 刷新", use_container_width=True): - st.rerun() - with c2: - st.caption("自动每 5 分钟更新页面(点击刷新立即更新)") - - # ---- 拉取 Oracle 实时状态 + NAS 同步状态 ---- - act_data = None - act_err = None - if _oracle_url and _oracle_token: - try: - r = requests.get( - f"{_oracle_url}/api/oracle/activity?token={_oracle_token}", - timeout=15) - if r.status_code == 200: - act_data = r.json() - else: - act_err = f"Oracle activity HTTP {r.status_code}" - except Exception as e: - act_err = f"连接 Oracle 失败: {e}" - else: - act_err = "未配置 oracle_url / oracle_token" - - nas_sync = None - try: - rr = requests.get(f"{_core_url}/api/status", timeout=10) - if rr.status_code == 200: - nas_sync = rr.json().get('sync', {}) - except Exception: - pass - - if act_err: - st.warning(act_err) - if act_data is None and nas_sync is None: - st.markdown('
暂时无法获取服务状态
', unsafe_allow_html=True) - else: - # ---- 服务状态卡 ---- - st.markdown('
各服务当前状态
', unsafe_allow_html=True) - - q = (act_data or {}).get('queue') or {} - dbinfo = (act_data or {}).get('db') or {} - cur_vid = q.get('current') - cur_txt = '—' - if cur_vid: - cur_txt = f"#{cur_vid.get('video_id')} {esc((cur_vid.get('filename') or '')[-42:])}" - by_status = dbinfo.get('by_status') or {} - st.markdown( - f'
' - f'
{"运行中" if q.get("running") else "停止"}
' - f'
FAM-Edge 队列
' - f'
{q.get("queued", 0)}
' - f'
排队中
' - f'
{by_status.get("done", 0)}
' - f'
已完成
' - f'
{by_status.get("pending", 0)}
' - f'
待处理
' - f'
{by_status.get("failed", 0)}
' - f'
失败
' - f'
', unsafe_allow_html=True) - - # 详细状态行 - qs = q.get('stats') or {} - st.markdown( - f'
' - f'▶ 正在处理:{cur_txt}' - f'生产者已入队 {qs.get("produced", 0)} · ' - f'成功 {qs.get("consumed_ok", 0)} · 失败 {qs.get("consumed_fail", 0)}
', - unsafe_allow_html=True) - - # 服务卡:rclone / 人物 / NAS 同步 / 模型 - def _svc_card(icon, name, val, sub, color='#cbd5e1'): - return ( - f'
' - f'
{icon} {esc(name)}
' - f'
{val}
' - f'
{esc(sub)}
' - f'
') - - rclone = (act_data or {}).get('rclone') or {} - person = (act_data or {}).get('person') or {} - cards = [] - # rclone - if rclone: - cards.append(_svc_card('🔄', 'rclone 同步', - f"{esc((rclone.get('detail') or '')[:70])}", - f"最近 {(rclone.get('ts') or '')[:19]}", '#4ade80')) - else: - cards.append(_svc_card('🔄', 'rclone 同步', '暂无记录', '—')) - # 人物合并 - if person: - cards.append(_svc_card('👤', '人物合并', - f"{esc((person.get('action') or ''))}", - f"{(person.get('ts') or '')[:19]} · {esc((person.get('detail') or '')[:46])}", '#c084fc')) - else: - cards.append(_svc_card('👤', '人物合并', '暂无记录', '—')) - # NAS 同步 - if nas_sync: - cnt = nas_sync.get('last_count') or [0, 0, 0, 0] - cards.append(_svc_card('📡', 'NAS 同步', - f"游标 {esc(str(nas_sync.get('cursor') or '')[:19])}", - f"最近 {(str(nas_sync.get('last_sync_at') or ''))[:19]} · 增量 V{cnt[0]} E{cnt[1]} P{cnt[2]} M{cnt[3]}", - '#fbbf24')) - else: - cards.append(_svc_card('📡', 'NAS 同步', '不可达', '—')) - # 模型 - mcs = (act_data or {}).get('model_calls') or [] - if mcs: - m0 = mcs[0] - cards.append(_svc_card('🧠', '云端模型', - f"最近:{esc(m0.get('model') or '')} {'✅' if m0.get('success') else '❌ ' + esc((m0.get('error') or '')[:30])}", - f"{(m0.get('started_at') or '')[:19]} · 耗时 {round(m0.get('duration_sec') or 0, 1)}s · 近5次 成功{sum(1 for m in mcs if m.get('success'))}/{len(mcs)}", - '#4ade80')) - else: - cards.append(_svc_card('🧠', '云端模型', '暂无调用', '—')) - st.markdown( - f'
{"".join(cards)}
', - unsafe_allow_html=True) - - # ---- 活动时间流 ---- - acts = (act_data or {}).get('activities') or [] - st.markdown('
最近活动
', unsafe_allow_html=True) - if not acts: - st.markdown('
暂无活动记录(服务刚启动?)
', - unsafe_allow_html=True) - else: - _svc_badge = { - 'queue': ('队列'), - 'rclone': ('同步'), - 'person': ('人物'), - } - items = [] - for a in acts[:50]: - badge = _svc_badge.get(a.get('service'), f"{esc(a.get('service'))}") - ts = str(a.get('ts') or '')[:19] - items.append( - f'
' - f'
{esc(ts)}
' - f'
{badge}' - f'{esc(a.get("action"))}' - f'{esc(a.get("detail") or "")}' - f'
') - st.markdown( - f'
{"".join(items)}
', unsafe_allow_html=True) - st.caption(f'共 {len(acts)} 条记录(活动日志仅保留最近 7 天)') diff --git a/fam-ui/src/components/Badge.vue b/fam-ui/src/components/Badge.vue new file mode 100644 index 0000000..01e21b8 --- /dev/null +++ b/fam-ui/src/components/Badge.vue @@ -0,0 +1,21 @@ + + + diff --git a/fam-ui/src/components/EmptyState.vue b/fam-ui/src/components/EmptyState.vue new file mode 100644 index 0000000..dca965b --- /dev/null +++ b/fam-ui/src/components/EmptyState.vue @@ -0,0 +1,13 @@ + + + diff --git a/fam-ui/src/components/EventItem.vue b/fam-ui/src/components/EventItem.vue new file mode 100644 index 0000000..f83daf1 --- /dev/null +++ b/fam-ui/src/components/EventItem.vue @@ -0,0 +1,66 @@ + + + diff --git a/fam-ui/src/components/PageHeader.vue b/fam-ui/src/components/PageHeader.vue new file mode 100644 index 0000000..3d3d6c8 --- /dev/null +++ b/fam-ui/src/components/PageHeader.vue @@ -0,0 +1,19 @@ + + + diff --git a/fam-ui/src/components/PersonCard.vue b/fam-ui/src/components/PersonCard.vue new file mode 100644 index 0000000..b62c3bc --- /dev/null +++ b/fam-ui/src/components/PersonCard.vue @@ -0,0 +1,129 @@ + + + diff --git a/fam-ui/src/components/ServiceCard.vue b/fam-ui/src/components/ServiceCard.vue new file mode 100644 index 0000000..913b54f --- /dev/null +++ b/fam-ui/src/components/ServiceCard.vue @@ -0,0 +1,15 @@ + + + diff --git a/fam-ui/src/components/StatCard.vue b/fam-ui/src/components/StatCard.vue new file mode 100644 index 0000000..86139b2 --- /dev/null +++ b/fam-ui/src/components/StatCard.vue @@ -0,0 +1,21 @@ + + + diff --git a/fam-ui/src/config_loader.py b/fam-ui/src/config_loader.py deleted file mode 100644 index b4c89e2..0000000 --- a/fam-ui/src/config_loader.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -配置加载器 (FAM-UI 复用) -""" -import os -import re -import yaml - - -def _resolve_env_vars(value): - if isinstance(value, str): - def replace_env(match): - return os.environ.get(match.group(1), match.group(0)) - return re.sub(r'\$\{(\w+)\}', replace_env, value) - elif isinstance(value, dict): - return {k: _resolve_env_vars(v) for k, v in value.items()} - elif isinstance(value, list): - return [_resolve_env_vars(item) for item in value] - return value - - -def load_config(config_path=None): - if config_path is None: - config_path = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - 'config', 'config.yaml' - ) - with open(config_path, 'r', encoding='utf-8') as f: - raw = yaml.safe_load(f) - return _resolve_env_vars(raw) diff --git a/fam-ui/src/main.js b/fam-ui/src/main.js new file mode 100644 index 0000000..490d3e2 --- /dev/null +++ b/fam-ui/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' +import router from './router.js' + +createApp(App).use(router).mount('#app') diff --git a/fam-ui/src/router.js b/fam-ui/src/router.js new file mode 100644 index 0000000..4caab36 --- /dev/null +++ b/fam-ui/src/router.js @@ -0,0 +1,19 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { path: '/', redirect: '/timeline' }, + { path: '/timeline', name: 'timeline', component: () => import('./views/Timeline.vue'), meta: { icon: '🕒', label: '事件时间轴' } }, + { path: '/chat', name: 'chat', component: () => import('./views/Chat.vue'), meta: { icon: '💬', label: 'AI 对话' } }, + { path: '/chat-history', name: 'chat-history', component: () => import('./views/ChatHistory.vue'), meta: { icon: '📝', label: '对话历史' } }, + { path: '/people', name: 'people', component: () => import('./views/People.vue'), meta: { icon: '👤', label: '人物管理' } }, + { path: '/stats', name: 'stats', component: () => import('./views/Stats.vue'), meta: { icon: '📈', label: '统计图表' } }, + { path: '/model-stats', name: 'model-stats', component: () => import('./views/ModelStats.vue'), meta: { icon: '🤖', label: '模型统计' } }, + { path: '/service-status', name: 'service-status', component: () => import('./views/ServiceStatus.vue'), meta: { icon: '🖥', label: '服务状态' } }, +] + +export const navItems = routes.filter(r => r.meta) + +export default createRouter({ + history: createWebHistory(), + routes, +}) diff --git a/fam-ui/src/style.css b/fam-ui/src/style.css new file mode 100644 index 0000000..4bf4aab --- /dev/null +++ b/fam-ui/src/style.css @@ -0,0 +1,52 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&display=swap'); +@import "tailwindcss"; + +@theme { + --color-bg: #0a0d13; + --color-panel: #12161f; + --color-panel-2: #171c28; + --color-panel-3: #0d1119; + --color-border: #232a3a; + --color-border-hi: #384357; + + --color-text: #e7ecf3; + --color-text-dim: #949fb3; + --color-text-mute: #67728a; + --color-text-faint: #454e60; + + --color-accent: #5b8cff; + --color-accent-2: #8b6bff; + --color-accent-bright: #9db8ff; + + --color-ok: #3ddc9b; + --color-warn: #f5b84e; + --color-danger: #ff7373; + --color-info: #4fd1e8; + --color-violet: #c893ff; + + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: 'JetBrains Mono', monospace; + + --shadow-card: 0 1px 2px rgba(0,0,0,.2), 0 8px 24px -12px rgba(0,0,0,.5); +} + +html, body, #app { height: 100%; } + +body { + background: + radial-gradient(1200px 500px at 15% -10%, rgb(91 140 255 / .07), transparent), + radial-gradient(900px 500px at 100% 0%, rgb(139 107 255 / .05), transparent), + var(--color-bg); + color: var(--color-text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +::selection { background: rgb(91 140 255 / .2); } + +/* 滚动条:深色主题下默认滚动条太亮,统一细一点、低调一点 */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--color-border-hi); border-radius: 999px; border: 2px solid var(--color-bg); } + +.tabular { font-variant-numeric: tabular-nums; } diff --git a/fam-ui/src/views/Chat.vue b/fam-ui/src/views/Chat.vue new file mode 100644 index 0000000..a590713 --- /dev/null +++ b/fam-ui/src/views/Chat.vue @@ -0,0 +1,103 @@ + + + diff --git a/fam-ui/src/views/ChatHistory.vue b/fam-ui/src/views/ChatHistory.vue new file mode 100644 index 0000000..ab25643 --- /dev/null +++ b/fam-ui/src/views/ChatHistory.vue @@ -0,0 +1,51 @@ + + + diff --git a/fam-ui/src/views/ModelStats.vue b/fam-ui/src/views/ModelStats.vue new file mode 100644 index 0000000..3aa5bbb --- /dev/null +++ b/fam-ui/src/views/ModelStats.vue @@ -0,0 +1,107 @@ + + + diff --git a/fam-ui/src/views/People.vue b/fam-ui/src/views/People.vue new file mode 100644 index 0000000..9b67855 --- /dev/null +++ b/fam-ui/src/views/People.vue @@ -0,0 +1,46 @@ + + + diff --git a/fam-ui/src/views/ServiceStatus.vue b/fam-ui/src/views/ServiceStatus.vue new file mode 100644 index 0000000..1d1c619 --- /dev/null +++ b/fam-ui/src/views/ServiceStatus.vue @@ -0,0 +1,115 @@ + + + diff --git a/fam-ui/src/views/Stats.vue b/fam-ui/src/views/Stats.vue new file mode 100644 index 0000000..6be4c03 --- /dev/null +++ b/fam-ui/src/views/Stats.vue @@ -0,0 +1,85 @@ + + + diff --git a/fam-ui/src/views/Timeline.vue b/fam-ui/src/views/Timeline.vue new file mode 100644 index 0000000..7150d21 --- /dev/null +++ b/fam-ui/src/views/Timeline.vue @@ -0,0 +1,141 @@ + + + diff --git a/fam-ui/vite.config.js b/fam-ui/vite.config.js new file mode 100644 index 0000000..10da8bf --- /dev/null +++ b/fam-ui/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' + +// 开发环境代理 /api 到 NAS 上的 fam-core,方便本地直接联调真实数据。 +// 生产环境由 fam-core 的 Flask 同源提供,不需要这个代理。 +export default defineConfig({ + plugins: [vue(), tailwindcss()], + server: { + proxy: { + '/api': { + target: 'http://192.168.50.64:8000', + changeOrigin: true, + }, + }, + }, +})