diff --git a/Dockerfile.backend b/Dockerfile.backend index 57a9735..9fd8059 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,14 +1,19 @@ +# 分层构建:依赖与代码分离,仅代码变更时只重建最后一层,加快迭代 FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11-slim ENV MODULE_NAME=backend.app.main ENV VARIABLE_NAME=app ENV PORT=8000 ENV HOST=0.0.0.0 +# SQLite 对并发写不友好,单 worker 避免多进程竞争 +ENV WEB_CONCURRENCY=1 WORKDIR /app +# 依赖层:只有 requirements 变更时才重建 COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt +# 代码层:仅此层会随业务代码变更而重建 COPY backend /app/backend diff --git a/backend.log b/backend.log new file mode 100644 index 0000000..a42ecb9 --- /dev/null +++ b/backend.log @@ -0,0 +1,901 @@ +INFO: Will watch for changes in these directories: ['/Users/dannier/Desktop/living/AiTool'] +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: Started reloader process [85187] using WatchFiles +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/string/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/conftest.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_where.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_constructors.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_pickle.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_repr.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_reduction.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_concat.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_reshape.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_dtypes.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_setops.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_comparison.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_function.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_formats.py'. Reloading... +Process SpawnProcess-1: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/_libs/window/__init__.py'. Reloading... +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_freq_attr.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_setops.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_legend.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_arithmetic.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_delete.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_timedelta_range.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_formats.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_searchsorted.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/string/test_astype.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_ops.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_timedelta.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_subplots.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_scalar_compat.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/string/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_constructors.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_join.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_pickle.py', '.venv/lib/python3.9/site-packages/pandas/_libs/tslibs/__init__.py'. Reloading... +Process SpawnProcess-3: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_categorical.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_api.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_apply_mutate.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_groupby_dropna.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_groupby.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_color.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_numba.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_grouping.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_libgroupby.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_timegrouper.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_reductions.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_numeric_only.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_all_methods.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_apply.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_index_as_string.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_pipe.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/conftest.py'. Reloading... +Process SpawnProcess-4: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/arrays/numpy_/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_groupby_subclass.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_groupby.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_counting.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/multi/test_constructors.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_construction.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/numpy_/test_numpy.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_filters.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexing/multiindex/test_chaining_and_caching.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_raises.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_cumulative.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_equals.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/numpy_/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_missing.py'. Reloading... +Process SpawnProcess-5: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_fillna.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_insert.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_is_monotonic.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/transform/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_astype.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_corrwith.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_sample.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/transform/test_numba.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_arithmetic.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_value_counts.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_quantile.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_skew.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/transform/test_transform.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_cython.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_describe.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_size.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_factorize.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_other.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_numba.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_nth.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_aggregate.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_groupby_shift_diff.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_rank.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_nlargest_nsmallest.py', '.venv/lib/python3.9/site-packages/pandas/tests/tslibs/test_fields.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_repeat.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_shift.py'. Reloading... +Process SpawnProcess-6: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/attr/_version_info.py', '.venv/lib/python3.9/site-packages/jsonschema_specifications/_core.py', '.venv/lib/python3.9/site-packages/mdurl/__init__.py', '.venv/lib/python3.9/site-packages/attr/_next_gen.py', '.venv/lib/python3.9/site-packages/mdurl/_encode.py', '.venv/lib/python3.9/site-packages/attr/exceptions.py', '.venv/lib/python3.9/site-packages/mdurl/_parse.py', '.venv/lib/python3.9/site-packages/mdurl/_url.py', '.venv/lib/python3.9/site-packages/attr/__init__.py', '.venv/lib/python3.9/site-packages/mdurl/_format.py', '.venv/lib/python3.9/site-packages/attr/_compat.py', '.venv/lib/python3.9/site-packages/attr/converters.py', '.venv/lib/python3.9/site-packages/attr/_funcs.py', '.venv/lib/python3.9/site-packages/attr/validators.py', '.venv/lib/python3.9/site-packages/attr/filters.py', '.venv/lib/python3.9/site-packages/attr/_make.py', '.venv/lib/python3.9/site-packages/attr/setters.py', '.venv/lib/python3.9/site-packages/attr/_config.py', '.venv/lib/python3.9/site-packages/six.py', '.venv/lib/python3.9/site-packages/jsonschema_specifications/__init__.py', '.venv/lib/python3.9/site-packages/mdurl/_decode.py', '.venv/lib/python3.9/site-packages/rpds/__init__.py'. Reloading... +Process SpawnProcess-7: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +Process SpawnProcess-8: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/_config/dates.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_bin_groupby.py', '.venv/lib/python3.9/site-packages/mako/codegen.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/indexing/test_getitem.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_size.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_diff.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_hist_box_by.py', '.venv/lib/python3.9/site-packages/pandas/tests/construction/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/object/test_astype.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_to_numpy.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/__init__.py', '.venv/lib/python3.9/site-packages/mako/exceptions.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/indexing/test_where.py', '.venv/lib/python3.9/site-packages/pandas/_config/localization.py', '.venv/lib/python3.9/site-packages/mako/lexer.py'. Reloading... +Process SpawnProcess-9: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/api/indexers/__init__.py', '.venv/lib/python3.9/site-packages/pandas/errors/__init__.py', '.venv/lib/python3.9/site-packages/lxml/ElementInclude.py'. Reloading... +Process SpawnProcess-10: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/httpx/_content.py', '.venv/lib/python3.9/site-packages/mako/pyparser.py', '.venv/lib/python3.9/site-packages/mako/parsetree.py', '.venv/lib/python3.9/site-packages/mako/testing/_config.py', '.venv/lib/python3.9/site-packages/mako/_ast_util.py', '.venv/lib/python3.9/site-packages/lxml/sax.py', '.venv/lib/python3.9/site-packages/lxml/html/usedoctest.py', '.venv/lib/python3.9/site-packages/mako/testing/config.py', '.venv/lib/python3.9/site-packages/mako/testing/exclusions.py', '.venv/lib/python3.9/site-packages/mako/pygen.py', '.venv/lib/python3.9/site-packages/mako/__init__.py', '.venv/lib/python3.9/site-packages/mako/testing/__init__.py', '.venv/lib/python3.9/site-packages/mako/filters.py', '.venv/lib/python3.9/site-packages/mako/testing/fixtures.py', '.venv/lib/python3.9/site-packages/httpx/_types.py', '.venv/lib/python3.9/site-packages/httpx/_decoders.py', '.venv/lib/python3.9/site-packages/mako/cmd.py', '.venv/lib/python3.9/site-packages/mako/testing/assertions.py', '.venv/lib/python3.9/site-packages/httpx/_client.py', '.venv/lib/python3.9/site-packages/httpx/_urlparse.py', '.venv/lib/python3.9/site-packages/lxml/html/_setmixin.py', '.venv/lib/python3.9/site-packages/mako/lookup.py', '.venv/lib/python3.9/site-packages/mako/cache.py', '.venv/lib/python3.9/site-packages/httpx/_config.py', '.venv/lib/python3.9/site-packages/httptools/__init__.py', '.venv/lib/python3.9/site-packages/httpx/_models.py', '.venv/lib/python3.9/site-packages/lxml/html/defs.py', '.venv/lib/python3.9/site-packages/httpx/__init__.py', '.venv/lib/python3.9/site-packages/mako/testing/helpers.py', '.venv/lib/python3.9/site-packages/mako/util.py', '.venv/lib/python3.9/site-packages/lxml/html/_difflib.py', '.venv/lib/python3.9/site-packages/mako/runtime.py', '.venv/lib/python3.9/site-packages/httpx/_status_codes.py', '.venv/lib/python3.9/site-packages/mako/template.py', '.venv/lib/python3.9/site-packages/mako/ast.py', '.venv/lib/python3.9/site-packages/mako/compat.py', '.venv/lib/python3.9/site-packages/lxml/doctestcompare.py', '.venv/lib/python3.9/site-packages/lxml/pyclasslookup.py', '.venv/lib/python3.9/site-packages/httpx/_auth.py'. Reloading... +Process SpawnProcess-11: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/mako/ext/preprocessors.py', '.venv/lib/python3.9/site-packages/mako/ext/__init__.py', '.venv/lib/python3.9/site-packages/lxml/_elementpath.py', '.venv/lib/python3.9/site-packages/mako/ext/autohandler.py', '.venv/lib/python3.9/site-packages/lxml/html/soupparser.py', '.venv/lib/python3.9/site-packages/lxml/html/html5parser.py', '.venv/lib/python3.9/site-packages/mako/ext/extract.py', '.venv/lib/python3.9/site-packages/lxml/html/ElementSoup.py', '.venv/lib/python3.9/site-packages/cachetools/func.py', '.venv/lib/python3.9/site-packages/cachetools/keys.py', '.venv/lib/python3.9/site-packages/mako/ext/beaker_cache.py', '.venv/lib/python3.9/site-packages/mako/ext/babelplugin.py', '.venv/lib/python3.9/site-packages/cachetools/_decorators.py', '.venv/lib/python3.9/site-packages/mako/ext/pygmentplugin.py', '.venv/lib/python3.9/site-packages/lxml/html/formfill.py', '.venv/lib/python3.9/site-packages/lxml/html/clean.py', '.venv/lib/python3.9/site-packages/jiter/__init__.py', '.venv/lib/python3.9/site-packages/httptools/parser/protocol.py', '.venv/lib/python3.9/site-packages/mako/ext/turbogears.py', '.venv/lib/python3.9/site-packages/mako/ext/linguaplugin.py', '.venv/lib/python3.9/site-packages/lxml/html/_diffcommand.py', '.venv/lib/python3.9/site-packages/cachetools/__init__.py', '.venv/lib/python3.9/site-packages/lxml/html/__init__.py', '.venv/lib/python3.9/site-packages/lxml/html/diff.py'. Reloading... +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/altair/theme.py'. Reloading... +Process SpawnProcess-12: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/lxml/usedoctest.py', '.venv/lib/python3.9/site-packages/altair/typing/__init__.py', '.venv/lib/python3.9/site-packages/lxml/includes/extlibs/__init__.py', '.venv/lib/python3.9/site-packages/httptools/parser/errors.py', '.venv/lib/python3.9/site-packages/httpx/_exceptions.py', '.venv/lib/python3.9/site-packages/lxml/isoschematron/__init__.py', '.venv/lib/python3.9/site-packages/lxml/__init__.py'. Reloading... +Process SpawnProcess-13: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +Process SpawnProcess-14: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/httpx/_multipart.py', '.venv/lib/python3.9/site-packages/httpx/_main.py', '.venv/lib/python3.9/site-packages/lxml/builder.py', '.venv/lib/python3.9/site-packages/httptools/parser/__init__.py', '.venv/lib/python3.9/site-packages/lxml/cssselect.py', '.venv/lib/python3.9/site-packages/httpx/_api.py', '.venv/lib/python3.9/site-packages/httpx/__version__.py', '.venv/lib/python3.9/site-packages/httpx/_utils.py', '.venv/lib/python3.9/site-packages/lxml/includes/__init__.py', '.venv/lib/python3.9/site-packages/lxml/html/_html5builder.py', '.venv/lib/python3.9/site-packages/httpx/_compat.py'. Reloading... +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/sjisprober.py', '.venv/lib/python3.9/site-packages/chardet/jisfreq.py', '.venv/lib/python3.9/site-packages/chardet/resultdict.py', '.venv/lib/python3.9/site-packages/chardet/sbcharsetprober.py', '.venv/lib/python3.9/site-packages/chardet/escprober.py', '.venv/lib/python3.9/site-packages/chardet/version.py', '.venv/lib/python3.9/site-packages/lxml/includes/libexslt/__init__.py', '.venv/lib/python3.9/site-packages/chardet/langgreekmodel.py', '.venv/lib/python3.9/site-packages/chardet/codingstatemachine.py', '.venv/lib/python3.9/site-packages/lxml/includes/libxslt/__init__.py', '.venv/lib/python3.9/site-packages/altair/vegalite/__init__.py'. Reloading... +Process SpawnProcess-15: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +Process SpawnProcess-16: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/httpx/_urls.py', '.venv/lib/python3.9/site-packages/streamlit/error_util.py', '.venv/lib/python3.9/site-packages/lxml/html/builder.py', '.venv/lib/python3.9/site-packages/git/index/fun.py', '.venv/lib/python3.9/site-packages/chardet/universaldetector.py', '.venv/lib/python3.9/site-packages/streamlit/__init__.py', '.venv/lib/python3.9/site-packages/httptools/_version.py', '.venv/lib/python3.9/site-packages/chardet/big5prober.py', '.venv/lib/python3.9/site-packages/altair/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/url_util.py', '.venv/lib/python3.9/site-packages/chardet/langrussianmodel.py', '.venv/lib/python3.9/site-packages/altair/_magics.py'. Reloading... +Process SpawnProcess-17: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/utf8prober.py', '.venv/lib/python3.9/site-packages/chardet/latin1prober.py', '.venv/lib/python3.9/site-packages/altair/utils/plugin_registry.py', '.venv/lib/python3.9/site-packages/altair/utils/__init__.py', '.venv/lib/python3.9/site-packages/altair/utils/mimebundle.py', '.venv/lib/python3.9/site-packages/chardet/charsetprober.py', '.venv/lib/python3.9/site-packages/chardet/jpcntx.py', '.venv/lib/python3.9/site-packages/chardet/gb2312freq.py', '.venv/lib/python3.9/site-packages/chardet/langhebrewmodel.py', '.venv/lib/python3.9/site-packages/altair/expr/core.py', '.venv/lib/python3.9/site-packages/altair/utils/html.py', '.venv/lib/python3.9/site-packages/chardet/__init__.py', '.venv/lib/python3.9/site-packages/chardet/mbcharsetprober.py', '.venv/lib/python3.9/site-packages/altair/utils/display.py', '.venv/lib/python3.9/site-packages/httpx/_transports/wsgi.py', '.venv/lib/python3.9/site-packages/altair/utils/selection.py', '.venv/lib/python3.9/site-packages/altair/expr/consts.py', '.venv/lib/python3.9/site-packages/altair/jupyter/jupyter_chart.py', '.venv/lib/python3.9/site-packages/altair/utils/server.py', '.venv/lib/python3.9/site-packages/altair/expr/__init__.py', '.venv/lib/python3.9/site-packages/httpx/_transports/asgi.py', '.venv/lib/python3.9/site-packages/httpx/_transports/default.py', '.venv/lib/python3.9/site-packages/altair/utils/deprecation.py', '.venv/lib/python3.9/site-packages/altair/utils/_transformed_data.py', '.venv/lib/python3.9/site-packages/chardet/__main__.py', '.venv/lib/python3.9/site-packages/chardet/big5freq.py', '.venv/lib/python3.9/site-packages/chardet/langhungarianmodel.py', '.venv/lib/python3.9/site-packages/httpx/_transports/__init__.py', '.venv/lib/python3.9/site-packages/chardet/euckrprober.py', '.venv/lib/python3.9/site-packages/chardet/langthaimodel.py', '.venv/lib/python3.9/site-packages/chardet/gb2312prober.py', '.venv/lib/python3.9/site-packages/altair/utils/schemapi.py', '.venv/lib/python3.9/site-packages/altair/utils/_dfi_types.py', '.venv/lib/python3.9/site-packages/chardet/johabprober.py', '.venv/lib/python3.9/site-packages/chardet/euctwfreq.py', '.venv/lib/python3.9/site-packages/chardet/euctwprober.py', '.venv/lib/python3.9/site-packages/altair/jupyter/__init__.py', '.venv/lib/python3.9/site-packages/altair/utils/_importers.py', '.venv/lib/python3.9/site-packages/chardet/charsetgroupprober.py', '.venv/lib/python3.9/site-packages/httpx/_transports/base.py', '.venv/lib/python3.9/site-packages/altair/utils/compiler.py', '.venv/lib/python3.9/site-packages/altair/utils/_vegafusion_data.py', '.venv/lib/python3.9/site-packages/chardet/sbcsgroupprober.py', '.venv/lib/python3.9/site-packages/altair/utils/data.py', '.venv/lib/python3.9/site-packages/altair/expr/funcs.py', '.venv/lib/python3.9/site-packages/chardet/euckrfreq.py', '.venv/lib/python3.9/site-packages/chardet/escsm.py', '.venv/lib/python3.9/site-packages/altair/utils/execeval.py', '.venv/lib/python3.9/site-packages/altair/utils/save.py', '.venv/lib/python3.9/site-packages/chardet/langturkishmodel.py', '.venv/lib/python3.9/site-packages/altair/utils/core.py', '.venv/lib/python3.9/site-packages/altair/utils/_show.py', '.venv/lib/python3.9/site-packages/chardet/johabfreq.py', '.venv/lib/python3.9/site-packages/httpx/_transports/mock.py', '.venv/lib/python3.9/site-packages/chardet/langbulgarianmodel.py', '.venv/lib/python3.9/site-packages/chardet/utf1632prober.py'. Reloading... +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/codingstatemachinedict.py', '.venv/lib/python3.9/site-packages/git/config.py', '.venv/lib/python3.9/site-packages/chardet/macromanprober.py', '.venv/lib/python3.9/site-packages/et_xmlfile/incremental_tree.py', '.venv/lib/python3.9/site-packages/chardet/chardistribution.py', '.venv/lib/python3.9/site-packages/altair/vegalite/api.py', '.venv/lib/python3.9/site-packages/distro/distro.py', '.venv/lib/python3.9/site-packages/et_xmlfile/__init__.py', '.venv/lib/python3.9/site-packages/chardet/mbcssm.py', '.venv/lib/python3.9/site-packages/distro/__init__.py', '.venv/lib/python3.9/site-packages/chardet/cp949prober.py', '.venv/lib/python3.9/site-packages/altair/vegalite/data.py', '.venv/lib/python3.9/site-packages/altair/vegalite/schema.py', '.venv/lib/python3.9/site-packages/distro/__main__.py', '.venv/lib/python3.9/site-packages/chardet/enums.py', '.venv/lib/python3.9/site-packages/altair/vegalite/display.py', '.venv/lib/python3.9/site-packages/chardet/mbcsgroupprober.py', '.venv/lib/python3.9/site-packages/et_xmlfile/xmlfile.py', '.venv/lib/python3.9/site-packages/chardet/eucjpprober.py', '.venv/lib/python3.9/site-packages/chardet/hebrewprober.py'. Reloading... +Process SpawnProcess-18: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/git/__init__.py', '.venv/lib/python3.9/site-packages/git/types.py', '.venv/lib/python3.9/site-packages/git/diff.py', '.venv/lib/python3.9/site-packages/git/cmd.py', '.venv/lib/python3.9/site-packages/git/exc.py', '.venv/lib/python3.9/site-packages/git/util.py', '.venv/lib/python3.9/site-packages/lxml/includes/libxml/__init__.py', '.venv/lib/python3.9/site-packages/git/remote.py', '.venv/lib/python3.9/site-packages/git/compat.py', '.venv/lib/python3.9/site-packages/git/db.py'. Reloading... +Process SpawnProcess-19: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +Process SpawnProcess-20: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/metadata/__init__.py', '.venv/lib/python3.9/site-packages/chardet/metadata/languages.py', '.venv/lib/python3.9/site-packages/git/refs/symbolic.py', '.venv/lib/python3.9/site-packages/git/refs/reference.py', '.venv/lib/python3.9/site-packages/git/repo/fun.py', '.venv/lib/python3.9/site-packages/git/refs/remote.py', '.venv/lib/python3.9/site-packages/git/repo/base.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/__init__.py', '.venv/lib/python3.9/site-packages/git/refs/head.py', '.venv/lib/python3.9/site-packages/git/objects/blob.py', '.venv/lib/python3.9/site-packages/git/objects/tag.py', '.venv/lib/python3.9/site-packages/chardet/cli/chardetect.py', '.venv/lib/python3.9/site-packages/git/refs/log.py', '.venv/lib/python3.9/site-packages/git/refs/tag.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/theme.py', '.venv/lib/python3.9/site-packages/git/objects/__init__.py', '.venv/lib/python3.9/site-packages/chardet/cli/__init__.py', '.venv/lib/python3.9/site-packages/git/refs/__init__.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/api.py', '.venv/lib/python3.9/site-packages/git/objects/fun.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/channels.py', '.venv/lib/python3.9/site-packages/git/objects/base.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/data.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/compiler.py', '.venv/lib/python3.9/site-packages/git/objects/commit.py', '.venv/lib/python3.9/site-packages/git/objects/tree.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/display.py', '.venv/lib/python3.9/site-packages/git/repo/__init__.py', '.venv/lib/python3.9/site-packages/git/objects/util.py'. Reloading... +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/git/index/__init__.py', '.venv/lib/python3.9/site-packages/blinker/base.py', '.venv/lib/python3.9/site-packages/git/index/typ.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/core.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/mixins.py', '.venv/lib/python3.9/site-packages/git/index/base.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/_typing.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/__init__.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/_config.py', '.venv/lib/python3.9/site-packages/git/index/util.py'. Reloading... +Process SpawnProcess-21: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/streamlit/elements/exception.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/subtitle_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/streamlit_plotly_theme.py', '.venv/lib/python3.9/site-packages/streamlit/config_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/options_selector_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/code.py', '.venv/lib/python3.9/site-packages/streamlit/elements/balloons.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/mutable_status_container.py', '.venv/lib/python3.9/site-packages/streamlit/elements/write.py', '.venv/lib/python3.9/site-packages/streamlit/hello/mapping_demo.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/policies.py', '.venv/lib/python3.9/site-packages/streamlit/development.py', '.venv/lib/python3.9/site-packages/streamlit/type_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/form_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/vega_charts.py', '.venv/lib/python3.9/site-packages/streamlit/navigation/page.py', '.venv/lib/python3.9/site-packages/streamlit/elements/plotly_chart.py', '.venv/lib/python3.9/site-packages/streamlit/hello/utils.py', '.venv/lib/python3.9/site-packages/streamlit/external/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/heading.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/js_number.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/column_config_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/alert.py', '.venv/lib/python3.9/site-packages/streamlit/time_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/pyplot.py', '.venv/lib/python3.9/site-packages/streamlit/hello/dataframe_demo.py', '.venv/lib/python3.9/site-packages/streamlit/elements/snow.py', '.venv/lib/python3.9/site-packages/streamlit/delta_generator_singletons.py', '.venv/lib/python3.9/site-packages/streamlit/elements/arrow.py', '.venv/lib/python3.9/site-packages/streamlit/elements/form.py', '.venv/lib/python3.9/site-packages/streamlit/deprecation_util.py', '.venv/lib/python3.9/site-packages/streamlit/temporary_directory.py', '.venv/lib/python3.9/site-packages/blinker/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/hello/plotting_demo.py', '.venv/lib/python3.9/site-packages/streamlit/cursor.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/color_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/dialog_decorator.py', '.venv/lib/python3.9/site-packages/streamlit/commands/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/json.py', '.venv/lib/python3.9/site-packages/streamlit/hello/hello.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/pandas_styler_utils.py', '.venv/lib/python3.9/site-packages/streamlit/navigation/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/version.py', '.venv/lib/python3.9/site-packages/streamlit/elements/toast.py', '.venv/lib/python3.9/site-packages/streamlit/config_option.py', '.venv/lib/python3.9/site-packages/streamlit/emojis.py', '.venv/lib/python3.9/site-packages/streamlit/commands/logo.py', '.venv/lib/python3.9/site-packages/streamlit/elements/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/logger.py', '.venv/lib/python3.9/site-packages/blinker/_utilities.py', '.venv/lib/python3.9/site-packages/streamlit/elements/bokeh_chart.py', '.venv/lib/python3.9/site-packages/streamlit/material_icon_names.py', '.venv/lib/python3.9/site-packages/streamlit/elements/text.py', '.venv/lib/python3.9/site-packages/streamlit/__main__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/graphviz_chart.py', '.venv/lib/python3.9/site-packages/streamlit/file_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/html.py', '.venv/lib/python3.9/site-packages/streamlit/string_util.py', '.venv/lib/python3.9/site-packages/streamlit/dataframe_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/spinner.py', '.venv/lib/python3.9/site-packages/streamlit/env_util.py', '.venv/lib/python3.9/site-packages/streamlit/delta_generator.py', '.venv/lib/python3.9/site-packages/streamlit/elements/progress.py'. Reloading... +Process SpawnProcess-22: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +Process SpawnProcess-23: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/select_slider.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/media_file_storage.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/file_uploader.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/credentials.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/radio.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/audio_input.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/runtime.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/data_editor.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/time_widgets.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/memory_uploaded_file_manager.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/text_widgets.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/connection_factory.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/fragment.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/pages_manager.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/media_file_manager.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/dialog.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/number_input.py', '.venv/lib/python3.9/site-packages/streamlit/commands/experimental_query_params.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/runtime_util.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/metrics_util.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/metric.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/checkbox.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/forward_msg_cache.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/chat.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/script_data.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/button.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/memory_session_storage.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/websocket_session_manager.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/stats.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/button_group.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/secrets.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/uploaded_file_manager.py'. Reloading... +Process SpawnProcess-24: +Traceback (most recent call last): + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap + self.run() + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve + await self._serve(sockets) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve + config.load() + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load + self.loaded_app = import_from_string(self.app) + File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1030, in _gcd_import + File "", line 1007, in _find_and_load + File "", line 986, in _find_and_load_unlocked + File "", line 680, in _load_unlocked + File "", line 850, in exec_module + File "", line 228, in _call_with_frames_removed + File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in + from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links + File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in + from backend.app import models + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in + class Customer(Base): + File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer + contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) +TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' +INFO: Stopping reloader process [85187] diff --git a/backend/app/main.py b/backend/app/main.py index 6aa048c..1e9b254 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,19 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from backend.app.routers import customers, projects, finance +from backend.app.routers import ( + customers, + projects, + finance, + settings, + ai_settings, + email_configs, + cloud_docs, + cloud_doc_config, + portal_links, +) +from backend.app.db import Base, engine +from backend.app import models # noqa: F401 - ensure models are imported def create_app() -> FastAPI: @@ -16,6 +28,35 @@ def create_app() -> FastAPI: version="0.1.0", ) + # Ensure database tables exist (especially when running in Docker) + @app.on_event("startup") + def on_startup() -> None: + Base.metadata.create_all(bind=engine) + # Add new columns to finance_records if they don't exist (Module 6) + try: + from sqlalchemy import text + with engine.connect() as conn: + r = conn.execute(text("PRAGMA table_info(finance_records)")) + cols = [row[1] for row in r] + if "amount" not in cols: + conn.execute(text("ALTER TABLE finance_records ADD COLUMN amount NUMERIC(12,2)")) + if "billing_date" not in cols: + conn.execute(text("ALTER TABLE finance_records ADD COLUMN billing_date DATE")) + conn.commit() + except Exception: + pass + # Add customers.tags if missing (customer tags for project 收纳) + try: + from sqlalchemy import text + with engine.connect() as conn: + r = conn.execute(text("PRAGMA table_info(customers)")) + cols = [row[1] for row in r] + if "tags" not in cols: + conn.execute(text("ALTER TABLE customers ADD COLUMN tags VARCHAR(512)")) + conn.commit() + except Exception: + pass + # CORS raw_origins = os.getenv("CORS_ORIGINS") if raw_origins: @@ -35,6 +76,12 @@ def create_app() -> FastAPI: app.include_router(customers.router) app.include_router(projects.router) app.include_router(finance.router) + app.include_router(settings.router) + app.include_router(ai_settings.router) + app.include_router(email_configs.router) + app.include_router(cloud_docs.router) + app.include_router(cloud_doc_config.router) + app.include_router(portal_links.router) # Static data mount (for quotes, contracts, finance archives, etc.) data_dir = Path("data") diff --git a/backend/app/models.py b/backend/app/models.py index b5aa584..2f553f0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,6 +1,7 @@ -from datetime import datetime +from datetime import date, datetime from sqlalchemy import ( + Date, Column, DateTime, ForeignKey, @@ -20,6 +21,7 @@ class Customer(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False) contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True) + tags: Mapped[str | None] = mapped_column(String(512), nullable=True) # 逗号分隔,如:重点客户,已签约 created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False ) @@ -51,6 +53,30 @@ class Project(Base): quotes: Mapped[list["Quote"]] = relationship( "Quote", back_populates="project", cascade="all, delete-orphan" ) + cloud_docs: Mapped[list["ProjectCloudDoc"]] = relationship( + "ProjectCloudDoc", back_populates="project", cascade="all, delete-orphan" + ) + + +class ProjectCloudDoc(Base): + """项目与云文档的映射,用于增量更新(有则 PATCH,无则 POST)。""" + __tablename__ = "project_cloud_docs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + project_id: Mapped[int] = mapped_column( + Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True + ) + platform: Mapped[str] = mapped_column(String(32), nullable=False, index=True) # feishu | yuque | tencent + cloud_doc_id: Mapped[str] = mapped_column(String(256), nullable=False) + cloud_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + project: Mapped["Project"] = relationship("Project", back_populates="cloud_docs") class Quote(Base): @@ -74,9 +100,11 @@ class FinanceRecord(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) month: Mapped[str] = mapped_column(String(7), nullable=False, index=True) # YYYY-MM - type: Mapped[str] = mapped_column(String(50), nullable=False) # invoice / bank_receipt / ... + type: Mapped[str] = mapped_column(String(50), nullable=False) # invoice / bank_receipt / manual / ... file_name: Mapped[str] = mapped_column(String(255), nullable=False) file_path: Mapped[str] = mapped_column(String(512), nullable=False) + amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True) + billing_date: Mapped[date | None] = mapped_column(Date, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False ) diff --git a/backend/app/routers/ai_settings.py b/backend/app/routers/ai_settings.py new file mode 100644 index 0000000..b99ac21 --- /dev/null +++ b/backend/app/routers/ai_settings.py @@ -0,0 +1,300 @@ +""" +AI 模型配置:支持多套配置,持久化在 data/ai_configs.json,可选用当前生效配置。 +GET /settings/ai 当前选用配置;GET /settings/ai/list 列表;POST 新增;PUT /:id 更新;DELETE /:id 删除;POST /:id/activate 选用。 +""" +import json +import uuid +from pathlib import Path +from typing import Any, Dict, List + +from fastapi import APIRouter, HTTPException, Query, status +from pydantic import BaseModel, Field + +from backend.app.services.ai_service import get_active_ai_config, test_connection_with_config + +router = APIRouter(prefix="/settings/ai", tags=["ai-settings"]) + +CONFIGS_PATH = Path("data/ai_configs.json") +LEGACY_CONFIG_PATH = Path("data/ai_config.json") + +DEFAULT_FIELDS: Dict[str, Any] = { + "provider": "OpenAI", + "api_key": "", + "base_url": "", + "model_name": "gpt-4o-mini", + "temperature": 0.2, + "system_prompt_override": "", +} + + +class AIConfigRead(BaseModel): + model_config = {"protected_namespaces": ()} + + id: str = "" + name: str = "" + provider: str = "OpenAI" + api_key: str = "" + base_url: str = "" + model_name: str = "gpt-4o-mini" + temperature: float = 0.2 + system_prompt_override: str = "" + + +class AIConfigListItem(BaseModel): + """列表项:不含完整 api_key,仅标记是否已配置""" + id: str + name: str + provider: str + model_name: str + base_url: str = "" + api_key_configured: bool = False + is_active: bool = False + + +class AIConfigCreate(BaseModel): + model_config = {"protected_namespaces": ()} + + name: str = Field("", max_length=64) + provider: str = "OpenAI" + api_key: str = "" + base_url: str = "" + model_name: str = "gpt-4o-mini" + temperature: float = 0.2 + system_prompt_override: str = "" + + +class AIConfigUpdate(BaseModel): + model_config = {"protected_namespaces": ()} + + name: str | None = Field(None, max_length=64) + provider: str | None = None + api_key: str | None = None + base_url: str | None = None + model_name: str | None = None + temperature: float | None = None + system_prompt_override: str | None = None + + +def _load_configs_file() -> Dict[str, Any]: + if not CONFIGS_PATH.exists(): + return {"configs": [], "active_id": ""} + try: + data = json.loads(CONFIGS_PATH.read_text(encoding="utf-8")) + return {"configs": data.get("configs", []), "active_id": data.get("active_id", "") or ""} + except Exception: + return {"configs": [], "active_id": ""} + + +def _migrate_from_legacy() -> None: + if CONFIGS_PATH.exists(): + return + if not LEGACY_CONFIG_PATH.exists(): + return + try: + legacy = json.loads(LEGACY_CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + return + cfg = {**DEFAULT_FIELDS, **legacy} + new_id = str(uuid.uuid4())[:8] + payload = { + "configs": [ + { + "id": new_id, + "name": "默认配置", + "provider": cfg.get("provider", "OpenAI"), + "api_key": cfg.get("api_key", ""), + "base_url": cfg.get("base_url", ""), + "model_name": cfg.get("model_name", "gpt-4o-mini"), + "temperature": cfg.get("temperature", 0.2), + "system_prompt_override": cfg.get("system_prompt_override", ""), + } + ], + "active_id": new_id, + } + CONFIGS_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIGS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _save_configs(configs: List[Dict], active_id: str) -> None: + CONFIGS_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIGS_PATH.write_text( + json.dumps({"configs": configs, "active_id": active_id}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +@router.get("", response_model=AIConfigRead) +async def get_current_ai_settings(): + """返回当前选用的 AI 配置(用于编辑表单与兼容旧接口)。""" + _migrate_from_legacy() + cfg = get_active_ai_config() + return AIConfigRead( + id=cfg.get("id", ""), + name=cfg.get("name", ""), + provider=cfg.get("provider", "OpenAI"), + api_key=cfg.get("api_key", ""), + base_url=cfg.get("base_url", ""), + model_name=cfg.get("model_name", "gpt-4o-mini"), + temperature=float(cfg.get("temperature", 0.2)), + system_prompt_override=cfg.get("system_prompt_override", ""), + ) + + +@router.get("/list", response_model=List[AIConfigListItem]) +async def list_ai_configs(): + """列出所有已配置的模型,方便查看、选用或编辑。""" + _migrate_from_legacy() + data = _load_configs_file() + configs = data.get("configs") or [] + active_id = data.get("active_id") or "" + out = [] + for c in configs: + out.append( + AIConfigListItem( + id=c.get("id", ""), + name=c.get("name", "未命名"), + provider=c.get("provider", "OpenAI"), + model_name=c.get("model_name", ""), + base_url=(c.get("base_url") or "")[:64] or "", + api_key_configured=bool((c.get("api_key") or "").strip()), + is_active=(c.get("id") == active_id), + ) + ) + return out + + +@router.get("/{config_id}", response_model=AIConfigRead) +async def get_ai_config_by_id(config_id: str): + """获取单条配置(用于编辑)。""" + _migrate_from_legacy() + data = _load_configs_file() + for c in data.get("configs") or []: + if c.get("id") == config_id: + return AIConfigRead( + id=c.get("id", ""), + name=c.get("name", ""), + provider=c.get("provider", "OpenAI"), + api_key=c.get("api_key", ""), + base_url=c.get("base_url", ""), + model_name=c.get("model_name", "gpt-4o-mini"), + temperature=float(c.get("temperature", 0.2)), + system_prompt_override=c.get("system_prompt_override", ""), + ) + raise HTTPException(status_code=404, detail="配置不存在") + + +@router.post("", response_model=AIConfigRead, status_code=status.HTTP_201_CREATED) +async def create_ai_config(payload: AIConfigCreate): + """新增一套模型配置。""" + _migrate_from_legacy() + data = _load_configs_file() + configs = list(data.get("configs") or []) + active_id = data.get("active_id") or "" + new_id = str(uuid.uuid4())[:8] + name = (payload.name or "").strip() or f"{payload.provider} - {payload.model_name}" + new_cfg = { + "id": new_id, + "name": name[:64], + "provider": payload.provider or "OpenAI", + "api_key": payload.api_key or "", + "base_url": (payload.base_url or "").strip(), + "model_name": (payload.model_name or "gpt-4o-mini").strip(), + "temperature": float(payload.temperature) if payload.temperature is not None else 0.2, + "system_prompt_override": (payload.system_prompt_override or "").strip(), + } + configs.append(new_cfg) + if not active_id: + active_id = new_id + _save_configs(configs, active_id) + return AIConfigRead(**new_cfg) + + +@router.put("/{config_id}", response_model=AIConfigRead) +async def update_ai_config(config_id: str, payload: AIConfigUpdate): + """更新指定配置。""" + _migrate_from_legacy() + data = _load_configs_file() + configs = data.get("configs") or [] + for c in configs: + if c.get("id") == config_id: + if payload.name is not None: + c["name"] = (payload.name or "").strip()[:64] or c.get("name", "") + if payload.provider is not None: + c["provider"] = payload.provider + if payload.api_key is not None: + c["api_key"] = payload.api_key + if payload.base_url is not None: + c["base_url"] = (payload.base_url or "").strip() + if payload.model_name is not None: + c["model_name"] = (payload.model_name or "").strip() + if payload.temperature is not None: + c["temperature"] = float(payload.temperature) + if payload.system_prompt_override is not None: + c["system_prompt_override"] = (payload.system_prompt_override or "").strip() + _save_configs(configs, data.get("active_id", "")) + return AIConfigRead( + id=c.get("id", ""), + name=c.get("name", ""), + provider=c.get("provider", "OpenAI"), + api_key=c.get("api_key", ""), + base_url=c.get("base_url", ""), + model_name=c.get("model_name", "gpt-4o-mini"), + temperature=float(c.get("temperature", 0.2)), + system_prompt_override=c.get("system_prompt_override", ""), + ) + raise HTTPException(status_code=404, detail="配置不存在") + + +@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_ai_config(config_id: str): + """删除指定配置;若为当前选用,则改用列表第一项。""" + _migrate_from_legacy() + data = _load_configs_file() + configs = [c for c in (data.get("configs") or []) if c.get("id") != config_id] + active_id = data.get("active_id", "") + if active_id == config_id: + active_id = configs[0].get("id", "") if configs else "" + _save_configs(configs, active_id) + return None + + +@router.post("/test") +async def test_ai_connection(config_id: str | None = Query(None, description="指定配置 ID,不传则用当前选用")): + """测试连接;不传 config_id 时使用当前选用配置。""" + if config_id: + data = _load_configs_file() + for c in data.get("configs") or []: + if c.get("id") == config_id: + try: + result = await test_connection_with_config(c) + return {"status": "ok", "message": result} + except Exception as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + raise HTTPException(status_code=404, detail="配置不存在") + try: + result = await test_connection_with_config(get_active_ai_config()) + return {"status": "ok", "message": result} + except Exception as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + +@router.post("/{config_id}/activate", response_model=AIConfigRead) +async def activate_ai_config(config_id: str): + """选用该配置为当前生效。""" + _migrate_from_legacy() + data = _load_configs_file() + exists = any(c.get("id") == config_id for c in (data.get("configs") or [])) + if not exists: + raise HTTPException(status_code=404, detail="配置不存在") + _save_configs(data.get("configs", []), config_id) + cfg = get_active_ai_config() + return AIConfigRead( + id=cfg.get("id", ""), + name=cfg.get("name", ""), + provider=cfg.get("provider", "OpenAI"), + api_key=cfg.get("api_key", ""), + base_url=cfg.get("base_url", ""), + model_name=cfg.get("model_name", "gpt-4o-mini"), + temperature=float(cfg.get("temperature", 0.2)), + system_prompt_override=cfg.get("system_prompt_override", ""), + ) diff --git a/backend/app/routers/cloud_doc_config.py b/backend/app/routers/cloud_doc_config.py new file mode 100644 index 0000000..f42caad --- /dev/null +++ b/backend/app/routers/cloud_doc_config.py @@ -0,0 +1,139 @@ +""" +云文档配置:各平台 API 凭证的存储与读取。 +飞书 App ID/Secret、语雀 Token、腾讯文档 Client ID/Secret。 +""" +import json +from pathlib import Path +from typing import Any, Dict + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/settings/cloud-doc-config", tags=["cloud-doc-config"]) + +CONFIG_PATH = Path("data/cloud_doc_credentials.json") + +PLATFORMS = ("feishu", "yuque", "tencent") + + +class FeishuConfig(BaseModel): + app_id: str = Field("", description="飞书应用 App ID") + app_secret: str = Field("", description="飞书应用 App Secret") + + +class YuqueConfig(BaseModel): + token: str = Field("", description="语雀 Personal Access Token") + default_repo: str = Field("", description="默认知识库 namespace,如 my/repo") + + +class TencentConfig(BaseModel): + client_id: str = Field("", description="腾讯文档应用 Client ID") + client_secret: str = Field("", description="腾讯文档应用 Client Secret") + + +class FeishuConfigRead(BaseModel): + app_id: str = "" + app_secret_configured: bool = False + + +class YuqueConfigRead(BaseModel): + token_configured: bool = False + default_repo: str = "" + + +class TencentConfigRead(BaseModel): + client_id: str = "" + client_secret_configured: bool = False + + +class CloudDocConfigRead(BaseModel): + feishu: FeishuConfigRead + yuque: YuqueConfigRead + tencent: TencentConfigRead + + +def _load_config() -> Dict[str, Any]: + if not CONFIG_PATH.exists(): + return {} + try: + data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_config(data: Dict[str, Any]) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def _mask_secrets_for_read(raw: Dict[str, Any]) -> CloudDocConfigRead: + f = raw.get("feishu") or {} + y = raw.get("yuque") or {} + t = raw.get("tencent") or {} + return CloudDocConfigRead( + feishu=FeishuConfigRead( + app_id=f.get("app_id") or "", + app_secret_configured=bool((f.get("app_secret") or "").strip()), + ), + yuque=YuqueConfigRead( + token_configured=bool((y.get("token") or "").strip()), + default_repo=(y.get("default_repo") or "").strip(), + ), + tencent=TencentConfigRead( + client_id=t.get("client_id") or "", + client_secret_configured=bool((t.get("client_secret") or "").strip()), + ), + ) + + +@router.get("", response_model=CloudDocConfigRead) +async def get_cloud_doc_config(): + """获取云文档配置(凭证以是否已配置返回,不返回明文)。""" + raw = _load_config() + return _mask_secrets_for_read(raw) + + +@router.put("", response_model=CloudDocConfigRead) +async def update_cloud_doc_config(payload: Dict[str, Any]): + """ + 更新云文档配置。传各平台字段,未传的保留原值。 + 例: { "feishu": { "app_id": "xxx", "app_secret": "yyy" }, "yuque": { "token": "zzz", "default_repo": "a/b" } } + """ + raw = _load_config() + for platform in PLATFORMS: + if platform not in payload or not isinstance(payload[platform], dict): + continue + p = payload[platform] + if platform == "feishu": + if "app_id" in p and p["app_id"] is not None: + raw.setdefault("feishu", {})["app_id"] = str(p["app_id"]).strip() + if "app_secret" in p and p["app_secret"] is not None: + raw.setdefault("feishu", {})["app_secret"] = str(p["app_secret"]).strip() + elif platform == "yuque": + if "token" in p and p["token"] is not None: + raw.setdefault("yuque", {})["token"] = str(p["token"]).strip() + if "default_repo" in p and p["default_repo"] is not None: + raw.setdefault("yuque", {})["default_repo"] = str(p["default_repo"]).strip() + elif platform == "tencent": + if "client_id" in p and p["client_id"] is not None: + raw.setdefault("tencent", {})["client_id"] = str(p["client_id"]).strip() + if "client_secret" in p and p["client_secret"] is not None: + raw.setdefault("tencent", {})["client_secret"] = str(p["client_secret"]).strip() + _save_config(raw) + return _mask_secrets_for_read(raw) + + +def get_credentials(platform: str) -> Dict[str, str]: + """供 cloud_doc_service 使用:读取某平台明文凭证。""" + raw = _load_config() + return (raw.get(platform) or {}).copy() + + +def get_all_credentials() -> Dict[str, Dict[str, str]]: + """供推送流程使用:读取全部平台凭证(明文)。""" + raw = _load_config() + return {k: dict(v) for k, v in raw.items() if isinstance(v, dict)} diff --git a/backend/app/routers/cloud_docs.py b/backend/app/routers/cloud_docs.py new file mode 100644 index 0000000..f97dbda --- /dev/null +++ b/backend/app/routers/cloud_docs.py @@ -0,0 +1,91 @@ +""" +云文档快捷入口:持久化在 data/cloud_docs.json,支持增删改查。 +""" +import json +import uuid +from pathlib import Path +from typing import Any, Dict, List + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/settings/cloud-docs", tags=["cloud-docs"]) + +CONFIG_PATH = Path("data/cloud_docs.json") + + +class CloudDocLinkCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=64, description="显示名称") + url: str = Field(..., min_length=1, max_length=512, description="登录/入口 URL") + + +class CloudDocLinkRead(BaseModel): + id: str + name: str + url: str + + +class CloudDocLinkUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=64) + url: str | None = Field(None, min_length=1, max_length=512) + + +def _load_links() -> List[Dict[str, Any]]: + if not CONFIG_PATH.exists(): + return [] + try: + data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def _save_links(links: List[Dict[str, Any]]) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text( + json.dumps(links, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +@router.get("", response_model=List[CloudDocLinkRead]) +async def list_cloud_docs(): + """获取所有云文档快捷入口。""" + links = _load_links() + return [CloudDocLinkRead(**x) for x in links] + + +@router.post("", response_model=CloudDocLinkRead, status_code=status.HTTP_201_CREATED) +async def create_cloud_doc(payload: CloudDocLinkCreate): + """新增一条云文档入口。""" + links = _load_links() + new_id = str(uuid.uuid4())[:8] + new_item = {"id": new_id, "name": payload.name.strip(), "url": payload.url.strip()} + links.append(new_item) + _save_links(links) + return CloudDocLinkRead(**new_item) + + +@router.put("/{link_id}", response_model=CloudDocLinkRead) +async def update_cloud_doc(link_id: str, payload: CloudDocLinkUpdate): + """更新名称或 URL。""" + links = _load_links() + for item in links: + if item.get("id") == link_id: + if payload.name is not None: + item["name"] = payload.name.strip() + if payload.url is not None: + item["url"] = payload.url.strip() + _save_links(links) + return CloudDocLinkRead(**item) + raise HTTPException(status_code=404, detail="云文档入口不存在") + + +@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_cloud_doc(link_id: str): + """删除一条云文档入口。""" + links = _load_links() + new_list = [x for x in links if x.get("id") != link_id] + if len(new_list) == len(links): + raise HTTPException(status_code=404, detail="云文档入口不存在") + _save_links(new_list) diff --git a/backend/app/routers/customers.py b/backend/app/routers/customers.py index bd3f0a2..2f4c6a0 100644 --- a/backend/app/routers/customers.py +++ b/backend/app/routers/customers.py @@ -16,9 +16,18 @@ router = APIRouter(prefix="/customers", tags=["customers"]) @router.get("/", response_model=List[CustomerRead]) -async def list_customers(db: Session = Depends(get_db)): - customers = db.query(models.Customer).order_by(models.Customer.created_at.desc()).all() - return customers +async def list_customers( + q: str | None = None, + db: Session = Depends(get_db), +): + """列表客户,支持 q 按名称、联系方式模糊搜索。""" + query = db.query(models.Customer).order_by(models.Customer.created_at.desc()) + if q and q.strip(): + term = f"%{q.strip()}%" + query = query.filter( + (models.Customer.name.ilike(term)) | (models.Customer.contact_info.ilike(term)) + ) + return query.all() @router.post("/", response_model=CustomerRead, status_code=status.HTTP_201_CREATED) @@ -26,6 +35,7 @@ async def create_customer(payload: CustomerCreate, db: Session = Depends(get_db) customer = models.Customer( name=payload.name, contact_info=payload.contact_info, + tags=payload.tags, ) db.add(customer) db.commit() @@ -53,6 +63,8 @@ async def update_customer( customer.name = payload.name if payload.contact_info is not None: customer.contact_info = payload.contact_info + if payload.tags is not None: + customer.tags = payload.tags db.commit() db.refresh(customer) diff --git a/backend/app/routers/email_configs.py b/backend/app/routers/email_configs.py new file mode 100644 index 0000000..27f9470 --- /dev/null +++ b/backend/app/routers/email_configs.py @@ -0,0 +1,183 @@ +""" +Email accounts for multi-email finance sync. Stored in data/email_configs.json. +""" +import json +import uuid +from pathlib import Path +from typing import Any, Dict, List + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/settings/email", tags=["email-configs"]) + +CONFIG_PATH = Path("data/email_configs.json") + + +class EmailConfigCreate(BaseModel): + host: str = Field(..., description="IMAP host") + port: int = Field(993, description="IMAP port") + user: str = Field(..., description="Email address") + password: str = Field(..., description="Password or authorization code") + mailbox: str = Field("INBOX", description="Mailbox name") + active: bool = Field(True, description="Include in sync") + + +class EmailConfigRead(BaseModel): + id: str + host: str + port: int + user: str + mailbox: str + active: bool + + +class EmailConfigUpdate(BaseModel): + host: str | None = None + port: int | None = None + user: str | None = None + password: str | None = None + mailbox: str | None = None + active: bool | None = None + + +def _load_configs() -> List[Dict[str, Any]]: + if not CONFIG_PATH.exists(): + return [] + try: + data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def _save_configs(configs: List[Dict[str, Any]]) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text( + json.dumps(configs, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def _to_read(c: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": c["id"], + "host": c["host"], + "port": c["port"], + "user": c["user"], + "mailbox": c.get("mailbox", "INBOX"), + "active": c.get("active", True), + } + + +@router.get("", response_model=List[EmailConfigRead]) +async def list_email_configs(): + """List all email account configs (password omitted).""" + configs = _load_configs() + return [_to_read(c) for c in configs] + + +@router.post("", response_model=EmailConfigRead, status_code=status.HTTP_201_CREATED) +async def create_email_config(payload: EmailConfigCreate): + """Add a new email account.""" + configs = _load_configs() + new_id = str(uuid.uuid4()) + configs.append({ + "id": new_id, + "host": payload.host, + "port": payload.port, + "user": payload.user, + "password": payload.password, + "mailbox": payload.mailbox, + "active": payload.active, + }) + _save_configs(configs) + return _to_read(configs[-1]) + + +@router.put("/{config_id}", response_model=EmailConfigRead) +async def update_email_config(config_id: str, payload: EmailConfigUpdate): + """Update an email account (omit password to keep existing).""" + configs = _load_configs() + for c in configs: + if c.get("id") == config_id: + if payload.host is not None: + c["host"] = payload.host + if payload.port is not None: + c["port"] = payload.port + if payload.user is not None: + c["user"] = payload.user + if payload.password is not None: + c["password"] = payload.password + if payload.mailbox is not None: + c["mailbox"] = payload.mailbox + if payload.active is not None: + c["active"] = payload.active + _save_configs(configs) + return _to_read(c) + raise HTTPException(status_code=404, detail="Email config not found") + + +@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_email_config(config_id: str): + """Remove an email account.""" + configs = _load_configs() + new_list = [c for c in configs if c.get("id") != config_id] + if len(new_list) == len(configs): + raise HTTPException(status_code=404, detail="Email config not found") + _save_configs(new_list) + + +@router.get("/{config_id}/folders") +async def list_email_folders(config_id: str): + """ + List mailbox folders for this account (for choosing custom labels). + Returns [{ "raw": "...", "decoded": "收件箱" }, ...]. Use decoded for display and for mailbox config. + """ + import asyncio + from backend.app.services.email_service import list_mailboxes_for_config + + configs = _load_configs() + config = next((c for c in configs if c.get("id") == config_id), None) + if not config: + raise HTTPException(status_code=404, detail="Email config not found") + host = config.get("host") + user = config.get("user") + password = config.get("password") + port = int(config.get("port", 993)) + if not all([host, user, password]): + raise HTTPException(status_code=400, detail="Config missing host/user/password") + + def _fetch(): + return list_mailboxes_for_config(host, port, user, password) + + try: + folders = await asyncio.to_thread(_fetch) + except Exception as e: + raise HTTPException(status_code=502, detail=f"无法连接邮箱或获取文件夹列表: {e}") from e + + return {"folders": [{"raw": r, "decoded": d} for r, d in folders]} + + +def get_email_configs_for_sync() -> List[Dict[str, Any]]: + """Return list of configs that are active (for sync). Falls back to env if file empty.""" + configs = _load_configs() + active = [c for c in configs if c.get("active", True)] + if active: + return active + # Fallback to single account from env + import os + host = os.getenv("IMAP_HOST") + user = os.getenv("IMAP_USER") + password = os.getenv("IMAP_PASSWORD") + if host and user and password: + return [{ + "id": "env", + "host": host, + "port": int(os.getenv("IMAP_PORT", "993")), + "user": user, + "password": password, + "mailbox": os.getenv("IMAP_MAILBOX", "INBOX"), + "active": True, + }] + return [] diff --git a/backend/app/routers/finance.py b/backend/app/routers/finance.py index 5d236c6..4f16f8a 100644 --- a/backend/app/routers/finance.py +++ b/backend/app/routers/finance.py @@ -1,8 +1,20 @@ -from fastapi import APIRouter, HTTPException -from fastapi.responses import FileResponse +from typing import List -from backend.app.schemas import FinanceSyncResponse, FinanceSyncResult +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from backend.app.db import get_db +from backend.app import models +from backend.app.schemas import ( + FinanceRecordRead, + FinanceRecordUpdate, + FinanceSyncResponse, + FinanceSyncResult, + FinanceUploadResponse, +) from backend.app.services.email_service import create_monthly_zip, sync_finance_emails +from backend.app.services.invoice_upload import process_invoice_upload router = APIRouter(prefix="/finance", tags=["finance"]) @@ -13,10 +25,87 @@ async def sync_finance(): try: items_raw = await sync_finance_emails() except RuntimeError as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc + # 邮箱配置/连接等问题属于可预期的业务错误,用 400 让前端直接展示原因,而不是泛化为 500。 + raise HTTPException(status_code=400, detail=str(exc)) from exc - items = [FinanceSyncResult(**item) for item in items_raw] - return FinanceSyncResponse(items=items) + details = [FinanceSyncResult(**item) for item in items_raw] + return FinanceSyncResponse( + status="success", + new_files=len(details), + details=details, + ) + + +@router.get("/months", response_model=List[str]) +async def list_finance_months(db: Session = Depends(get_db)): + """List distinct months that have finance records (YYYY-MM), newest first.""" + from sqlalchemy import distinct + + rows = ( + db.query(distinct(models.FinanceRecord.month)) + .order_by(models.FinanceRecord.month.desc()) + .all() + ) + return [r[0] for r in rows] + + +@router.get("/records", response_model=List[FinanceRecordRead]) +async def list_finance_records( + month: str = Query(..., description="YYYY-MM"), + db: Session = Depends(get_db), +): + """List finance records for a given month.""" + records = ( + db.query(models.FinanceRecord) + .filter(models.FinanceRecord.month == month) + .order_by(models.FinanceRecord.created_at.desc()) + .all() + ) + return records + + +@router.post("/upload", response_model=FinanceUploadResponse, status_code=201) +async def upload_invoice( + file: UploadFile = File(...), + db: Session = Depends(get_db), +): + """Upload an invoice (PDF or image). Saves to data/finance/{YYYY-MM}/manual/, runs AI OCR for amount/date.""" + suf = (file.filename or "").lower().split(".")[-1] if "." in (file.filename or "") else "" + allowed = {"pdf", "jpg", "jpeg", "png", "webp"} + if suf not in allowed: + raise HTTPException(400, "仅支持 PDF、JPG、PNG、WEBP 格式") + file_name, file_path, month_str, amount, billing_date = await process_invoice_upload(file) + record = models.FinanceRecord( + month=month_str, + type="manual", + file_name=file_name, + file_path=file_path, + amount=amount, + billing_date=billing_date, + ) + db.add(record) + db.commit() + db.refresh(record) + return record + + +@router.patch("/records/{record_id}", response_model=FinanceRecordRead) +async def update_finance_record( + record_id: int, + payload: FinanceRecordUpdate, + db: Session = Depends(get_db), +): + """Update amount and/or billing_date of a finance record (e.g. after manual review).""" + record = db.query(models.FinanceRecord).get(record_id) + if not record: + raise HTTPException(404, "记录不存在") + if payload.amount is not None: + record.amount = payload.amount + if payload.billing_date is not None: + record.billing_date = payload.billing_date + db.commit() + db.refresh(record) + return record @router.get("/download/{month}") diff --git a/backend/app/routers/portal_links.py b/backend/app/routers/portal_links.py new file mode 100644 index 0000000..1897f00 --- /dev/null +++ b/backend/app/routers/portal_links.py @@ -0,0 +1,91 @@ +""" +快捷门户入口:持久化在 data/portal_links.json,支持增删改查。 +""" +import json +import uuid +from pathlib import Path +from typing import Any, Dict, List + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/settings/portal-links", tags=["portal-links"]) + +CONFIG_PATH = Path("data/portal_links.json") + + +class PortalLinkCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=64, description="显示名称") + url: str = Field(..., min_length=1, max_length=512, description="门户 URL") + + +class PortalLinkRead(BaseModel): + id: str + name: str + url: str + + +class PortalLinkUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=64) + url: str | None = Field(None, min_length=1, max_length=512) + + +def _load_links() -> List[Dict[str, Any]]: + if not CONFIG_PATH.exists(): + return [] + try: + data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def _save_links(links: List[Dict[str, Any]]) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text( + json.dumps(links, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +@router.get("", response_model=List[PortalLinkRead]) +async def list_portal_links(): + """获取所有快捷门户入口。""" + links = _load_links() + return [PortalLinkRead(**x) for x in links] + + +@router.post("", response_model=PortalLinkRead, status_code=status.HTTP_201_CREATED) +async def create_portal_link(payload: PortalLinkCreate): + """新增一条快捷门户入口。""" + links = _load_links() + new_id = str(uuid.uuid4())[:8] + new_item = {"id": new_id, "name": payload.name.strip(), "url": payload.url.strip()} + links.append(new_item) + _save_links(links) + return PortalLinkRead(**new_item) + + +@router.put("/{link_id}", response_model=PortalLinkRead) +async def update_portal_link(link_id: str, payload: PortalLinkUpdate): + """更新名称或 URL。""" + links = _load_links() + for item in links: + if item.get("id") == link_id: + if payload.name is not None: + item["name"] = payload.name.strip() + if payload.url is not None: + item["url"] = payload.url.strip() + _save_links(links) + return PortalLinkRead(**item) + raise HTTPException(status_code=404, detail="快捷门户入口不存在") + + +@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_portal_link(link_id: str): + """删除一条快捷门户入口。""" + links = _load_links() + new_list = [x for x in links if x.get("id") != link_id] + if len(new_list) == len(links): + raise HTTPException(status_code=404, detail="快捷门户入口不存在") + _save_links(new_list) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 7104b32..e536bf9 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -1,8 +1,10 @@ +import logging +from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Union from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from backend.app import models from backend.app.db import get_db @@ -11,25 +13,35 @@ from backend.app.schemas import ( ContractGenerateResponse, ProjectRead, ProjectUpdate, + PushToCloudRequest, + PushToCloudResponse, QuoteGenerateResponse, RequirementAnalyzeRequest, RequirementAnalyzeResponse, ) from backend.app.services.ai_service import analyze_requirement +from backend.app.services.cloud_doc_service import CloudDocManager from backend.app.services.doc_service import ( generate_contract_word, generate_quote_excel, generate_quote_pdf_from_data, ) +from backend.app.routers.cloud_doc_config import get_all_credentials router = APIRouter(prefix="/projects", tags=["projects"]) -def _build_markdown_from_analysis(data: Dict[str, Any]) -> str: +def _build_markdown_from_analysis(data: Union[Dict[str, Any], List[Any]]) -> str: """ Convert structured AI analysis JSON into a human-editable Markdown document. + Tolerates AI returning a list (e.g. modules only) and normalizes to a dict. """ + if isinstance(data, list): + data = {"modules": data, "total_estimated_hours": None, "total_amount": None, "notes": None} + if not isinstance(data, dict): + data = {} + lines: list[str] = [] lines.append("# 项目方案草稿") lines.append("") @@ -48,7 +60,15 @@ def _build_markdown_from_analysis(data: Dict[str, Any]) -> str: if modules: lines.append("## 功能模块与技术实现") for idx, module in enumerate(modules, start=1): - name = module.get("name", f"模块 {idx}") + if not isinstance(module, dict): + # AI sometimes returns strings or other shapes; treat as a single title line + raw_name = str(module).strip() if module else "" + name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}" + lines.append(f"### {idx}. {name}") + lines.append("") + continue + raw_name = (module.get("name") or "").strip() + name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}" desc = module.get("description") or "" tech = module.get("technical_approach") or "" hours = module.get("estimated_hours") @@ -83,13 +103,31 @@ def _build_markdown_from_analysis(data: Dict[str, Any]) -> str: @router.get("/", response_model=list[ProjectRead]) -async def list_projects(db: Session = Depends(get_db)): - projects = ( +async def list_projects( + customer_tag: str | None = None, + db: Session = Depends(get_db), +): + """列表项目;customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。""" + query = ( db.query(models.Project) + .options(joinedload(models.Project.customer)) + .join(models.Customer) .order_by(models.Project.created_at.desc()) - .all() ) - return projects + if customer_tag and customer_tag.strip(): + tag = customer_tag.strip() + # 客户 tags 逗号分隔,按整词匹配 + from sqlalchemy import or_ + t = models.Customer.tags + query = query.filter( + or_( + t == tag, + t.ilike(f"{tag},%"), + t.ilike(f"%,{tag},%"), + t.ilike(f"%,{tag}"), + ) + ) + return query.all() @router.get("/{project_id}", response_model=ProjectRead) @@ -109,6 +147,8 @@ async def update_project( project = db.query(models.Project).get(project_id) if not project: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + if payload.raw_requirement is not None: + project.raw_requirement = payload.raw_requirement if payload.ai_solution_md is not None: project.ai_solution_md = payload.ai_solution_md if payload.status is not None: @@ -123,12 +163,24 @@ async def analyze_project_requirement( payload: RequirementAnalyzeRequest, db: Session = Depends(get_db), ): + logging.getLogger(__name__).info( + "收到 AI 解析请求: customer_id=%s, 需求长度=%d", + payload.customer_id, + len(payload.raw_text or ""), + ) # Ensure customer exists customer = db.query(models.Customer).get(payload.customer_id) if not customer: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found") - analysis = await analyze_requirement(payload.raw_text) + try: + analysis = await analyze_requirement(payload.raw_text) + except RuntimeError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + ai_solution_md = _build_markdown_from_analysis(analysis) project = models.Project( @@ -151,6 +203,7 @@ async def analyze_project_requirement( @router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse) async def generate_project_quote( project_id: int, + template: str | None = None, db: Session = Depends(get_db), ): project = db.query(models.Project).get(project_id) @@ -167,7 +220,9 @@ async def generate_project_quote( excel_path = base_dir / f"quote_project_{project.id}.xlsx" pdf_path = base_dir / f"quote_project_{project.id}.pdf" - template_path = Path("templates/quote_template.xlsx") + from backend.app.routers.settings import get_quote_template_path + + template_path = get_quote_template_path(template) if not template_path.exists(): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -254,3 +309,61 @@ async def generate_project_contract( return ContractGenerateResponse(project_id=project.id, contract_path=str(output_path)) + +@router.post("/{project_id}/push-to-cloud", response_model=PushToCloudResponse) +async def push_project_to_cloud( + project_id: int, + payload: PushToCloudRequest, + db: Session = Depends(get_db), +): + """ + 将当前项目方案(Markdown)推送到云文档。若该项目此前已推送过该平台,则更新原文档(增量同步)。 + """ + project = db.query(models.Project).options(joinedload(models.Project.customer)).get(project_id) + if not project: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + platform = (payload.platform or "").strip().lower() + if platform not in ("feishu", "yuque", "tencent"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="platform 须为 feishu / yuque / tencent", + ) + title = (payload.title or "").strip() or f"项目方案 - 项目#{project_id}" + body_md = (payload.body_md if payload.body_md is not None else project.ai_solution_md) or "" + if not body_md.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="暂无方案内容,请先在编辑器中填写或保存方案后再推送", + ) + existing = ( + db.query(models.ProjectCloudDoc) + .filter( + models.ProjectCloudDoc.project_id == project_id, + models.ProjectCloudDoc.platform == platform, + ) + .first() + ) + existing_doc_id = existing.cloud_doc_id if existing else None + credentials = get_all_credentials() + manager = CloudDocManager(credentials) + try: + cloud_doc_id, url = await manager.push_markdown( + platform, title, body_md, existing_doc_id=existing_doc_id + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + if existing: + existing.cloud_doc_id = cloud_doc_id + existing.cloud_url = url + existing.updated_at = datetime.now(timezone.utc) + else: + record = models.ProjectCloudDoc( + project_id=project_id, + platform=platform, + cloud_doc_id=cloud_doc_id, + cloud_url=url, + ) + db.add(record) + db.commit() + return PushToCloudResponse(url=url, cloud_doc_id=cloud_doc_id) + diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..88fdcea --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,93 @@ +from pathlib import Path +from typing import List + +from fastapi import APIRouter, File, HTTPException, UploadFile, status + +router = APIRouter(prefix="/settings", tags=["settings"]) + +TEMPLATES_DIR = Path("data/templates") +ALLOWED_EXCEL = {".xlsx", ".xltx"} +ALLOWED_WORD = {".docx", ".dotx"} +ALLOWED_EXTENSIONS = ALLOWED_EXCEL | ALLOWED_WORD + +# Allowed MIME types when client sends Content-Type (validate if present) +ALLOWED_MIME_TYPES = frozenset({ + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # .xlsx + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", # .xltx + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", # .dotx +}) + + +def _ensure_templates_dir() -> Path: + TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) + return TEMPLATES_DIR + + +@router.get("/templates", response_model=List[dict]) +async def list_templates(): + """List uploaded template files (name, type, size, mtime).""" + _ensure_templates_dir() + out: List[dict] = [] + for f in sorted(TEMPLATES_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): + if not f.is_file(): + continue + suf = f.suffix.lower() + if suf not in ALLOWED_EXTENSIONS: + continue + st = f.stat() + out.append({ + "name": f.name, + "type": "excel" if suf in ALLOWED_EXCEL else "word", + "size": st.st_size, + "uploaded_at": st.st_mtime, + }) + return out + + +@router.post("/templates/upload", status_code=status.HTTP_201_CREATED) +async def upload_template(file: UploadFile = File(...)): + """Upload a .xlsx, .xltx, .docx or .dotx template to data/templates/.""" + suf = Path(file.filename or "").suffix.lower() + if suf not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only .xlsx, .xltx, .docx and .dotx files are allowed.", + ) + content_type = (file.content_type or "").strip().split(";")[0].strip().lower() + if content_type and content_type not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid content type. Allowed: .xlsx, .xltx, .docx, .dotx Office formats.", + ) + dir_path = _ensure_templates_dir() + dest = dir_path / (file.filename or "template" + suf) + content = await file.read() + dest.write_bytes(content) + return {"name": dest.name, "path": str(dest)} + + +def get_latest_excel_template() -> Path | None: + """Return path to the most recently modified .xlsx or .xltx in data/templates, or None.""" + if not TEMPLATES_DIR.exists(): + return None + excel_files = [ + f for f in TEMPLATES_DIR.iterdir() + if f.is_file() and f.suffix.lower() in ALLOWED_EXCEL + ] + if not excel_files: + return None + return max(excel_files, key=lambda p: p.stat().st_mtime) + + +def get_quote_template_path(template_filename: str | None) -> Path: + """Resolve quote template path: optional filename in data/templates or latest excel template or default.""" + if template_filename: + candidate = TEMPLATES_DIR / template_filename + if candidate.is_file() and candidate.suffix.lower() in ALLOWED_EXCEL: + return candidate + latest = get_latest_excel_template() + if latest: + return latest + default = Path("templates/quote_template.xlsx") + return default diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 817da38..da5c04c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date, datetime from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field class CustomerBase(BaseModel): name: str = Field(..., description="Customer name") contact_info: Optional[str] = Field(None, description="Contact information") + tags: Optional[str] = Field(None, description="Comma-separated tags, e.g. 重点客户,已签约") class CustomerCreate(CustomerBase): @@ -16,6 +17,7 @@ class CustomerCreate(CustomerBase): class CustomerUpdate(BaseModel): name: Optional[str] = None contact_info: Optional[str] = None + tags: Optional[str] = None class CustomerRead(CustomerBase): @@ -33,12 +35,14 @@ class ProjectRead(BaseModel): ai_solution_md: Optional[str] = None status: str created_at: datetime + customer: Optional[CustomerRead] = None class Config: from_attributes = True class ProjectUpdate(BaseModel): + raw_requirement: Optional[str] = None ai_solution_md: Optional[str] = None status: Optional[str] = None @@ -75,6 +79,17 @@ class ContractGenerateResponse(BaseModel): contract_path: str +class PushToCloudRequest(BaseModel): + platform: str = Field(..., description="feishu | yuque | tencent") + title: Optional[str] = Field(None, description="文档标题,默认使用「项目方案 - 项目#id」") + body_md: Optional[str] = Field(None, description="要推送的 Markdown 内容,不传则使用项目已保存的方案") + + +class PushToCloudResponse(BaseModel): + url: str + cloud_doc_id: str + + class FinanceSyncResult(BaseModel): id: int month: str @@ -84,5 +99,40 @@ class FinanceSyncResult(BaseModel): class FinanceSyncResponse(BaseModel): - items: List[FinanceSyncResult] + status: str = "success" + new_files: int = 0 + details: List[FinanceSyncResult] = Field(default_factory=list) + + +class FinanceRecordRead(BaseModel): + id: int + month: str + type: str + file_name: str + file_path: str + amount: Optional[float] = None + billing_date: Optional[date] = None + created_at: datetime + + class Config: + from_attributes = True + + +class FinanceRecordUpdate(BaseModel): + amount: Optional[float] = None + billing_date: Optional[date] = None + + +class FinanceUploadResponse(BaseModel): + id: int + month: str + type: str + file_name: str + file_path: str + amount: Optional[float] = None + billing_date: Optional[date] = None + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index cdea5ce..25f5e1a 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -1,37 +1,71 @@ +import base64 import json import os -from typing import Any, Dict +import re +from pathlib import Path +from typing import Any, Dict, Tuple from openai import AsyncOpenAI +from openai import NotFoundError as OpenAINotFoundError + +AI_CONFIG_PATH = Path("data/ai_config.json") +AI_CONFIGS_PATH = Path("data/ai_configs.json") -_client: AsyncOpenAI | None = None - - -def get_ai_client() -> AsyncOpenAI: +def get_active_ai_config() -> Dict[str, Any]: """ - Create (or reuse) a singleton AsyncOpenAI client. - - The client is configured via: - - AI_API_KEY / OPENAI_API_KEY - - AI_BASE_URL (optional, defaults to official OpenAI endpoint) - - AI_MODEL (optional, defaults to gpt-4.1-mini or a similar capable model) + 从 data/ai_configs.json 读取当前选用的配置;若无则从旧版 ai_config.json 迁移并返回。 + 供 router 与内部调用。 """ - global _client - if _client is not None: - return _client + defaults = { + "id": "", + "name": "", + "provider": "OpenAI", + "api_key": "", + "base_url": "", + "model_name": "gpt-4o-mini", + "temperature": 0.2, + "system_prompt_override": "", + } + if AI_CONFIGS_PATH.exists(): + try: + data = json.loads(AI_CONFIGS_PATH.read_text(encoding="utf-8")) + configs = data.get("configs") or [] + active_id = data.get("active_id") or "" + for c in configs: + if c.get("id") == active_id: + return {**defaults, **c} + if configs: + return {**defaults, **configs[0]} + except Exception: + pass + # 兼容旧版单文件 + if AI_CONFIG_PATH.exists(): + try: + data = json.loads(AI_CONFIG_PATH.read_text(encoding="utf-8")) + return {**defaults, **data} + except Exception: + pass + if not defaults.get("api_key"): + defaults["api_key"] = os.getenv("AI_API_KEY") or os.getenv("OPENAI_API_KEY") or "" + if not defaults.get("base_url") and os.getenv("AI_BASE_URL"): + defaults["base_url"] = os.getenv("AI_BASE_URL") + if defaults.get("model_name") == "gpt-4o-mini" and os.getenv("AI_MODEL"): + defaults["model_name"] = os.getenv("AI_MODEL") + return defaults - api_key = os.getenv("AI_API_KEY") or os.getenv("OPENAI_API_KEY") + +def _load_ai_config() -> Dict[str, Any]: + """当前生效的 AI 配置(供需求解析、发票识别等使用)。""" + return get_active_ai_config() + + +def _client_from_config(config: Dict[str, Any]) -> AsyncOpenAI: + api_key = (config.get("api_key") or "").strip() if not api_key: - raise RuntimeError("AI_API_KEY or OPENAI_API_KEY must be set in environment.") - - base_url = os.getenv("AI_BASE_URL") # can point to OpenAI, DeepSeek, Qwen, etc. - - _client = AsyncOpenAI( - api_key=api_key, - base_url=base_url or None, - ) - return _client + raise RuntimeError("AI API Key 未配置,请在 设置 → AI 模型配置 中填写。") + base_url = (config.get("base_url") or "").strip() or None + return AsyncOpenAI(api_key=api_key, base_url=base_url) def _build_requirement_prompt(raw_text: str) -> str: @@ -71,38 +105,139 @@ def _build_requirement_prompt(raw_text: str) -> str: async def analyze_requirement(raw_text: str) -> Dict[str, Any]: """ Call the AI model to analyze customer requirements. - - Returns a Python dict matching the JSON structure described - in `_build_requirement_prompt`. + Reads config from data/ai_config.json (and env fallback) on every request. """ - client = get_ai_client() - model = os.getenv("AI_MODEL", "gpt-4.1-mini") + import logging + logger = logging.getLogger(__name__) + + config = _load_ai_config() + client = _client_from_config(config) + model = config.get("model_name") or "gpt-4o-mini" + temperature = float(config.get("temperature", 0.2)) + system_override = (config.get("system_prompt_override") or "").strip() + + logger.info("AI 需求解析: 调用模型 %s,输入长度 %d 字符", model, len(raw_text)) prompt = _build_requirement_prompt(raw_text) - - completion = await client.chat.completions.create( - model=model, - response_format={"type": "json_object"}, - messages=[ - { - "role": "system", - "content": ( - "你是一名严谨的系统架构师,只能输出有效的 JSON,不要输出任何解释文字。" - ), - }, - { - "role": "user", - "content": prompt, - }, - ], - temperature=0.2, + system_content = ( + system_override + if system_override + else "你是一名严谨的系统架构师,只能输出有效的 JSON,不要输出任何解释文字。" ) + try: + completion = await client.chat.completions.create( + model=model, + response_format={"type": "json_object"}, + messages=[ + {"role": "system", "content": system_content}, + {"role": "user", "content": prompt}, + ], + temperature=temperature, + ) + except OpenAINotFoundError as e: + raise RuntimeError( + "当前配置的模型不存在或无权访问。请在 设置 → AI 模型配置 中确认「模型名称」与当前提供商一致(如阿里云使用 qwen 系列、OpenAI 使用 gpt-4o-mini 等)。" + ) from e + content = completion.choices[0].message.content or "{}" try: - data: Dict[str, Any] = json.loads(content) + data: Any = json.loads(content) except json.JSONDecodeError as exc: + logger.error("AI 返回非 JSON,片段: %s", (content or "")[:200]) raise RuntimeError(f"AI 返回的内容不是合法 JSON:{content}") from exc + # Some models return a list (e.g. modules only); normalize to expected dict shape + if isinstance(data, list): + data = { + "modules": data, + "total_estimated_hours": None, + "total_amount": None, + "notes": None, + } + if not isinstance(data, dict): + data = {} + + mods = data.get("modules") or [] + logger.info("AI 需求解析完成: 模块数 %d", len(mods) if isinstance(mods, list) else 0) return data + +async def test_connection() -> str: + """使用当前选用配置测试连接。""" + return await test_connection_with_config(get_active_ai_config()) + + +async def test_connection_with_config(config: Dict[str, Any]) -> str: + """ + 使用指定配置发送简单补全以验证 API Key 与 Base URL。 + 供测试当前配置或指定 config_id 时使用。 + """ + client = _client_from_config(config) + model = config.get("model_name") or "gpt-4o-mini" + try: + completion = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": "Hello"}], + max_tokens=50, + ) + except OpenAINotFoundError as e: + raise RuntimeError( + "当前配置的模型不存在或无权访问。请在 设置 → AI 模型配置 中确认「模型名称」(如阿里云使用 qwen 系列)。" + ) from e + return (completion.choices[0].message.content or "").strip() or "OK" + + +async def extract_invoice_metadata(image_bytes: bytes, mime: str = "image/jpeg") -> Tuple[float | None, str | None]: + """ + Use AI vision to extract total amount and invoice date from an image. + Returns (amount, date_yyyy_mm_dd). On any error or unsupported model, returns (None, None). + """ + config = _load_ai_config() + api_key = (config.get("api_key") or "").strip() + if not api_key: + return (None, None) + try: + client = _client_from_config(config) + model = config.get("model_name") or "gpt-4o-mini" + b64 = base64.b64encode(image_bytes).decode("ascii") + data_url = f"data:{mime};base64,{b64}" + prompt = ( + "从这张发票/收据图片中识别并提取:1) 价税合计/总金额(数字,不含货币符号);2) 开票日期(格式 YYYY-MM-DD)。" + "只返回 JSON,不要其他文字,格式:{\"amount\": 数字或null, \"date\": \"YYYY-MM-DD\" 或 null}。" + ) + completion = await client.chat.completions.create( + model=model, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": data_url}}, + ], + } + ], + max_tokens=150, + ) + content = (completion.choices[0].message.content or "").strip() + if not content: + return (None, None) + # Handle markdown code block + if "```" in content: + content = re.sub(r"^.*?```(?:json)?\s*", "", content).strip() + content = re.sub(r"\s*```.*$", "", content).strip() + data = json.loads(content) + amount_raw = data.get("amount") + date_raw = data.get("date") + amount = None + if amount_raw is not None: + try: + amount = float(amount_raw) + except (TypeError, ValueError): + pass + date_str = None + if isinstance(date_raw, str) and re.match(r"\d{4}-\d{2}-\d{2}", date_raw): + date_str = date_raw[:10] + return (amount, date_str) + except Exception: + return (None, None) diff --git a/backend/app/services/cloud_doc_service.py b/backend/app/services/cloud_doc_service.py new file mode 100644 index 0000000..78a21f1 --- /dev/null +++ b/backend/app/services/cloud_doc_service.py @@ -0,0 +1,315 @@ +""" +云文档集成:飞书、语雀、腾讯文档的文档创建/更新。 +统一以 Markdown 为中间格式,由各平台 API 写入。 + +扩展建议:可增加「月度财务明细表」自动导出——每月在飞书/腾讯文档生成表格, +插入当月发票等附件预览链接,供财务查看(需对接财务记录与附件列表)。 +""" +from typing import Any, Dict, List, Tuple + +import httpx + +FEISHU_BASE = "https://open.feishu.cn" +YUQUE_BASE = "https://www.yuque.com/api/v2" + + +async def get_feishu_tenant_token(app_id: str, app_secret: str) -> str: + """获取飞书 tenant_access_token。""" + async with httpx.AsyncClient() as client: + r = await client.post( + f"{FEISHU_BASE}/open-apis/auth/v3/tenant_access_token/internal", + json={"app_id": app_id, "app_secret": app_secret}, + timeout=10.0, + ) + r.raise_for_status() + data = r.json() + if data.get("code") != 0: + raise RuntimeError(data.get("msg", "飞书鉴权失败")) + return data["tenant_access_token"] + + +def _feishu_text_block_elements(md: str) -> List[Dict[str, Any]]: + """将 Markdown 转为飞书文本块 elements(按行拆成 textRun,简单实现)。""" + elements: List[Dict[str, Any]] = [] + for line in md.split("\n"): + line = line.rstrip() + if not line: + elements.append({"type": "textRun", "text_run": {"text": "\n"}}) + else: + elements.append({"type": "textRun", "text_run": {"text": line + "\n"}}) + if not elements: + elements.append({"type": "textRun", "text_run": {"text": " "}}) + return elements + + +async def feishu_create_doc( + token: str, title: str, body_md: str, folder_token: str = "" +) -> Tuple[str, str]: + """ + 创建飞书文档并写入内容。返回 (document_id, url)。 + 使用 docx/v1:创建文档后向根块下添加子块写入 Markdown 文本。 + """ + async with httpx.AsyncClient() as client: + headers = {"Authorization": f"Bearer {token}"} + # 1. 创建文档 + create_body: Dict[str, Any] = {"title": title[:50] or "未命名文档"} + if folder_token: + create_body["folder_token"] = folder_token + r = await client.post( + f"{FEISHU_BASE}/open-apis/docx/v1/documents", + headers=headers, + json=create_body, + timeout=15.0, + ) + r.raise_for_status() + data = r.json() + if data.get("code") != 0: + raise RuntimeError(data.get("msg", "飞书创建文档失败")) + doc = data.get("data", {}) + document_id = doc.get("document", {}).get("document_id") + if not document_id: + raise RuntimeError("飞书未返回 document_id") + url = doc.get("document", {}).get("url", "") + # 2. 根块 ID 即 document_id(飞书约定) + block_id = document_id + # 3. 添加子块(内容) + elements = _feishu_text_block_elements(body_md) + # 单块有长度限制,分批写入多块 + chunk_size = 3000 + for i in range(0, len(elements), chunk_size): + chunk = elements[i : i + chunk_size] + body_json = {"children": [{"block_type": "text", "text": {"elements": chunk}}], "index": -1} + r3 = await client.post( + f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children", + headers=headers, + json=body_json, + timeout=15.0, + ) + r3.raise_for_status() + res = r3.json() + if res.get("code") != 0: + raise RuntimeError(res.get("msg", "飞书写入块失败")) + # 下一批挂在刚创建的块下 + new_items = res.get("data", {}).get("children", []) + if new_items: + block_id = new_items[0].get("block_id", block_id) + return document_id, url or f"https://feishu.cn/docx/{document_id}" + + +async def feishu_update_doc(token: str, document_id: str, body_md: str) -> str: + """ + 更新飞书文档内容:获取现有块并批量更新首个文本块,或追加新块。 + 返回文档 URL。 + """ + async with httpx.AsyncClient() as client: + headers = {"Authorization": f"Bearer {token}"} + r = await client.get( + f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks", + headers=headers, + params={"document_id": document_id}, + timeout=10.0, + ) + r.raise_for_status() + data = r.json() + if data.get("code") != 0: + raise RuntimeError(data.get("msg", "飞书获取块失败")) + items = data.get("data", {}).get("items", []) + elements = _feishu_text_block_elements(body_md) + if items: + first_id = items[0].get("block_id") + if first_id: + # 批量更新:只更新第一个块的内容 + update_body = { + "requests": [ + { + "request_type": "blockUpdate", + "block_id": first_id, + "update_text": {"elements": elements}, + } + ] + } + r2 = await client.patch( + f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks/batch_update", + headers=headers, + json=update_body, + timeout=15.0, + ) + r2.raise_for_status() + if r2.json().get("code") != 0: + # 若 PATCH 不支持该块类型,则追加新块 + pass + else: + return f"https://feishu.cn/docx/{document_id}" + # 无块或更新失败:在根下追加子块 + block_id = document_id + for i in range(0, len(elements), 3000): + chunk = elements[i : i + 3000] + body_json = {"children": [{"block_type": "text", "text": {"elements": chunk}}], "index": -1} + r3 = await client.post( + f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children", + headers=headers, + json=body_json, + timeout=15.0, + ) + r3.raise_for_status() + res = r3.json() + if res.get("data", {}).get("children"): + block_id = res["data"]["children"][0].get("block_id", block_id) + return f"https://feishu.cn/docx/{document_id}" + + +# --------------- 语雀 --------------- + + +async def yuque_create_doc( + token: str, repo_id_or_namespace: str, title: str, body_md: str +) -> Tuple[str, str]: + """ + 在语雀知识库创建文档。repo_id_or_namespace 可为 repo_id 或 namespace(如 user/repo)。 + 返回 (doc_id, url)。 + """ + async with httpx.AsyncClient() as client: + headers = { + "X-Auth-Token": token, + "Content-Type": "application/json", + "User-Agent": "OpsCore-CloudDoc/1.0", + } + # 若为 namespace 需先解析为 repo_id(语雀 API 创建文档用 repo_id) + repo_id = repo_id_or_namespace + if "/" in repo_id_or_namespace: + r_repo = await client.get( + f"{YUQUE_BASE}/repos/{repo_id_or_namespace}", + headers=headers, + timeout=10.0, + ) + if r_repo.status_code == 200 and r_repo.json().get("data"): + repo_id = str(r_repo.json()["data"]["id"]) + r = await client.post( + f"{YUQUE_BASE}/repos/{repo_id}/docs", + headers=headers, + json={ + "title": title[:100] or "未命名", + "body": body_md, + "format": "markdown", + }, + timeout=15.0, + ) + r.raise_for_status() + data = r.json() + doc = data.get("data", {}) + doc_id = str(doc.get("id", "")) + url = doc.get("url", "") + if not url and doc.get("slug"): + url = f"https://www.yuque.com/{doc.get('namespace', '').replace('/', '/')}/{doc.get('slug', '')}" + return doc_id, url or "" + + +async def yuque_update_doc( + token: str, repo_id_or_namespace: str, doc_id: str, title: str, body_md: str +) -> str: + """更新语雀文档。返回文档 URL。""" + async with httpx.AsyncClient() as client: + headers = { + "X-Auth-Token": token, + "Content-Type": "application/json", + "User-Agent": "OpsCore-CloudDoc/1.0", + } + r = await client.put( + f"{YUQUE_BASE}/repos/{repo_id_or_namespace}/docs/{doc_id}", + headers=headers, + json={ + "title": title[:100] or "未命名", + "body": body_md, + "format": "markdown", + }, + timeout=15.0, + ) + r.raise_for_status() + data = r.json() + doc = data.get("data", {}) + return doc.get("url", "") or f"https://www.yuque.com/docs/{doc_id}" + + +async def yuque_list_docs(token: str, repo_id_or_namespace: str) -> List[Dict[str, Any]]: + """获取知识库文档列表。""" + async with httpx.AsyncClient() as client: + headers = { + "X-Auth-Token": token, + "User-Agent": "OpsCore-CloudDoc/1.0", + } + r = await client.get( + f"{YUQUE_BASE}/repos/{repo_id_or_namespace}/docs", + headers=headers, + timeout=10.0, + ) + r.raise_for_status() + data = r.json() + return data.get("data", []) + + +# --------------- 腾讯文档(占位) --------------- + + +async def tencent_create_doc(client_id: str, client_secret: str, title: str, body_md: str) -> Tuple[str, str]: + """ + 腾讯文档需 OAuth 用户授权与文件创建 API,此处返回占位。 + 正式接入需在腾讯开放平台创建应用并走 OAuth 流程。 + """ + raise RuntimeError( + "腾讯文档 Open API 需在开放平台配置 OAuth 并获取用户授权;当前版本请先用飞书或语雀推送。" + ) + + +# --------------- 统一入口 --------------- + + +class CloudDocManager: + """统一封装:读取配置并执行创建/更新,支持增量(有 cloud_doc_id 则更新)。""" + + def __init__(self, credentials: Dict[str, Dict[str, str]]): + self.credentials = credentials + + async def push_markdown( + self, + platform: str, + title: str, + body_md: str, + existing_doc_id: str | None = None, + extra: Dict[str, str] | None = None, + ) -> Tuple[str, str]: + """ + 将 Markdown 推送到指定平台。若 existing_doc_id 存在则更新,否则创建。 + 返回 (cloud_doc_id, url)。 + extra: 平台相关参数,如 yuque 的 default_repo。 + """ + extra = extra or {} + if platform == "feishu": + cred = self.credentials.get("feishu") or {} + app_id = (cred.get("app_id") or "").strip() + app_secret = (cred.get("app_secret") or "").strip() + if not app_id or not app_secret: + raise RuntimeError("请先在设置中配置飞书 App ID 与 App Secret") + token = await get_feishu_tenant_token(app_id, app_secret) + if existing_doc_id: + url = await feishu_update_doc(token, existing_doc_id, body_md) + return existing_doc_id, url + return await feishu_create_doc(token, title, body_md) + + if platform == "yuque": + cred = self.credentials.get("yuque") or {} + token = (cred.get("token") or "").strip() + default_repo = (cred.get("default_repo") or extra.get("repo") or "").strip() + if not token: + raise RuntimeError("请先在设置中配置语雀 Personal Access Token") + if not default_repo: + raise RuntimeError("请先在设置中配置语雀默认知识库(namespace,如 user/repo)") + if existing_doc_id: + url = await yuque_update_doc(token, default_repo, existing_doc_id, title, body_md) + return existing_doc_id, url + return await yuque_create_doc(token, default_repo, title, body_md) + + if platform == "tencent": + await tencent_create_doc("", "", title, body_md) + return "", "" + + raise RuntimeError(f"不支持的平台: {platform}") diff --git a/backend/app/services/doc_service.py b/backend/app/services/doc_service.py index 369cc4d..ac31694 100644 --- a/backend/app/services/doc_service.py +++ b/backend/app/services/doc_service.py @@ -44,7 +44,12 @@ async def generate_quote_excel( # Assume the first worksheet is used for the quote. ws = wb.active - modules: List[Dict[str, Any]] = project_data.get("modules", []) + raw_modules: List[Any] = project_data.get("modules", []) + # Normalize: only dicts have .get(); coerce others to a minimal dict + modules: List[Dict[str, Any]] = [ + m if isinstance(m, dict) else {"name": str(m) or f"模块 {i}"} + for i, m in enumerate(raw_modules, start=1) + ] total_amount = project_data.get("total_amount") total_hours = project_data.get("total_estimated_hours") notes = project_data.get("notes") @@ -157,7 +162,11 @@ async def generate_quote_pdf_from_data( c.setFont("Helvetica", 10) - modules: List[Dict[str, Any]] = project_data.get("modules", []) + raw_modules: List[Any] = project_data.get("modules", []) + modules = [ + m if isinstance(m, dict) else {"name": str(m) or f"模块 {i}"} + for i, m in enumerate(raw_modules, start=1) + ] for idx, module in enumerate(modules, start=1): name = module.get("name", "") hours = module.get("estimated_hours", "") diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index e98eb7c..e937ea7 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,17 +1,34 @@ import asyncio import email +import hashlib import imaplib +import logging import os -from datetime import datetime +import re +import sqlite3 +import ssl +from datetime import date, datetime from email.header import decode_header from pathlib import Path from typing import Any, Dict, List, Tuple +# Ensure IMAP ID command is recognised by imaplib so we can spoof a +# desktop mail client (Foxmail/Outlook) for providers like NetEase/163. +imaplib.Commands["ID"] = ("NONAUTH", "AUTH", "SELECTED") + +from sqlalchemy.orm import Session + from backend.app.db import SessionLocal from backend.app.models import FinanceRecord FINANCE_BASE_DIR = Path("data/finance") +SYNC_DB_PATH = Path("data/finance/sync_history.db") + +# Folder names for classification (invoices, receipts, statements) +INVOICES_DIR = "invoices" +RECEIPTS_DIR = "receipts" +STATEMENTS_DIR = "statements" def _decode_header_value(value: str | None) -> str: @@ -27,17 +44,21 @@ def _decode_header_value(value: str | None) -> str: return decoded -def _classify_type(subject: str) -> str: +def _classify_type(subject: str, filename: str) -> str: """ - Classify finance document type based on subject keywords. + Classify finance document type. Returns: invoices, receipts, statements, others. + Maps to folders: invoices/, receipts/, statements/. """ - subject_lower = subject.lower() + text = f"{subject} {filename}".lower() # 发票 / 开票类 - if any(k in subject for k in ["发票", "开票", "票据", "invoice"]): + if any(k in text for k in ["发票", "开票", "票据", "invoice", "fapiao"]): return "invoices" + # 回执 + if any(k in text for k in ["回执", "签收单", "receipt"]): + return "receipts" # 银行流水 / 账户明细 / 对公活期等 if any( - k in subject + k in text for k in [ "流水", "活期", @@ -50,9 +71,7 @@ def _classify_type(subject: str) -> str: "statement", ] ): - return "bank_records" - if any(k in subject for k in ["回执", "receipt"]): - return "receipts" + return "statements" return "others" @@ -71,132 +90,474 @@ def _parse_email_date(msg: email.message.Message) -> datetime: return dt +def _run_invoice_ocr_sync(file_path: str, mime: str, raw_bytes: bytes) -> Tuple[float | None, str | None]: + """Run extract_invoice_metadata from a sync context (new event loop). Handles PDF via first page image.""" + from backend.app.services.ai_service import extract_invoice_metadata + from backend.app.services.invoice_upload import _pdf_first_page_to_image + + if "pdf" in (mime or "").lower() or Path(file_path).suffix.lower() == ".pdf": + img_result = _pdf_first_page_to_image(raw_bytes) + if img_result: + image_bytes, img_mime = img_result + raw_bytes, mime = image_bytes, img_mime + # else keep raw_bytes and try anyway (may fail) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(extract_invoice_metadata(raw_bytes, mime)) + finally: + loop.close() + + +def _rename_invoice_file( + file_path: str, + amount: float | None, + billing_date: date | None, +) -> Tuple[str, str]: + """ + Rename invoice file to YYYYMMDD_金额_原文件名. + Returns (new_file_name, new_file_path). + """ + path = Path(file_path) + if not path.exists(): + return (path.name, file_path) + date_str = (billing_date or date.today()).strftime("%Y%m%d") + amount_str = f"{amount:.2f}" if amount is not None else "0.00" + # Sanitize original name: take stem, limit length + orig_stem = path.stem[: 80] if len(path.stem) > 80 else path.stem + suffix = path.suffix + new_name = f"{date_str}_{amount_str}_{orig_stem}{suffix}" + new_path = path.parent / new_name + counter = 1 + while new_path.exists(): + new_path = path.parent / f"{date_str}_{amount_str}_{orig_stem}_{counter}{suffix}" + counter += 1 + path.rename(new_path) + return (new_path.name, str(new_path)) + + +def _ensure_sync_history_table(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS attachment_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT, + file_hash TEXT NOT NULL, + month TEXT, + doc_type TEXT, + file_name TEXT, + file_path TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(message_id, file_hash) + ) + """ + ) + conn.commit() + + +def _has_sync_history() -> bool: + """是否有过同步记录;无记录视为首次同步,需拉全量;有记录则只拉增量(UNSEEN)。""" + if not SYNC_DB_PATH.exists(): + return False + try: + conn = sqlite3.connect(SYNC_DB_PATH) + try: + cur = conn.execute("SELECT 1 FROM attachment_history LIMIT 1") + return cur.fetchone() is not None + finally: + conn.close() + except Exception: + return False + + def _save_attachment( msg: email.message.Message, month_str: str, - doc_type: str, -) -> List[Tuple[str, str]]: +) -> List[Tuple[str, str, str, bytes, str]]: """ - Save PDF/image attachments and return list of (file_name, file_path). + Save PDF/image attachments. + Returns list of (file_name, file_path, mime, raw_bytes, doc_type). + raw_bytes kept for invoice OCR when doc_type == invoices. + + 同时使用 data/finance/sync_history.db 做增量去重: + - 以 (message_id, MD5(content)) 为唯一键,避免重复保存相同附件。 """ - saved: List[Tuple[str, str]] = [] - base_dir = _ensure_month_dir(month_str, doc_type) + saved: List[Tuple[str, str, str, bytes, str]] = [] - for part in msg.walk(): - content_disposition = part.get("Content-Disposition", "") - if "attachment" not in content_disposition: - continue + msg_id = msg.get("Message-ID") or "" + subject = _decode_header_value(msg.get("Subject")) - filename = part.get_filename() - filename = _decode_header_value(filename) - if not filename: - continue + SYNC_DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(SYNC_DB_PATH) + try: + _ensure_sync_history_table(conn) - content_type = part.get_content_type() - maintype = part.get_content_maintype() + for part in msg.walk(): + content_disposition = part.get("Content-Disposition", "") + if "attachment" not in content_disposition: + continue - # Accept pdf and common images - if maintype not in ("application", "image"): - continue + filename = part.get_filename() + filename = _decode_header_value(filename) + if not filename: + continue - data = part.get_payload(decode=True) - if not data: - continue + ext = Path(filename).suffix.lower() + if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".xlsx"): + continue - file_path = base_dir / filename - # Ensure unique filename - counter = 1 - while file_path.exists(): - stem = file_path.stem - suffix = file_path.suffix - file_path = base_dir / f"{stem}_{counter}{suffix}" - counter += 1 + maintype = part.get_content_maintype() + if maintype not in ("application", "image"): + continue - with open(file_path, "wb") as f: - f.write(data) + data = part.get_payload(decode=True) + if not data: + continue - saved.append((filename, str(file_path))) + # 分类:基于主题 + 文件名 + doc_type = _classify_type(subject, filename) + base_dir = _ensure_month_dir(month_str, doc_type) + + # 增量去重:根据 (message_id, md5) 判断是否已同步过 + file_hash = hashlib.md5(data).hexdigest() # nosec - content hash only + cur = conn.execute( + "SELECT 1 FROM attachment_history WHERE message_id = ? AND file_hash = ?", + (msg_id, file_hash), + ) + if cur.fetchone(): + continue + + mime = part.get_content_type() or "application/octet-stream" + file_path = base_dir / filename + counter = 1 + while file_path.exists(): + stem, suffix = file_path.stem, file_path.suffix + file_path = base_dir / f"{stem}_{counter}{suffix}" + counter += 1 + + file_path.write_bytes(data) + + conn.execute( + """ + INSERT OR IGNORE INTO attachment_history + (message_id, file_hash, month, doc_type, file_name, file_path) + VALUES (?, ?, ?, ?, ?, ?) + """, + (msg_id, file_hash, month_str, doc_type, file_path.name, str(file_path)), + ) + + saved.append((file_path.name, str(file_path), mime, data, doc_type)) + + finally: + conn.commit() + conn.close() return saved +def _decode_imap_utf7(s: str | bytes) -> str: + """Decode IMAP4 UTF-7 mailbox name (RFC 3501). Returns decoded string.""" + if isinstance(s, bytes): + s = s.decode("ascii", errors="replace") + if "&" not in s: + return s + parts = s.split("&") + out = [parts[0]] + for i in range(1, len(parts)): + chunk = parts[i] + if "-" in chunk: + u, rest = chunk.split("-", 1) + if u == "": + out.append("&") + else: + try: + # IMAP UTF-7: &BASE64- where BASE64 is modified (,+ instead of /,=) + pad = (4 - len(u) % 4) % 4 + b = (u + "=" * pad).translate(str.maketrans(",+", "/=")) + decoded = __import__("base64").b64decode(b).decode("utf-16-be") + out.append(decoded) + except Exception: + out.append("&" + chunk) + out.append(rest) + else: + out.append("&" + chunk) + return "".join(out) + + +def _parse_list_response(data: List[bytes]) -> List[Tuple[str, str]]: + """Parse imap.list() response to [(raw_name, decoded_name), ...]. Format: (flags) \"delim\" \"mailbox\".""" + import shlex + result: List[Tuple[str, str]] = [] + for line in data: + if not isinstance(line, bytes): + continue + try: + line_str = line.decode("ascii", errors="replace") + except Exception: + continue + try: + parts = shlex.split(line_str) + except ValueError: + continue + if not parts: + continue + # Mailbox name is the last part (RFC 3501 LIST: (attrs) delim name) + raw = parts[-1] + decoded = _decode_imap_utf7(raw) + result.append((raw, decoded)) + return result + + +def _list_mailboxes(imap: imaplib.IMAP4_SSL) -> List[Tuple[str, str]]: + """List all mailboxes. Returns [(raw_name, decoded_name), ...].""" + status, data = imap.list() + if status != "OK" or not data: + return [] + return _parse_list_response(data) + + +def list_mailboxes_for_config(host: str, port: int, user: str, password: str) -> List[Tuple[str, str]]: + """Connect and list all mailboxes (for dropdown). Returns [(raw_name, decoded_name), ...].""" + with imaplib.IMAP4_SSL(host, int(port)) as imap: + imap.login(user, password) + return _list_mailboxes(imap) + + +def _select_mailbox(imap: imaplib.IMAP4_SSL, mailbox: str) -> bool: + """ + Robust mailbox selection with deep discovery scan. + + Strategy: + 1. LIST all folders, log raw lines for debugging. + 2. Look for entry containing '\\Inbox' flag; if found, SELECT that folder. + 3. Try standard candidates: user-configured name / INBOX / common UTF-7 收件箱编码. + 4. As last resort, attempt SELECT on every listed folder and log which succeed/fail. + """ + logger = logging.getLogger(__name__) + + name = (mailbox or "INBOX").strip() or "INBOX" + + # 1) Discovery scan: list all folders and log raw entries + try: + status, data = imap.list() + if status != "OK" or not data: + logger.warning("IMAP LIST returned no data or non-OK status: %s", status) + data = [] + except Exception as exc: + logger.error("IMAP LIST failed: %s", exc) + data = [] + + logger.info("IMAP Discovery Scan: listing all folders for mailbox=%s", name) + for raw in data: + logger.info("IMAP FOLDER RAW: %r", raw) + + # 2) 优先按 \\Inbox 属性查找“真正的收件箱” + inbox_candidates: list[str] = [] + for raw in data: + line = raw.decode("utf-8", errors="ignore") if isinstance(raw, bytes) else str(raw) + if "\\Inbox" not in line: + continue + m = re.search(r'"([^"]+)"\s*$', line) + if not m: + continue + folder_name = m.group(1) + inbox_candidates.append(folder_name) + + # 3) 补充常规候选:配置名 / INBOX / 常见 UTF-7 收件箱编码 + primary_names = [name, "INBOX"] + utf7_names = ["&XfJT0ZTx-"] + for nm in primary_names + utf7_names: + if nm not in inbox_candidates: + inbox_candidates.append(nm) + + logger.info("IMAP Inbox candidate list (ordered): %r", inbox_candidates) + + # 4) 依次尝试候选收件箱 + for candidate in inbox_candidates: + for readonly in (False, True): + try: + status, _ = imap.select(candidate, readonly=readonly) + logger.info( + "IMAP SELECT candidate=%r readonly=%s -> %s", candidate, readonly, status + ) + if status == "OK": + return True + except Exception as exc: + logger.warning( + "IMAP SELECT failed for candidate=%r readonly=%s: %s", + candidate, + readonly, + exc, + ) + + # 5) 最后手段:尝试 LIST 返回的每一个文件夹 + logger.info("IMAP Fallback: trying SELECT on every listed folder...") + for raw in data: + line = raw.decode("utf-8", errors="ignore") if isinstance(raw, bytes) else str(raw) + m = re.search(r'"([^"]+)"\s*$', line) + if not m: + continue + folder_name = m.group(1) + for readonly in (False, True): + try: + status, _ = imap.select(folder_name, readonly=readonly) + logger.info( + "IMAP SELECT fallback folder=%r readonly=%s -> %s", + folder_name, + readonly, + status, + ) + if status == "OK": + return True + except Exception as exc: + logger.warning( + "IMAP SELECT fallback failed for folder=%r readonly=%s: %s", + folder_name, + readonly, + exc, + ) + + logger.error("IMAP: unable to SELECT any inbox-like folder for mailbox=%s", name) + return False + + +def _sync_one_account(config: Dict[str, Any], db: Session, results: List[Dict[str, Any]]) -> None: + host = config.get("host") + user = config.get("user") + password = config.get("password") + port = int(config.get("port", 993)) + mailbox = (config.get("mailbox") or "INBOX").strip() or "INBOX" + + if not all([host, user, password]): + return + + # Use strict TLS context for modern protocols (TLS 1.2+) + tls_context = ssl.create_default_context() + + with imaplib.IMAP4_SSL(host, port, ssl_context=tls_context) as imap: + # Enable low-level IMAP debug output to backend logs to help diagnose + # handshake / protocol / mailbox selection issues with specific providers. + imap.debug = 4 + imap.login(user, password) + # NetEase / 163 等会对未知客户端静默限制 SELECT,这里通过 ID 命令伪装为常见桌面客户端。 + try: + logger = logging.getLogger(__name__) + id_str = ( + '("name" "Foxmail" ' + '"version" "7.2.25.170" ' + '"vendor" "Tencent" ' + '"os" "Windows" ' + '"os-version" "10.0")' + ) + logger.info("IMAP sending Foxmail-style ID: %s", id_str) + # Use low-level command so it works across Python versions. + typ, dat = imap._command("ID", id_str) # type: ignore[attr-defined] + logger.info("IMAP ID command result: %s %r", typ, dat) + except Exception as exc: + # ID 失败不应阻断登录,只记录日志,方便后续排查。 + logging.getLogger(__name__).warning("IMAP ID command failed: %s", exc) + if not _select_mailbox(imap, mailbox): + raise RuntimeError( + f"无法选择邮箱「{mailbox}」,请检查该账户的 Mailbox 配置(如 163 使用 INBOX)" + ) + + # 首次同步(历史库无记录):拉取全部邮件中的附件,由 attachment_history 去重 + # 已有历史:只拉取未读邮件,避免重复拉取 + is_first_sync = not _has_sync_history() + search_criterion = "ALL" if is_first_sync else "UNSEEN" + logging.getLogger(__name__).info( + "Finance sync: %s (criterion=%s)", + "全量" if is_first_sync else "增量", + search_criterion, + ) + status, data = imap.search(None, search_criterion) + if status != "OK": + return + + id_list = data[0].split() + for msg_id in id_list: + status, msg_data = imap.fetch(msg_id, "(RFC822)") + if status != "OK": + continue + + raw_email = msg_data[0][1] + msg = email.message_from_bytes(raw_email) + dt = _parse_email_date(msg) + month_str = dt.strftime("%Y-%m") + + saved = _save_attachment(msg, month_str) + for file_name, file_path, mime, raw_bytes, doc_type in saved: + final_name = file_name + final_path = file_path + amount = None + billing_date = None + + if doc_type == "invoices": + amount, date_str = _run_invoice_ocr_sync(file_path, mime, raw_bytes) + if date_str: + try: + billing_date = date.fromisoformat(date_str[:10]) + except ValueError: + billing_date = date.today() + else: + billing_date = date.today() + final_name, final_path = _rename_invoice_file( + file_path, amount, billing_date + ) + + record = FinanceRecord( + month=month_str, + type=doc_type, + file_name=final_name, + file_path=final_path, + amount=amount, + billing_date=billing_date, + ) + db.add(record) + db.flush() + results.append({ + "id": record.id, + "month": record.month, + "type": record.type, + "file_name": record.file_name, + "file_path": record.file_path, + }) + + imap.store(msg_id, "+FLAGS", "\\Seen \\Flagged") + + async def sync_finance_emails() -> List[Dict[str, Any]]: """ - Connect to IMAP, fetch unread finance-related emails, download attachments, - save to filesystem and record FinanceRecord entries. + Sync from all active email configs (data/email_configs.json). + Falls back to env vars if no configs. Classifies into invoices/, receipts/, statements/. + Invoices are renamed to YYYYMMDD_金额_原文件名 using OCR. """ def _sync() -> List[Dict[str, Any]]: - host = os.getenv("IMAP_HOST") - user = os.getenv("IMAP_USER") - password = os.getenv("IMAP_PASSWORD") - port = int(os.getenv("IMAP_PORT", "993")) - mailbox = os.getenv("IMAP_MAILBOX", "INBOX") + from backend.app.routers.email_configs import get_email_configs_for_sync - if not all([host, user, password]): - raise RuntimeError("IMAP_HOST, IMAP_USER, IMAP_PASSWORD must be set.") + configs = get_email_configs_for_sync() + if not configs: + raise RuntimeError("未配置邮箱。请在 设置 → 邮箱账户 中添加,或配置 IMAP_* 环境变量。") results: List[Dict[str, Any]] = [] + errors: List[str] = [] + db = SessionLocal() + try: + for config in configs: + try: + _sync_one_account(config, db, results) + except Exception as e: + # 不让单个账户的异常中断全部同步,记录错误并继续其他账户。 + user = config.get("user", "") or config.get("id", "") + errors.append(f"同步账户 {user} 失败: {e}") + db.commit() + finally: + db.close() - with imaplib.IMAP4_SSL(host, port) as imap: - imap.login(user, password) - imap.select(mailbox) - - # Search for UNSEEN emails with finance related keywords in subject. - # Note: IMAP SEARCH is limited; here we search UNSEEN first then filter in Python. - status, data = imap.search(None, "UNSEEN") - if status != "OK": - return results - - id_list = data[0].split() - db = SessionLocal() - try: - for msg_id in id_list: - status, msg_data = imap.fetch(msg_id, "(RFC822)") - if status != "OK": - continue - - raw_email = msg_data[0][1] - msg = email.message_from_bytes(raw_email) - - subject = _decode_header_value(msg.get("Subject")) - doc_type = _classify_type(subject) - - # Filter by keywords first - if doc_type == "others": - continue - - dt = _parse_email_date(msg) - month_str = dt.strftime("%Y-%m") - - saved_files = _save_attachment(msg, month_str, doc_type) - for file_name, file_path in saved_files: - record = FinanceRecord( - month=month_str, - type=doc_type, - file_name=file_name, - file_path=file_path, - ) - # NOTE: created_at defaults at DB layer - db.add(record) - db.flush() - - results.append( - { - "id": record.id, - "month": record.month, - "type": record.type, - "file_name": record.file_name, - "file_path": record.file_path, - } - ) - - # Mark email as seen and flagged to avoid re-processing - imap.store(msg_id, "+FLAGS", "\\Seen \\Flagged") - - db.commit() - finally: - db.close() + if not results and errors: + # 所有账户都失败了,整体报错,前端可显示详细原因。 + raise RuntimeError("; ".join(errors)) return results @@ -205,7 +566,8 @@ async def sync_finance_emails() -> List[Dict[str, Any]]: async def create_monthly_zip(month_str: str) -> str: """ - Zip the finance folder for a given month (YYYY-MM) and return the zip path. + Zip the finance folder for a given month (YYYY-MM). + Preserves folder structure (invoices/, receipts/, statements/, manual/) inside the zip. """ import zipfile @@ -227,4 +589,3 @@ async def create_monthly_zip(month_str: str) -> str: return str(zip_path) return await asyncio.to_thread(_zip) - diff --git a/backend/app/services/invoice_upload.py b/backend/app/services/invoice_upload.py new file mode 100644 index 0000000..3059970 --- /dev/null +++ b/backend/app/services/invoice_upload.py @@ -0,0 +1,90 @@ +""" +Manual invoice upload: save file, optionally run AI vision to extract amount/date. +""" +import io +from datetime import date, datetime +from pathlib import Path +from typing import Any, Dict, Tuple + +from fastapi import UploadFile + +from backend.app.services.ai_service import extract_invoice_metadata + +FINANCE_BASE = Path("data/finance") +ALLOWED_IMAGE = {".jpg", ".jpeg", ".png", ".webp"} +ALLOWED_PDF = {".pdf"} + + +def _current_month() -> str: + return datetime.utcnow().strftime("%Y-%m") + + +def _pdf_first_page_to_image(pdf_bytes: bytes) -> Tuple[bytes, str] | None: + """Render first page of PDF to PNG bytes. Returns (bytes, 'image/png') or None on error.""" + try: + import fitz + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + if doc.page_count == 0: + doc.close() + return None + page = doc[0] + pix = page.get_pixmap(dpi=150) + png_bytes = pix.tobytes("png") + doc.close() + return (png_bytes, "image/png") + except Exception: + return None + + +async def process_invoice_upload( + file: UploadFile, +) -> Tuple[str, str, str, float | None, date | None]: + """ + Save uploaded file to data/finance/{YYYY-MM}/manual/, run OCR for amount/date. + Returns (file_name, file_path, month_str, amount, billing_date). + """ + month_str = _current_month() + manual_dir = FINANCE_BASE / month_str / "manual" + manual_dir.mkdir(parents=True, exist_ok=True) + + raw = await file.read() + filename = file.filename or "upload" + suf = Path(filename).suffix.lower() + + if suf in ALLOWED_IMAGE: + image_bytes, mime = raw, (file.content_type or "image/jpeg") + if "png" in (suf or ""): + mime = "image/png" + amount, date_str = await extract_invoice_metadata(image_bytes, mime) + elif suf in ALLOWED_PDF: + image_result = _pdf_first_page_to_image(raw) + if image_result: + image_bytes, mime = image_result + amount, date_str = await extract_invoice_metadata(image_bytes, mime) + else: + amount, date_str = None, None + # Save original PDF + else: + amount, date_str = None, None + + # Unique filename + dest = manual_dir / filename + counter = 1 + while dest.exists(): + dest = manual_dir / f"{dest.stem}_{counter}{dest.suffix}" + counter += 1 + + dest.write_bytes(raw) + file_path = str(dest) + file_name = dest.name + + billing_date = None + if date_str: + try: + billing_date = date.fromisoformat(date_str) + except ValueError: + pass + if billing_date is None: + billing_date = date.today() + + return (file_name, file_path, month_str, amount, billing_date) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..206534b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,42 @@ +# 开发模式:代码挂载 + 内部热重载,无需重建镜像即可生效(类似 K8s 仅更新配置/代码并滚动重启) +# 使用方式: docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# 或: ./docker_dev.sh dev + +services: + backend: + build: + context: . + dockerfile: Dockerfile.backend + container_name: ops-core-backend + env_file: + - .env + volumes: + - ./data:/app/data + # 挂载代码:宿主机修改后由 uvicorn --reload 自动重启进程,无需重建容器 + - ./backend:/app/backend + ports: + - "8000:8000" + # 仅重启进程,不重启容器;代码变更由 reload 自动加载 + command: uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: ops-core-frontend + volumes: + - ./frontend:/app + # 保留容器内 node_modules,避免被宿主机目录覆盖 + - frontend_node_modules:/app/node_modules + ports: + - "3000:3000" + environment: + NODE_ENV: development + NEXT_TELEMETRY_DISABLED: 1 + # 开发模式:先装依赖(volume 首次为空),再 dev,代码变更热更新 + command: sh -c "npm install && npm run dev" + depends_on: + - backend + +volumes: + frontend_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index 483ea14..60d86c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,6 @@ +# 生产/默认:分层构建,仅代码变更时只重建最后一层。 +# 开发(代码挂载+热重载): docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# 或执行: ./docker_dev.sh dev services: backend: build: diff --git a/docker_dev.sh b/docker_dev.sh index 8bffe6d..50b44d2 100644 --- a/docker_dev.sh +++ b/docker_dev.sh @@ -1,13 +1,55 @@ #!/usr/bin/env bash -# 使用 Docker Compose 一键构建并启动 FastAPI + Next.js - +# Docker 开发与部署:支持仅更新变动内容、动态加载,避免每次全量重建 set -euo pipefail -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${SCRIPT_DIR}" -echo "[Ops-Core] 使用 Docker 构建并启动服务 (backend + frontend)..." -docker compose -f docker-compose.yml up --build +COMPOSE_BASE="docker compose -f docker-compose.yml" +COMPOSE_DEV="docker compose -f docker-compose.yml -f docker-compose.dev.yml" -# 如需后台模式,可改为: -# docker compose -f docker-compose.yml up --build -d \ No newline at end of file +usage() { + echo "用法: $0 [mode]" + echo "" + echo " (无参数) 默认构建并启动(生产模式,分层构建仅重建变更层)" + echo " dev 开发模式:挂载代码 + 热重载(前台,Ctrl+C 会停容器)" + echo " dev-bg 开发模式后台运行,Ctrl+C 不会停容器,停止用: $0 down" + echo " restart 仅重启容器内服务,不重建镜像" + echo " build 仅重新构建镜像(依赖未变时只重建代码层,较快)" + echo " down 停止并移除容器" + exit 0 +} + +case "${1:-up}" in + up|"") + echo "[Ops-Core] 构建并启动(生产模式)..." + ${COMPOSE_BASE} up --build + ;; + dev) + echo "[Ops-Core] 开发模式:代码挂载 + 热重载,无需重建(Ctrl+C 会停止容器)..." + ${COMPOSE_DEV} up --build + ;; + dev-bg) + echo "[Ops-Core] 开发模式(后台):同上,但 Ctrl+C 不会停容器,需用 ./docker_dev.sh down 停止..." + ${COMPOSE_DEV} up --build -d + ;; + restart) + echo "[Ops-Core] 仅重启服务(不重建镜像)..." + ${COMPOSE_BASE} restart backend frontend + ;; + build) + echo "[Ops-Core] 仅构建镜像(变更少时只重建代码层)..." + ${COMPOSE_BASE} build + ;; + down) + ${COMPOSE_BASE} down + ${COMPOSE_DEV} down 2>/dev/null || true + ;; + -h|--help) + usage + ;; + *) + echo "未知参数: $1" + usage + ;; +esac diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..7c4df01 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,4 @@ +# 国内镜像,加快安装、避免卡死(Docker 内已单独配置,此文件供本地/CI 使用) +registry=https://registry.npmmirror.com +fetch-retries=5 +fetch-timeout=60000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b7d48c3..c7c760f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,10 +1,19 @@ +# 分层构建:依赖与代码分离,仅代码变更时只重建 COPY 及以后层 FROM node:20-alpine WORKDIR /app -COPY package.json ./ -RUN npm install +# 使用国内镜像源,加快安装并减少网络卡死 +RUN npm config set registry https://registry.npmmirror.com \ + && npm config set fetch-retries 5 \ + && npm config set fetch-timeout 60000 \ + && npm config set fetch-retry-mintimeout 10000 +# 依赖层:只有 package.json / package-lock 变更时才重建 +COPY package.json ./ +RUN npm install --prefer-offline --no-audit --progress=false + +# 代码层:业务代码变更只重建此层及后续 build COPY . . ENV NODE_ENV=production diff --git a/frontend/app/(main)/finance/page.tsx b/frontend/app/(main)/finance/page.tsx new file mode 100644 index 0000000..ce6d693 --- /dev/null +++ b/frontend/app/(main)/finance/page.tsx @@ -0,0 +1,503 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + apiBase, + financeApi, + type FinanceRecordRead, + type FinanceSyncResponse, + type FinanceSyncResult, +} from "@/lib/api/client"; +import { Download, Inbox, Loader2, Mail, FileText, Upload } from "lucide-react"; +import { toast } from "sonner"; + +export default function FinancePage() { + const [months, setMonths] = useState([]); + const [selectedMonth, setSelectedMonth] = useState(""); + const [records, setRecords] = useState([]); + const [loadingMonths, setLoadingMonths] = useState(true); + const [loadingRecords, setLoadingRecords] = useState(false); + const [syncing, setSyncing] = useState(false); + const [lastSync, setLastSync] = useState(null); + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [uploadFile, setUploadFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [uploading, setUploading] = useState(false); + const [reviewRecord, setReviewRecord] = useState(null); + const [reviewAmount, setReviewAmount] = useState(""); + const [reviewDate, setReviewDate] = useState(""); + const [savingReview, setSavingReview] = useState(false); + const fileInputRef = useRef(null); + const previewUrlRef = useRef(null); + + const loadMonths = useCallback(async () => { + try { + const list = await financeApi.listMonths(); + setMonths(list); + if (list.length > 0 && !selectedMonth) setSelectedMonth(list[0]); + } catch { + toast.error("加载月份列表失败"); + } finally { + setLoadingMonths(false); + } + }, [selectedMonth]); + + const loadRecords = useCallback(async () => { + if (!selectedMonth) { + setRecords([]); + return; + } + setLoadingRecords(true); + try { + const list = await financeApi.listRecords(selectedMonth); + setRecords(list); + } catch { + toast.error("加载记录失败"); + } finally { + setLoadingRecords(false); + } + }, [selectedMonth]); + + useEffect(() => { + loadMonths(); + }, [loadMonths]); + + useEffect(() => { + loadRecords(); + }, [loadRecords]); + + const handleSync = async () => { + setSyncing(true); + toast.loading("正在同步邮箱…", { id: "finance-sync" }); + try { + const res: FinanceSyncResponse = await financeApi.sync(); + setLastSync(res); + toast.dismiss("finance-sync"); + if (res.new_files > 0) { + toast.success(`发现 ${res.new_files} 个新文件`); + await loadMonths(); + if (selectedMonth) await loadRecords(); + } else { + toast.info("收件箱已是最新,无新文件"); + } + } catch (e) { + toast.dismiss("finance-sync"); + toast.error(e instanceof Error ? e.message : "同步失败"); + } finally { + setSyncing(false); + } + }; + + const resetUploadDialog = useCallback(() => { + setUploadFile(null); + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + setPreviewUrl(null); + setReviewRecord(null); + setReviewAmount(""); + setReviewDate(""); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, []); + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"]; + if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|jpg|jpeg|png|webp)$/i)) { + toast.error("仅支持 PDF、JPG、PNG、WEBP"); + return; + } + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + setUploadFile(file); + setReviewRecord(null); + setReviewAmount(""); + setReviewDate(""); + if (file.type.startsWith("image/")) { + const url = URL.createObjectURL(file); + previewUrlRef.current = url; + setPreviewUrl(url); + } else { + setPreviewUrl(null); + } + }; + + const handleUploadSubmit = async () => { + if (!uploadFile) return; + setUploading(true); + try { + const record = await financeApi.uploadInvoice(uploadFile); + setReviewRecord(record); + setReviewAmount(record.amount != null ? String(record.amount) : ""); + setReviewDate(record.billing_date || ""); + toast.success("已上传,请核对金额与日期"); + await loadMonths(); + if (selectedMonth === record.month) await loadRecords(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "上传失败"); + } finally { + setUploading(false); + } + }; + + const handleReviewSave = async () => { + if (!reviewRecord) return; + const amount = reviewAmount.trim() ? parseFloat(reviewAmount) : null; + const billing_date = reviewDate.trim() || null; + setSavingReview(true); + try { + await financeApi.updateRecord(reviewRecord.id, { amount, billing_date }); + toast.success("已保存"); + setUploadDialogOpen(false); + resetUploadDialog(); + if (selectedMonth) await loadRecords(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "保存失败"); + } finally { + setSavingReview(false); + } + }; + + const handleDownloadZip = async () => { + if (!selectedMonth) { + toast.error("请先选择月份"); + return; + } + try { + await financeApi.downloadMonth(selectedMonth); + toast.success(`已下载 ${selectedMonth}.zip`); + } catch (e) { + toast.error(e instanceof Error ? e.message : "下载失败"); + } + }; + + const formatDate = (s: string) => + new Date(s).toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + + const typeLabel: Record = { + invoices: "发票", + bank_records: "流水", + statements: "流水", + receipts: "回执", + manual: "手动上传", + others: "其他", + }; + + const monthlyTotal = records.reduce((sum, r) => sum + (r.amount ?? 0), 0); + const totalInvoicesThisMonth = records.filter( + (r) => r.amount != null && (r.type === "manual" || r.type === "invoices") + ).reduce((s, r) => s + (r.amount ?? 0), 0); + + return ( +
+
+
+
+

财务归档

+

+ 从网易邮箱同步发票、回执、流水等附件,或手动上传发票 +

+
+
+ {selectedMonth && ( + +

本月发票合计

+

+ ¥{totalInvoicesThisMonth.toLocaleString("zh-CN", { minimumFractionDigits: 2 })} +

+
+ )} + + +
+
+ + {/* Upload Invoice Dialog */} + { + setUploadDialogOpen(open); + if (!open) resetUploadDialog(); + }}> + + + {reviewRecord ? "核对金额与日期" : "上传发票"} + + {!reviewRecord ? ( + <> +
fileInputRef.current?.click()} + className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:bg-muted/50" + > + + {previewUrl ? ( + 预览 + ) : uploadFile ? ( +

{uploadFile.name}

+ ) : ( +

点击或拖拽 PDF/图片到此处

+ )} +
+ + + + + + ) : ( + <> + {previewUrl && ( + 预览 + )} +
+ + setReviewAmount(e.target.value)} + placeholder="可手动修改" + /> +
+
+ + setReviewDate(e.target.value)} + /> +
+ + + + + + )} +
+
+ + {/* Sync History / Last sync */} + + + + + 同步记录 + + + + {lastSync !== null ? ( + <> +

+ 最近一次同步:发现 {lastSync.new_files} 个新文件 +

+ {lastSync.details && lastSync.details.length > 0 && ( +
+ {Object.entries( + lastSync.details.reduce>( + (acc, item) => { + const t = item.type || "others"; + if (!acc[t]) acc[t] = []; + acc[t].push(item); + return acc; + }, + {}, + ), + ).map(([t, items]) => ( +
+

+ {typeLabel[t] ?? t}({items.length}) +

+
    + {items.map((it) => ( +
  • + {it.file_name} + + [{it.month}] + +
  • + ))} +
+
+ ))} +
+ )} + + ) : ( +

+ 点击「同步邮箱」后,将显示本次同步结果 +

+ )} +
+
+ + {/* Month + Download */} + + +
+ 按月份查看 +
+ + +
+
+
+ + {loadingRecords ? ( +

+ + 加载中… +

+ ) : records.length === 0 ? ( +

+ {selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"} +

+ ) : ( + <> +

+ 类型:发票 / 回执 / 流水。同步的发票已按「日期_金额_原文件名」重命名。 +

+ + + + 类型 + 文件名 + 金额 + 开票/归档时间 + 操作 + + + + {records.map((r) => ( + + + + {typeLabel[r.type] ?? r.type} + + + {r.file_name} + + {r.amount != null + ? `¥${Number(r.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}` + : "—"} + + + {r.billing_date || formatDate(r.created_at)} + + + + + 下载 + + + + ))} + + 本月合计 + + ¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })} + + + + +
+ + )} +
+
+
+
+ ); +} diff --git a/frontend/app/(main)/settings/ai/page.tsx b/frontend/app/(main)/settings/ai/page.tsx new file mode 100644 index 0000000..f59bac8 --- /dev/null +++ b/frontend/app/(main)/settings/ai/page.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + aiSettingsApi, + type AIConfig, + type AIConfigListItem, + type AIConfigCreate, + type AIConfigUpdate, +} from "@/lib/api/client"; +import { Loader2, Zap, Plus, Pencil, Trash2, CheckCircle } from "lucide-react"; +import { toast } from "sonner"; + +const PROVIDERS = ["OpenAI", "DeepSeek", "Custom"]; + +export default function SettingsAIPage() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(null); + + const [formName, setFormName] = useState(""); + const [provider, setProvider] = useState("OpenAI"); + const [apiKey, setApiKey] = useState(""); + const [apiKeyConfigured, setApiKeyConfigured] = useState(false); + const [baseUrl, setBaseUrl] = useState(""); + const [modelName, setModelName] = useState("gpt-4o-mini"); + const [temperature, setTemperature] = useState("0.2"); + const [systemPromptOverride, setSystemPromptOverride] = useState(""); + + const loadList = useCallback(async () => { + try { + const data = await aiSettingsApi.list(); + setList(data); + } catch { + toast.error("加载模型列表失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadList(); + }, [loadList]); + + const openAdd = () => { + setEditingId(null); + setFormName(""); + setProvider("OpenAI"); + setApiKey(""); + setApiKeyConfigured(false); + setBaseUrl(""); + setModelName("gpt-4o-mini"); + setTemperature("0.2"); + setSystemPromptOverride(""); + setDialogOpen(true); + }; + + const openEdit = async (id: string) => { + try { + const c = await aiSettingsApi.getById(id); + setEditingId(id); + setFormName(c.name || ""); + setProvider(c.provider || "OpenAI"); + setApiKey(""); + setApiKeyConfigured(!!(c.api_key && c.api_key.length > 0)); + setBaseUrl(c.base_url || ""); + setModelName(c.model_name || "gpt-4o-mini"); + setTemperature(String(c.temperature ?? 0.2)); + setSystemPromptOverride(c.system_prompt_override || ""); + setDialogOpen(true); + } catch { + toast.error("加载配置失败"); + } + }; + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + if (editingId) { + const payload: AIConfigUpdate = { + name: formName.trim() || undefined, + provider: provider || undefined, + base_url: baseUrl.trim() || undefined, + model_name: modelName.trim() || undefined, + temperature: parseFloat(temperature), + system_prompt_override: systemPromptOverride.trim() || undefined, + }; + if (apiKey.trim()) payload.api_key = apiKey.trim(); + await aiSettingsApi.update(editingId, payload); + toast.success("已更新"); + } else { + const payload: AIConfigCreate = { + name: formName.trim() || undefined, + provider: provider || undefined, + api_key: apiKey.trim() || undefined, + base_url: baseUrl.trim() || undefined, + model_name: modelName.trim() || undefined, + temperature: parseFloat(temperature), + system_prompt_override: systemPromptOverride.trim() || undefined, + }; + await aiSettingsApi.create(payload); + toast.success("已添加"); + } + setDialogOpen(false); + await loadList(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("ai-settings-changed")); + } + } catch (e) { + toast.error(e instanceof Error ? e.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + const handleActivate = async (id: string) => { + try { + await aiSettingsApi.activate(id); + toast.success("已选用该模型"); + await loadList(); + window.dispatchEvent(new CustomEvent("ai-settings-changed")); + } catch (e) { + toast.error(e instanceof Error ? e.message : "选用失败"); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("确定删除该模型配置?")) return; + try { + await aiSettingsApi.delete(id); + toast.success("已删除"); + await loadList(); + window.dispatchEvent(new CustomEvent("ai-settings-changed")); + } catch (e) { + toast.error(e instanceof Error ? e.message : "删除失败"); + } + }; + + const handleTest = async (id: string) => { + setTesting(id); + try { + const res = await aiSettingsApi.test(id); + toast.success(res.message ? `连接成功:${res.message}` : "连接成功"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "连接失败"); + } finally { + setTesting(null); + } + }; + + if (loading) { + return ( +
+ + 加载中… +
+ ); + } + + return ( +
+
+ + ← 设置 + +
+ + + +
+
+ 已配置的模型 +

+ 在此查看、选用或编辑多套 AI 模型配置;需求解析与测试将使用当前选用的配置。 +

+
+ +
+
+ + {list.length === 0 ? ( +

+ 暂无模型配置。点击「添加模型」填写 API Key、模型名称等,保存后即可在列表中选用。 +

+ ) : ( + + + + 名称 + 提供商 + 模型 + API Key + 操作 + + + + {list.map((item) => ( + + + {item.name || "未命名"} + {item.is_active && ( + + 当前选用 + + )} + + {item.provider} + {item.model_name || "—"} + {item.api_key_configured ? "已配置" : "未配置"} + +
+ {!item.is_active && ( + + )} + + + +
+
+
+ ))} +
+
+ )} +
+
+ + + + + {editingId ? "编辑模型配置" : "添加模型配置"} + +
+
+ + setFormName(e.target.value)} + placeholder="如:OpenAI 生产、DeepSeek 备用" + /> +
+
+ + +
+
+ + setApiKey(e.target.value)} + placeholder={apiKeyConfigured ? "已配置,输入新值以修改" : "请输入 API Key"} + autoComplete="off" + /> +
+
+ + setBaseUrl(e.target.value)} + placeholder="https://api.openai.com/v1" + /> +
+
+ + setModelName(e.target.value)} + placeholder="gpt-4o-mini / deepseek-chat 等" + /> +
+
+ + setTemperature(e.target.value)} + /> +
+
+ +