Инсталлятор спрашивает адрес Ollama в консоли, но это можно пропустить и задать всё с телефона: адрес, модель для разбора фраз, модель для поиска по смыслу. «Проверить связь» показывает, отвечает ли Ollama и сколько там моделей; сами модели выбираются из реально установленных, а не вводятся руками. Если Ollama молчит, предупреждение наверху ведёт прямо в настройки. Настройки применяются сразу: модули читают их через config.X, а не забирают копию при импорте, поэтому перезапуск не нужен. Из веба правятся только OLLAMA_HOST и две модели — по белому списку, потому что .env читает приложение при запуске. Адрес проверяется на формат, имена моделей — на недопустимые символы. Попутно исправлены два дефекта, найденных тестами: - .env в песочнице теперь тоже свой; до этого прогон тестов перезаписывал рабочий файл настроек; - значения проверяются все до применения: раньше неверное имя модели оставляло уже подменённый адрес Ollama. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
697 lines
29 KiB
Python
697 lines
29 KiB
Python
"""Flask-приложение: маршруты и точка входа."""
|
||
|
||
import uuid
|
||
import logging
|
||
import webbrowser
|
||
import threading
|
||
|
||
from flask import Flask, request, jsonify, send_from_directory
|
||
|
||
from . import store, search, brain, ollama, config
|
||
from .config import (PROJECT_DIR, TEMPLATES_DIR, PHOTOS_DIR, HOST, PORT,
|
||
MAX_UPLOAD_BYTES, ALLOWED_PHOTO_EXT)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
app = Flask(__name__, static_folder=str(PROJECT_DIR / "static"), static_url_path="/static")
|
||
app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_BYTES
|
||
|
||
|
||
# ============================================================
|
||
# Страница
|
||
# ============================================================
|
||
@app.route("/")
|
||
def index():
|
||
return send_from_directory(str(TEMPLATES_DIR), "index.html")
|
||
|
||
|
||
@app.route("/manifest.webmanifest")
|
||
def manifest():
|
||
"""Манифест отдаём отдельным маршрутом — с правильным MIME-типом.
|
||
|
||
Из /static/ Flask отдал бы его как обычный файл, и браузер мог бы
|
||
отказаться считать его манифестом, а без манифеста Android не берёт
|
||
иконку для ярлыка на домашнем экране.
|
||
"""
|
||
return send_from_directory(str(PROJECT_DIR / "static"), "manifest.webmanifest",
|
||
mimetype="application/manifest+json")
|
||
|
||
|
||
@app.route("/favicon.ico")
|
||
def favicon():
|
||
"""Браузеры просят /favicon.ico сами, независимо от тегов в разметке."""
|
||
return send_from_directory(str(PROJECT_DIR / "static"), "favicon-32.png",
|
||
mimetype="image/png")
|
||
|
||
|
||
@app.route("/api/health")
|
||
def health():
|
||
return jsonify({"ollama": ollama.check(), "host": config.OLLAMA_HOST,
|
||
"model": config.CHAT_MODEL,
|
||
# Пока ничего не записано и ИИ не отвечает — человек, скорее
|
||
# всего, только что поставил приложение: покажем настройки.
|
||
"fresh": not config.DB_FILE.exists()})
|
||
|
||
|
||
# ============================================================
|
||
# Настройки из браузера
|
||
#
|
||
# Чтобы после установки не возвращаться к консоли: адрес Ollama и модели
|
||
# задаются с того же телефона, а модели выбираются из реально имеющихся.
|
||
# ============================================================
|
||
@app.route("/api/config")
|
||
def api_config():
|
||
return jsonify({
|
||
"ollama_host": config.OLLAMA_HOST,
|
||
"chat_model": config.CHAT_MODEL,
|
||
"embed_model": config.EMBED_MODEL,
|
||
"available": ollama.check(),
|
||
"models": ollama.models(),
|
||
"env_file": str(config.ENV_FILE),
|
||
})
|
||
|
||
|
||
@app.route("/api/config/probe", methods=["POST"])
|
||
def api_config_probe():
|
||
"""Проверяет адрес, ничего не сохраняя: что там за Ollama и какие модели."""
|
||
host = ((request.get_json(silent=True) or {}).get("ollama_host") or "").strip()
|
||
if not host:
|
||
return jsonify({"ok": False, "reply": "Адрес не указан."}), 400
|
||
|
||
was = config.OLLAMA_HOST
|
||
try:
|
||
config.OLLAMA_HOST = host.rstrip("/")
|
||
alive = ollama.check()
|
||
models = ollama.models() if alive else []
|
||
finally:
|
||
config.OLLAMA_HOST = was # проба не должна менять настройки
|
||
|
||
if not alive:
|
||
return jsonify({"ok": False, "models": [],
|
||
"reply": "По адресу %s Ollama не отвечает." % host})
|
||
return jsonify({"ok": True, "models": models,
|
||
"reply": "Ollama отвечает, моделей: %d." % len(models)})
|
||
|
||
|
||
@app.route("/api/config", methods=["POST"])
|
||
def api_config_save():
|
||
data = request.get_json(silent=True) or {}
|
||
try:
|
||
changed = config.save_settings({
|
||
"OLLAMA_HOST": data.get("ollama_host"),
|
||
"HLAMINGO_CHAT_MODEL": data.get("chat_model"),
|
||
"HLAMINGO_EMBED_MODEL": data.get("embed_model"),
|
||
})
|
||
except ValueError as e:
|
||
return jsonify({"ok": False, "reply": str(e)}), 400
|
||
|
||
if not changed:
|
||
return jsonify({"ok": True, "reply": "Менять нечего."})
|
||
alive = ollama.check()
|
||
reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else
|
||
"Но Ollama по этому адресу молчит.")
|
||
return jsonify({"ok": True, "reply": reply, "available": alive,
|
||
"changed": changed})
|
||
|
||
|
||
# ============================================================
|
||
# Главный маршрут: свободная фраза
|
||
# ============================================================
|
||
@app.route("/api/say", methods=["POST"])
|
||
def say():
|
||
text = ((request.get_json(silent=True) or {}).get("text") or "").strip()
|
||
if not text:
|
||
return jsonify({"reply": "Напиши, что положил или что ищешь."})
|
||
|
||
parsed = brain.parse(text)
|
||
db = store.load()
|
||
action = parsed.get("action")
|
||
|
||
if action == "put":
|
||
return jsonify(_do_put(db, parsed))
|
||
if action == "list_container":
|
||
return jsonify(_do_list_container(db, parsed.get("container")))
|
||
if action == "list_place":
|
||
return jsonify(_do_list_place(db, parsed.get("place_path")))
|
||
return jsonify(_do_find(db, parsed.get("item") or text))
|
||
|
||
|
||
def _do_put(db, parsed):
|
||
"""Положить по разобранной фразе: места из фразы создаём на лету."""
|
||
if not (parsed.get("items") or parsed.get("item")):
|
||
return {"reply": "Не понял, что именно ты положил. Напиши: «положил X в коробку A3»."}
|
||
place_id = store.ensure_place_path(db, parsed.get("place_path"))
|
||
return _put_all(db, parsed, place_id)
|
||
|
||
|
||
def _put_all(db, parsed, place_id):
|
||
"""Общая часть записи — одна для кнопок и для свободной фразы."""
|
||
names = parsed.get("items") or ([parsed["item"]] if parsed.get("item") else [])
|
||
if not names:
|
||
return {"ok": False, "reply": "Не понял, что записать."}
|
||
|
||
saved, moved = [], []
|
||
|
||
for name in names:
|
||
known = store.find_item_by_name(db, name)
|
||
# Синонимы просим только для новых вещей: у известной они уже есть,
|
||
# а лишний запрос к модели — это лишние секунды ожидания при записи.
|
||
new_aliases = [] if known else brain.aliases(name, parsed.get("note"))
|
||
item, prev = store.put_item(
|
||
db, name,
|
||
# Количество относится к одной вещи; на список «паспорт и права»
|
||
# его вешать нельзя — получится «2 шт» у каждого.
|
||
qty=parsed.get("qty") if len(names) == 1 else None,
|
||
container=parsed.get("container"),
|
||
place_id=place_id,
|
||
note=parsed.get("note"),
|
||
aliases=new_aliases,
|
||
)
|
||
if item:
|
||
saved.append(item)
|
||
if prev:
|
||
moved.append((item, prev))
|
||
|
||
if not saved:
|
||
return {"ok": False, "reply": "Не понял, что записать."}
|
||
|
||
search.ensure_embeddings(db)
|
||
store.save(db)
|
||
|
||
address = store.item_address(db, saved[0])
|
||
listed = ", ".join("«%s»" % it["name"] for it in saved)
|
||
if moved and len(saved) == 1:
|
||
reply = "Переложил: %s теперь %s (было: %s)." % (listed, address, moved[0][1])
|
||
else:
|
||
reply = "Запомнил: %s — %s." % (listed, address)
|
||
if len(saved) == 1 and saved[0].get("qty"):
|
||
reply += " Количество: %s." % saved[0]["qty"]
|
||
|
||
return {"ok": True, "reply": reply, "kind": "put", "item": _card(db, saved[0]),
|
||
"items": [_card(db, it) for it in saved],
|
||
"aliases": saved[0].get("aliases") or []}
|
||
|
||
|
||
def _do_find(db, query):
|
||
hits = search.search(db, query)
|
||
if not hits:
|
||
return {"reply": "Про «%s» ничего не записано." % query, "kind": "find", "items": []}
|
||
|
||
cards = []
|
||
for item, score, why in hits:
|
||
card = _card(db, item)
|
||
card["score"] = round(score, 3)
|
||
card["why"] = why
|
||
cards.append(card)
|
||
|
||
top = cards[0]
|
||
reply = "«%s» — %s." % (top["name"], top["address"])
|
||
if top.get("qty"):
|
||
reply += " Количество: %s." % top["qty"]
|
||
if len(cards) > 1:
|
||
reply += " Есть ещё похожее — смотри ниже."
|
||
return {"reply": reply, "kind": "find", "items": cards}
|
||
|
||
|
||
def _do_list_container(db, code):
|
||
key = store.norm_code(code)
|
||
if not key or key not in db["containers"]:
|
||
return {"reply": "Коробку «%s» не знаю." % (code or "?"), "kind": "list", "items": []}
|
||
items = store.container_contents(db, key)
|
||
address = store.container_address(db, key)
|
||
common = {"kind": "list", "code": key, "photo": db["containers"][key].get("photo")}
|
||
if not items:
|
||
return dict(common, reply="%s — пока пусто." % address, items=[])
|
||
return dict(common, reply="%s — %d поз.:" % (address, len(items)),
|
||
items=[_card(db, it) for it in items])
|
||
|
||
|
||
def _do_list_place(db, path):
|
||
name = (path or [None])[-1]
|
||
place_id = store.find_place_by_name(db, name) if name else None
|
||
if not place_id:
|
||
return {"reply": "Место «%s» не знаю." % (name or "?"), "kind": "list", "items": []}
|
||
|
||
# Всё, что лежит в этом месте и в любом вложенном.
|
||
inside = _descendants(db, place_id)
|
||
boxes = [b["code"] for b in db["containers"].values() if b.get("place") in inside]
|
||
items = [it for it in db["items"].values()
|
||
if it.get("container") in boxes or it.get("place") in inside]
|
||
chain = " → ".join(store.place_chain(db, place_id))
|
||
if not items:
|
||
return {"reply": "%s — пока ничего не записано." % chain, "kind": "list", "items": []}
|
||
return {"reply": "%s — %d поз.:" % (chain, len(items)), "kind": "list",
|
||
"items": [_card(db, it) for it in items]}
|
||
|
||
|
||
def _descendants(db, place_id):
|
||
"""Место и все вложенные в него места."""
|
||
result = {place_id}
|
||
changed = True
|
||
while changed:
|
||
changed = False
|
||
for pid, place in db["places"].items():
|
||
if place.get("parent") in result and pid not in result:
|
||
result.add(pid)
|
||
changed = True
|
||
return result
|
||
|
||
|
||
def _card(db, item):
|
||
return {
|
||
"id": item["id"],
|
||
"name": item.get("name"),
|
||
"qty": item.get("qty"),
|
||
"note": item.get("note"),
|
||
"photo": item.get("photo"),
|
||
"aliases": item.get("aliases") or [],
|
||
"container": item.get("container"),
|
||
"state": item.get("state") or "stored",
|
||
"address": store.item_address(db, item),
|
||
"history": (item.get("history") or [])[-5:],
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# Действия кнопками: место → ёмкость → предмет
|
||
#
|
||
# Здесь ИИ не участвует в главном: место и коробку человек выбирает сам,
|
||
# и ошибиться разбором фразы уже негде. Модель нужна только для синонимов.
|
||
# ============================================================
|
||
@app.route("/api/places")
|
||
def api_places():
|
||
db = store.load()
|
||
return jsonify({"places": store.all_places(db)})
|
||
|
||
|
||
@app.route("/api/places", methods=["POST"])
|
||
def api_add_place():
|
||
data = request.get_json(silent=True) or {}
|
||
name = (data.get("name") or "").strip()
|
||
if not name:
|
||
return jsonify({"ok": False, "reply": "Название места пустое."}), 400
|
||
db = store.load()
|
||
parent = data.get("parent") or None
|
||
if parent and parent not in db["places"]:
|
||
return jsonify({"ok": False, "reply": "Родительское место не найдено."}), 404
|
||
chain = store.place_chain(db, parent) if parent else []
|
||
place_id = store.ensure_place_path(db, chain + [name])
|
||
store.save(db)
|
||
return jsonify({"ok": True, "id": place_id,
|
||
"path": " → ".join(store.place_chain(db, place_id))})
|
||
|
||
|
||
@app.route("/api/containers")
|
||
def api_containers():
|
||
db = store.load()
|
||
return jsonify({"containers": store.all_containers(db, request.args.get("place"))})
|
||
|
||
|
||
@app.route("/api/containers", methods=["POST"])
|
||
def api_add_container():
|
||
data = request.get_json(silent=True) or {}
|
||
code = store.norm_code(data.get("code"))
|
||
if not code:
|
||
return jsonify({"ok": False, "reply": "Код коробки пустой."}), 400
|
||
db = store.load()
|
||
store.ensure_container(db, code, kind=data.get("kind"), place_id=data.get("place") or None)
|
||
store.save(db)
|
||
return jsonify({"ok": True, "code": code, "address": store.container_address(db, code)})
|
||
|
||
|
||
@app.route("/api/put", methods=["POST"])
|
||
def api_put():
|
||
"""Положить: место и коробку выбрали кнопками, здесь только название вещи."""
|
||
data = request.get_json(silent=True) or {}
|
||
names = [n.strip() for n in (data.get("names") or [data.get("name") or ""]) if n.strip()]
|
||
if not names:
|
||
return jsonify({"ok": False, "reply": "Не указано, что кладём."}), 400
|
||
|
||
db = store.load()
|
||
place_id = data.get("place") or None
|
||
if place_id and place_id not in db["places"]:
|
||
return jsonify({"ok": False, "reply": "Место не найдено."}), 404
|
||
|
||
parsed = {"items": names, "qty": data.get("qty"), "container": data.get("container"),
|
||
"place_path": None, "note": data.get("note")}
|
||
result = _put_all(db, parsed, place_id)
|
||
return jsonify(result)
|
||
|
||
|
||
@app.route("/api/take", methods=["POST"])
|
||
def api_take():
|
||
"""Забрать вещь из коробки — теперь она на руках."""
|
||
data = request.get_json(silent=True) or {}
|
||
db = store.load()
|
||
item = _resolve(db, data)
|
||
if item is None:
|
||
return jsonify({"ok": False, "reply": "Не понял, какую вещь забрать."}), 404
|
||
if isinstance(item, list):
|
||
return jsonify({"ok": False, "reply": "Уточни, какую именно:", "items": item})
|
||
|
||
was = store.take_item(db, item)
|
||
store.save(db)
|
||
return jsonify({"ok": True, "kind": "take", "item": _card(db, item),
|
||
"reply": "Забрал «%s» (было: %s). Отметил, что на руках."
|
||
% (item["name"], was)})
|
||
|
||
|
||
@app.route("/api/move", methods=["POST"])
|
||
def api_move():
|
||
"""Переместить вещь в другое место или коробку."""
|
||
data = request.get_json(silent=True) or {}
|
||
db = store.load()
|
||
item = _resolve(db, data)
|
||
if item is None:
|
||
return jsonify({"ok": False, "reply": "Не понял, какую вещь перемещаем."}), 404
|
||
if isinstance(item, list):
|
||
return jsonify({"ok": False, "reply": "Уточни, какую именно:", "items": item})
|
||
|
||
place_id = data.get("place") or None
|
||
container = data.get("container")
|
||
if not place_id and not container:
|
||
return jsonify({"ok": False, "reply": "Не указано, куда перемещать."}), 400
|
||
|
||
moved, prev = store.put_item(db, item["name"], container=container, place_id=place_id)
|
||
store.save(db)
|
||
address = store.item_address(db, moved)
|
||
reply = ("Переместил «%s»: теперь %s (было: %s)." % (moved["name"], address, prev)
|
||
if prev else "«%s» теперь %s." % (moved["name"], address))
|
||
return jsonify({"ok": True, "kind": "move", "item": _card(db, moved), "reply": reply})
|
||
|
||
|
||
@app.route("/api/items")
|
||
def api_items():
|
||
"""Список вещей для выбора: все или подходящие под запрос."""
|
||
db = store.load()
|
||
query = (request.args.get("q") or "").strip()
|
||
if query:
|
||
items = [it for it, _, _ in search.search(db, query, top_k=20)]
|
||
else:
|
||
items = sorted(db["items"].values(), key=lambda i: (i.get("name") or "").lower())
|
||
return jsonify({"items": [_card(db, it) for it in items[:50]]})
|
||
|
||
|
||
def _resolve(db, data):
|
||
"""Находит вещь по id или по названию.
|
||
|
||
None — не нашли; список — нашли несколько и нужно уточнение;
|
||
иначе сам предмет.
|
||
"""
|
||
item_id = (data.get("id") or "").strip()
|
||
if item_id:
|
||
return db["items"].get(item_id)
|
||
|
||
name = (data.get("name") or "").strip()
|
||
if not name:
|
||
return None
|
||
exact = store.find_item_by_name(db, name)
|
||
if exact:
|
||
return exact
|
||
hits = search.search(db, name, top_k=5)
|
||
if not hits:
|
||
return None
|
||
if len(hits) == 1:
|
||
return hits[0][0]
|
||
return [_card(db, it) for it, _, _ in hits]
|
||
|
||
|
||
# ============================================================
|
||
# Просмотр
|
||
# ============================================================
|
||
@app.route("/api/find")
|
||
def api_find():
|
||
query = (request.args.get("q") or "").strip()
|
||
if not query:
|
||
return jsonify({"items": []})
|
||
db = store.load()
|
||
return jsonify(_do_find(db, query))
|
||
|
||
|
||
@app.route("/api/tree")
|
||
def api_tree():
|
||
return jsonify(store.tree(store.load()))
|
||
|
||
|
||
@app.route("/api/container/<code>")
|
||
def api_container(code):
|
||
return jsonify(_do_list_container(store.load(), code))
|
||
|
||
|
||
@app.route("/api/item/<item_id>", methods=["DELETE"])
|
||
def api_delete_item(item_id):
|
||
db = store.load()
|
||
item = db["items"].get(item_id)
|
||
if not item:
|
||
return jsonify({"ok": False, "reply": "Такой записи нет."}), 404
|
||
_drop_photo_file(store.detach_photo(item))
|
||
store.remove_item(db, item_id)
|
||
store.save(db)
|
||
return jsonify({"ok": True, "reply": "Удалил «%s»." % item.get("name")})
|
||
|
||
|
||
# ============================================================
|
||
# Правка. Каждая сущность — предмет, ёмкость, место — правится и удаляется.
|
||
# ============================================================
|
||
@app.route("/api/item/<item_id>", methods=["PATCH"])
|
||
def api_edit_item(item_id):
|
||
db = store.load()
|
||
item = db["items"].get(item_id)
|
||
if not item:
|
||
return jsonify({"ok": False, "reply": "Такой записи нет."}), 404
|
||
|
||
fields = request.get_json(silent=True) or {}
|
||
try:
|
||
changed = store.update_item(db, item, fields)
|
||
except ValueError as e:
|
||
return jsonify({"ok": False, "reply": str(e)}), 400
|
||
|
||
if not changed:
|
||
return jsonify({"ok": True, "item": _card(db, item), "reply": "Менять нечего."})
|
||
search.ensure_embeddings(db)
|
||
store.save(db)
|
||
return jsonify({"ok": True, "item": _card(db, item),
|
||
"reply": "Обновил: %s." % ", ".join(changed)})
|
||
|
||
|
||
@app.route("/api/containers/<code>", methods=["PATCH"])
|
||
def api_edit_container(code):
|
||
db = store.load()
|
||
if store.norm_code(code) not in db["containers"]:
|
||
return jsonify({"ok": False, "reply": "Такой коробки нет."}), 404
|
||
try:
|
||
key, changed = store.update_container(db, code, request.get_json(silent=True) or {})
|
||
except ValueError as e:
|
||
return jsonify({"ok": False, "reply": str(e)}), 400
|
||
|
||
if not changed:
|
||
return jsonify({"ok": True, "code": key, "reply": "Менять нечего."})
|
||
store.save(db)
|
||
return jsonify({"ok": True, "code": key, "address": store.container_address(db, key),
|
||
"reply": "Обновил коробку %s: %s." % (key, ", ".join(changed))})
|
||
|
||
|
||
@app.route("/api/containers/<code>", methods=["DELETE"])
|
||
def api_delete_container(code):
|
||
db = store.load()
|
||
key = store.norm_code(code)
|
||
box = db["containers"].get(key)
|
||
if not box:
|
||
return jsonify({"ok": False, "reply": "Такой коробки нет."}), 404
|
||
|
||
_drop_photo_file(store.detach_photo(box))
|
||
_, moved = store.delete_container(db, key)
|
||
store.save(db)
|
||
reply = "Коробка %s удалена." % key
|
||
if moved:
|
||
where = " → ".join(store.place_chain(db, box.get("place"))) or "без места"
|
||
reply += " Вещи не потерялись (%d): теперь они просто %s — %s." % (
|
||
len(moved), where, ", ".join(moved))
|
||
return jsonify({"ok": True, "reply": reply, "moved": moved})
|
||
|
||
|
||
@app.route("/api/places/<place_id>", methods=["PATCH"])
|
||
def api_edit_place(place_id):
|
||
db = store.load()
|
||
if place_id not in db["places"]:
|
||
return jsonify({"ok": False, "reply": "Такого места нет."}), 404
|
||
try:
|
||
changed = store.update_place(db, place_id, request.get_json(silent=True) or {})
|
||
except ValueError as e:
|
||
return jsonify({"ok": False, "reply": str(e)}), 400
|
||
|
||
if not changed:
|
||
return jsonify({"ok": True, "reply": "Менять нечего."})
|
||
store.save(db)
|
||
return jsonify({"ok": True, "path": " → ".join(store.place_chain(db, place_id)),
|
||
"reply": "Обновил место: %s." % ", ".join(changed)})
|
||
|
||
|
||
@app.route("/api/places/<place_id>", methods=["DELETE"])
|
||
def api_delete_place(place_id):
|
||
db = store.load()
|
||
place = db["places"].get(place_id)
|
||
if not place:
|
||
return jsonify({"ok": False, "reply": "Такого места нет."}), 404
|
||
|
||
# Непустое место не удаляем: перенести содержимое некуда, если это корень,
|
||
# а молча выбросить записи о вещах нельзя — в них весь смысл.
|
||
blockers = store.place_blockers(db, place_id)
|
||
if any(blockers.values()):
|
||
parts = []
|
||
if blockers["places"]:
|
||
parts.append("места: " + ", ".join(blockers["places"]))
|
||
if blockers["containers"]:
|
||
parts.append("коробки: " + ", ".join(blockers["containers"]))
|
||
if blockers["items"]:
|
||
parts.append("вещи: " + ", ".join(blockers["items"]))
|
||
return jsonify({"ok": False, "blockers": blockers,
|
||
"reply": "Сначала разбери, что внутри «%s» — %s."
|
||
% (place.get("name"), "; ".join(parts))}), 409
|
||
|
||
store.delete_place(db, place_id)
|
||
store.save(db)
|
||
return jsonify({"ok": True, "reply": "Место «%s» удалено." % place.get("name")})
|
||
|
||
|
||
# ============================================================
|
||
# Удаление фото
|
||
# ============================================================
|
||
def _drop_photo_file(filename):
|
||
"""Убирает файл снимка с диска, чтобы папка не копила мусор."""
|
||
if not filename:
|
||
return
|
||
path = PHOTOS_DIR / filename
|
||
try:
|
||
if path.exists():
|
||
path.unlink()
|
||
except Exception as e:
|
||
logger.warning("Не удалось удалить фото %s: %s", filename, e)
|
||
|
||
|
||
@app.route("/api/photo", methods=["DELETE"])
|
||
def api_delete_photo():
|
||
data = request.get_json(silent=True) or {}
|
||
db = store.load()
|
||
target = data.get("target")
|
||
|
||
if target == "container":
|
||
entity = db["containers"].get(store.norm_code(data.get("code")))
|
||
elif target == "item":
|
||
entity = db["items"].get(data.get("id") or "")
|
||
else:
|
||
return jsonify({"ok": False, "reply": "Не указано, у чего убрать фото."}), 400
|
||
|
||
if not entity:
|
||
return jsonify({"ok": False, "reply": "Не найдено."}), 404
|
||
if not entity.get("photo"):
|
||
return jsonify({"ok": True, "reply": "Фото и так нет."})
|
||
|
||
_drop_photo_file(store.detach_photo(entity))
|
||
store.save(db)
|
||
return jsonify({"ok": True, "reply": "Фото убрал."})
|
||
|
||
|
||
# ============================================================
|
||
# Фото
|
||
# ============================================================
|
||
@app.route("/api/photo", methods=["POST"])
|
||
def api_photo():
|
||
upload = request.files.get("photo")
|
||
if not upload or not upload.filename:
|
||
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
|
||
|
||
# Имя файла придумываем сами: имя от клиента может содержать «../»
|
||
# и увести запись за пределы папки с фотографиями.
|
||
ext = ("." + upload.filename.rsplit(".", 1)[-1].lower()) if "." in upload.filename else ""
|
||
if ext not in ALLOWED_PHOTO_EXT:
|
||
return jsonify({"ok": False, "reply": "Такой формат не принимаю: %s" % (ext or "?")}), 400
|
||
|
||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||
filename = uuid.uuid4().hex[:12] + ext
|
||
upload.save(str(PHOTOS_DIR / filename))
|
||
|
||
db = store.load()
|
||
target = request.form.get("target")
|
||
if target == "container":
|
||
key = store.norm_code(request.form.get("code"))
|
||
if key not in db["containers"]:
|
||
return jsonify({"ok": False, "reply": "Коробка не найдена."}), 404
|
||
db["containers"][key]["photo"] = filename
|
||
elif target == "item":
|
||
item = db["items"].get(request.form.get("id") or "")
|
||
if not item:
|
||
return jsonify({"ok": False, "reply": "Предмет не найден."}), 404
|
||
item["photo"] = filename
|
||
else:
|
||
return jsonify({"ok": False, "reply": "Не указано, к чему крепить фото."}), 400
|
||
|
||
store.save(db)
|
||
return jsonify({"ok": True, "photo": filename, "reply": "Фото прикрепил."})
|
||
|
||
|
||
# ============================================================
|
||
# Запуск
|
||
# ============================================================
|
||
def local_ips():
|
||
"""Адреса этого компьютера в локальной сети — их вводят на телефоне.
|
||
|
||
Первым идёт тот, по которому машина реально выходит в сеть. Это важно:
|
||
на этом компьютере шесть адресов (WSL, VMware, VPN), и угадать среди них
|
||
нужный невозможно.
|
||
|
||
Определяем здесь, а не в start.cmd: парсинг ipconfig ломается о
|
||
локализованные названия полей, а батник вообще не может содержать
|
||
кириллицу без порчи кодировки.
|
||
"""
|
||
import socket
|
||
found = []
|
||
# UDP-сокет никуда не отправляет данных: connect лишь заставляет систему
|
||
# выбрать исходящий интерфейс — то есть основной адрес машины.
|
||
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
try:
|
||
# Адрес произвольный и внешний: пакетов не уходит, важен лишь выбор
|
||
# маршрута. Внешний, чтобы не привязываться к чужой подсети.
|
||
probe.connect(("8.8.8.8", 9))
|
||
found.append(probe.getsockname()[0])
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
probe.close()
|
||
|
||
try:
|
||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||
address = info[4][0]
|
||
if not address.startswith(("127.", "169.254.")) and address not in found:
|
||
found.append(address)
|
||
except Exception as e:
|
||
logger.warning("Не удалось определить адреса: %s", e)
|
||
return found
|
||
|
||
|
||
def main():
|
||
logging.basicConfig(level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s")
|
||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
alive = ollama.check()
|
||
logger.info("Ollama %s: %s", config.OLLAMA_HOST, "доступен" if alive else "НЕ отвечает")
|
||
if not alive:
|
||
logger.warning("Без Ollama работает запись, обзор и поиск по названию. "
|
||
"Разбор фраз — упрощённый, поиск по смыслу отключён.")
|
||
|
||
addresses = local_ips()
|
||
if addresses:
|
||
logger.info("С ТЕЛЕФОНА: http://%s:%d", addresses[0], PORT)
|
||
if len(addresses) > 1:
|
||
logger.info("Если не открылось, другие адреса этого ПК: %s",
|
||
", ".join("http://%s:%d" % (a, PORT) for a in addresses[1:]))
|
||
logger.info("Здесь же: http://localhost:%d", PORT)
|
||
|
||
threading.Timer(1.0, lambda: webbrowser.open("http://localhost:%d" % PORT)).start()
|
||
app.run(host=HOST, port=PORT, debug=False, threaded=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|