From 6508589027e0cd98a15b65e44d03efa2aabba9a0 Mon Sep 17 00:00:00 2001 From: xucheng Date: Tue, 28 Oct 2025 23:30:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=85=AC?= =?UTF-8?q?=E5=8F=B8=E7=AE=80=E4=BB=8B=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/alembic.ini | 147 ++ backend/alembic/README | 1 + backend/alembic/env.py | 83 + backend/alembic/script.py.mako | 28 + .../65b0d87d025a_initial_migration.py | 79 + backend/app/core/config.py | 31 + backend/app/core/database.py | 9 + backend/app/core/dependencies.py | 18 + backend/app/main.py | 36 + backend/app/models/__init__.py | 7 + backend/app/models/analysis_module.py | 20 + backend/app/models/base.py | 3 + backend/app/models/progress_tracking.py | 23 + backend/app/models/report.py | 21 + backend/app/models/system_config.py | 13 + backend/app/routers/config.py | 38 + backend/app/routers/financial.py | 249 +++ backend/app/schemas/config.py | 33 + backend/app/schemas/financial.py | 57 + .../app/services/company_profile_client.py | 162 ++ backend/app/services/config_manager.py | 87 + backend/app/services/tushare_client.py | 52 + backend/requirements.txt | 8 + docs/tasks.md | 19 +- frontend/next.config.mjs | 13 + frontend/package-lock.json | 1582 ++++++++++++++++- frontend/package.json | 7 +- frontend/src/app/api/config/route.ts | 20 + .../src/app/api/financials/[...slug]/route.ts | 12 + frontend/src/app/config/page.tsx | 309 ++-- frontend/src/app/page.tsx | 708 +------- frontend/src/app/report/[symbol]/page.tsx | 422 +++++ frontend/src/components/ui/spinner.tsx | 16 + frontend/src/components/ui/tabs.tsx | 66 + frontend/src/hooks/useApi.ts | 52 + frontend/src/stores/useConfigStore.ts | 38 + frontend/src/types/index.ts | 55 + package-lock.json | 83 + package.json | 6 + scripts/dev.sh | 121 ++ scripts/setup_all.sh | 13 - scripts/setup_backend.sh | 66 - scripts/setup_frontend.sh | 37 - 43 files changed, 3867 insertions(+), 983 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/65b0d87d025a_initial_migration.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/dependencies.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/analysis_module.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/progress_tracking.py create mode 100644 backend/app/models/report.py create mode 100644 backend/app/models/system_config.py create mode 100644 backend/app/routers/config.py create mode 100644 backend/app/routers/financial.py create mode 100644 backend/app/schemas/config.py create mode 100644 backend/app/schemas/financial.py create mode 100644 backend/app/services/company_profile_client.py create mode 100644 backend/app/services/config_manager.py create mode 100644 backend/app/services/tushare_client.py create mode 100644 backend/requirements.txt create mode 100644 frontend/src/app/api/config/route.ts create mode 100644 frontend/src/app/api/financials/[...slug]/route.ts create mode 100644 frontend/src/app/report/[symbol]/page.tsx create mode 100644 frontend/src/components/ui/spinner.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/hooks/useApi.ts create mode 100644 frontend/src/stores/useConfigStore.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/dev.sh delete mode 100755 scripts/setup_all.sh delete mode 100755 scripts/setup_backend.sh delete mode 100755 scripts/setup_frontend.sh diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..3e4175c --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = postgresql+asyncpg://value:Value609!@192.168.3.195:5432/fundamental + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..2433598 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,83 @@ +from logging.config import fileConfig +import os +import sys + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Add app directory to sys.path +sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))) + +# Import Base from your models +from app.models import Base +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url").replace("+asyncpg", "") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + section = config.get_section(config.config_ini_section, {}) + section['sqlalchemy.url'] = section['sqlalchemy.url'].replace("+asyncpg", "") + connectable = engine_from_config( + section, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/65b0d87d025a_initial_migration.py b/backend/alembic/versions/65b0d87d025a_initial_migration.py new file mode 100644 index 0000000..5064385 --- /dev/null +++ b/backend/alembic/versions/65b0d87d025a_initial_migration.py @@ -0,0 +1,79 @@ +"""Initial migration + +Revision ID: 65b0d87d025a +Revises: +Create Date: 2025-10-22 09:03:08.353806 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '65b0d87d025a' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('reports', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('symbol', sa.String(), nullable=False), + sa.Column('market', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_reports_market'), 'reports', ['market'], unique=False) + op.create_index(op.f('ix_reports_status'), 'reports', ['status'], unique=False) + op.create_index(op.f('ix_reports_symbol'), 'reports', ['symbol'], unique=False) + op.create_table('analysis_modules', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('report_id', sa.UUID(), nullable=False), + sa.Column('module_type', sa.String(), nullable=False), + sa.Column('content', sa.JSON(), nullable=True), + sa.Column('status', sa.String(), nullable=False), + sa.Column('error_message', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['report_id'], ['reports.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_analysis_modules_report_id'), 'analysis_modules', ['report_id'], unique=False) + op.create_index(op.f('ix_analysis_modules_status'), 'analysis_modules', ['status'], unique=False) + op.create_table('progress_tracking', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('report_id', sa.UUID(), nullable=False), + sa.Column('step_name', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('started_at', sa.DateTime(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('duration_ms', sa.Integer(), nullable=True), + sa.Column('token_usage', sa.Integer(), nullable=True), + sa.Column('error_message', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['report_id'], ['reports.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_progress_tracking_report_id'), 'progress_tracking', ['report_id'], unique=False) + op.create_index(op.f('ix_progress_tracking_status'), 'progress_tracking', ['status'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_progress_tracking_status'), table_name='progress_tracking') + op.drop_index(op.f('ix_progress_tracking_report_id'), table_name='progress_tracking') + op.drop_table('progress_tracking') + op.drop_index(op.f('ix_analysis_modules_status'), table_name='analysis_modules') + op.drop_index(op.f('ix_analysis_modules_report_id'), table_name='analysis_modules') + op.drop_table('analysis_modules') + op.drop_index(op.f('ix_reports_symbol'), table_name='reports') + op.drop_index(op.f('ix_reports_status'), table_name='reports') + op.drop_index(op.f('ix_reports_market'), table_name='reports') + op.drop_table('reports') + # ### end Alembic commands ### diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..cd1e03d --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,31 @@ +""" +Application settings management using Pydantic +""" +from typing import List, Optional +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + APP_NAME: str = "Fundamental Analysis System" + APP_VERSION: str = "1.0.0" + DEBUG: bool = False + + # Default database URL switched to SQLite (async) to avoid optional driver issues. + # You can override via config or env to PostgreSQL later. + DATABASE_URL: str = "sqlite+aiosqlite:///./app.db" + DATABASE_ECHO: bool = False + + # API settings + API_V1_STR: str = "/api" + ALLOWED_ORIGINS: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"] + + # External service credentials, can be overridden + GEMINI_API_KEY: Optional[str] = None + TUSHARE_TOKEN: Optional[str] = None + + class Config: + env_file = ".env" + case_sensitive = True + +# Global settings instance +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..eeff3e4 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,9 @@ +""" +SQLAlchemy async database session factory +""" +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=settings.DATABASE_ECHO, future=True) +AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 0000000..cc7bd58 --- /dev/null +++ b/backend/app/core/dependencies.py @@ -0,0 +1,18 @@ +""" +Application dependencies and providers +""" +from typing import AsyncGenerator +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.services.config_manager import ConfigManager + +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + """Provides a database session to the application.""" + async with AsyncSessionLocal() as session: + yield session + +def get_config_manager(db_session: AsyncSession = Depends(get_db_session)) -> ConfigManager: + """Dependency to get the configuration manager.""" + return ConfigManager(db_session=db_session) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c32ab0d --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,36 @@ +""" +FastAPI application entrypoint +""" +import logging +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.routers.config import router as config_router +from app.routers.financial import router as financial_router + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s: %(message)s', + datefmt='%H:%M:%S' +) + +app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Routers +app.include_router(config_router, prefix=f"{settings.API_V1_STR}/config", tags=["config"]) +app.include_router(financial_router, prefix=f"{settings.API_V1_STR}/financials", tags=["financials"]) + +@app.get("/") +async def root(): + return {"status": "ok", "name": settings.APP_NAME, "version": settings.APP_VERSION} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..9ca79eb --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,7 @@ +from .base import Base +from .system_config import SystemConfig +from .report import Report +from .analysis_module import AnalysisModule +from .progress_tracking import ProgressTracking + +__all__ = ["Base", "SystemConfig", "Report", "AnalysisModule", "ProgressTracking"] diff --git a/backend/app/models/analysis_module.py b/backend/app/models/analysis_module.py new file mode 100644 index 0000000..f408d6b --- /dev/null +++ b/backend/app/models/analysis_module.py @@ -0,0 +1,20 @@ +""" +Analysis Module Model +""" +import uuid +from sqlalchemy import Column, String, JSON, ForeignKey, DateTime, func +from sqlalchemy.dialects.postgresql import UUID as pgUUID +from sqlalchemy.orm import relationship +from .base import Base + +class AnalysisModule(Base): + __tablename__ = 'analysis_modules' + + id = Column(pgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + report_id = Column(pgUUID(as_uuid=True), ForeignKey('reports.id'), nullable=False, index=True) + module_type = Column(String, nullable=False) + content = Column(JSON) + status = Column(String, nullable=False, default='pending', index=True) + error_message = Column(String) + + report = relationship("Report", back_populates="analysis_modules") diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..59be703 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base + +Base = declarative_base() diff --git a/backend/app/models/progress_tracking.py b/backend/app/models/progress_tracking.py new file mode 100644 index 0000000..f9b40b3 --- /dev/null +++ b/backend/app/models/progress_tracking.py @@ -0,0 +1,23 @@ +""" +Progress Tracking Model +""" +import uuid +from sqlalchemy import Column, String, Integer, DateTime, func, ForeignKey +from sqlalchemy.dialects.postgresql import UUID as pgUUID +from sqlalchemy.orm import relationship +from .base import Base + +class ProgressTracking(Base): + __tablename__ = 'progress_tracking' + + id = Column(pgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + report_id = Column(pgUUID(as_uuid=True), ForeignKey('reports.id'), nullable=False, index=True) + step_name = Column(String, nullable=False) + status = Column(String, nullable=False, default='pending', index=True) + started_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + duration_ms = Column(Integer) + token_usage = Column(Integer) + error_message = Column(String) + + report = relationship("Report", back_populates="progress_tracking") diff --git a/backend/app/models/report.py b/backend/app/models/report.py new file mode 100644 index 0000000..2eda68a --- /dev/null +++ b/backend/app/models/report.py @@ -0,0 +1,21 @@ +""" +Report Model +""" +import uuid +from sqlalchemy import Column, String, DateTime, func +from sqlalchemy.dialects.postgresql import UUID as pgUUID +from sqlalchemy.orm import relationship +from .base import Base + +class Report(Base): + __tablename__ = 'reports' + + id = Column(pgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + symbol = Column(String, nullable=False, index=True) + market = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default='generating', index=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + analysis_modules = relationship("AnalysisModule", back_populates="report", cascade="all, delete-orphan") + progress_tracking = relationship("ProgressTracking", back_populates="report", cascade="all, delete-orphan") diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..baf23bb --- /dev/null +++ b/backend/app/models/system_config.py @@ -0,0 +1,13 @@ +""" +System Configuration Model +""" +from sqlalchemy import Column, String, JSON +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class SystemConfig(Base): + __tablename__ = 'system_config' + + config_key = Column(String, primary_key=True, index=True) + config_value = Column(JSON, nullable=False) diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py new file mode 100644 index 0000000..37d794e --- /dev/null +++ b/backend/app/routers/config.py @@ -0,0 +1,38 @@ +""" +API router for configuration management +""" +from fastapi import APIRouter, Depends, HTTPException + +from app.core.dependencies import get_config_manager +from app.schemas.config import ConfigResponse, ConfigUpdateRequest, ConfigTestRequest, ConfigTestResponse +from app.services.config_manager import ConfigManager + +router = APIRouter() + +@router.get("/", response_model=ConfigResponse) +async def get_config(config_manager: ConfigManager = Depends(get_config_manager)): + """Retrieve the current system configuration.""" + return await config_manager.get_config() + +@router.put("/", response_model=ConfigResponse) +async def update_config( + config_update: ConfigUpdateRequest, + config_manager: ConfigManager = Depends(get_config_manager) +): + """Update system configuration.""" + return await config_manager.update_config(config_update) + +@router.post("/test", response_model=ConfigTestResponse) +async def test_config( + test_request: ConfigTestRequest, + config_manager: ConfigManager = Depends(get_config_manager) +): + """Test a specific configuration (e.g., database connection).""" + # The test logic will be implemented in a subsequent step inside the ConfigManager + # For now, we return a placeholder response. + # test_result = await config_manager.test_config( + # test_request.config_type, + # test_request.config_data + # ) + # return test_result + raise HTTPException(status_code=501, detail="Not Implemented") diff --git a/backend/app/routers/financial.py b/backend/app/routers/financial.py new file mode 100644 index 0000000..2bd00b8 --- /dev/null +++ b/backend/app/routers/financial.py @@ -0,0 +1,249 @@ +""" +API router for financial data (Tushare for China market) +""" +import json +import os +import time +from datetime import datetime, timezone +from typing import Dict, List + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import StreamingResponse +import os + +from app.core.config import settings +from app.schemas.financial import BatchFinancialDataResponse, FinancialConfigResponse, FinancialMeta, StepRecord, CompanyProfileResponse +from app.services.tushare_client import TushareClient +from app.services.company_profile_client import CompanyProfileClient + +router = APIRouter() + +# Load metric config from file (project root is repo root, not backend/) +# routers/ -> app/ -> backend/ -> repo root +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +FINANCIAL_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "financial-tushare.json") +BASE_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "config.json") + + +def _load_json(path: str) -> Dict: + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +@router.get("/config", response_model=FinancialConfigResponse) +async def get_financial_config(): + data = _load_json(FINANCIAL_CONFIG_PATH) + api_groups = data.get("api_groups", {}) + return FinancialConfigResponse(api_groups=api_groups) + + +@router.get("/china/{ts_code}", response_model=BatchFinancialDataResponse) +async def get_china_financials( + ts_code: str, + years: int = Query(5, ge=1, le=15), +): + # Load Tushare token + base_cfg = _load_json(BASE_CONFIG_PATH) + token = ( + os.environ.get("TUSHARE_TOKEN") + or settings.TUSHARE_TOKEN + or base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key") + ) + if not token: + raise HTTPException(status_code=500, detail="Tushare API token not configured. Set TUSHARE_TOKEN env or config/config.json data_sources.tushare.api_key") + + # Load metric config + fin_cfg = _load_json(FINANCIAL_CONFIG_PATH) + api_groups: Dict[str, List[Dict]] = fin_cfg.get("api_groups", {}) + + client = TushareClient(token=token) + + # Meta tracking + started_real = datetime.now(timezone.utc) + started = time.perf_counter_ns() + api_calls_total = 0 + api_calls_by_group: Dict[str, int] = {} + steps: List[StepRecord] = [] + current_action = "初始化" + + # Get company name from stock_basic API + company_name = None + try: + basic_data = await client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name") + api_calls_total += 1 + if basic_data and len(basic_data) > 0: + company_name = basic_data[0].get("name") + except Exception: + # If getting company name fails, continue without it + pass + + # Collect series per metric key + series: Dict[str, List[Dict]] = {} + + # Helper to store year-value pairs while keeping most recent per year + def _merge_year_value(key: str, year: str, value): + arr = series.setdefault(key, []) + # upsert by year + for item in arr: + if item["year"] == year: + item["value"] = value + return + arr.append({"year": year, "value": value}) + + # Query each API group we care + errors: Dict[str, str] = {} + for group_name, metrics in api_groups.items(): + step = StepRecord( + name=f"拉取 {group_name}", + start_ts=started_real.isoformat(), + status="running", + ) + steps.append(step) + current_action = step.name + if not metrics: + continue + api_name = metrics[0].get("api") or group_name + fields = list({m.get("tushareParam") for m in metrics if m.get("tushareParam")}) + if not fields: + continue + + date_field = "end_date" if group_name in ("fina_indicator", "income", "balancesheet", "cashflow") else "trade_date" + try: + data_rows = await client.query(api_name=api_name, params={"ts_code": ts_code, "limit": 5000}, fields=None) + api_calls_total += 1 + api_calls_by_group[group_name] = api_calls_by_group.get(group_name, 0) + 1 + except Exception as e: + step.status = "error" + step.error = str(e) + step.end_ts = datetime.now(timezone.utc).isoformat() + step.duration_ms = int((time.perf_counter_ns() - started) / 1_000_000) + errors[group_name] = str(e) + continue + + tmp: Dict[str, Dict] = {} + for row in data_rows: + date_val = row.get(date_field) + if not date_val: + continue + year = str(date_val)[:4] + existing = tmp.get(year) + if existing is None or str(row.get(date_field)) > str(existing.get(date_field)): + tmp[year] = row + for metric in metrics: + key = metric.get("tushareParam") + if not key: + continue + for year, row in tmp.items(): + _merge_year_value(key, year, row.get(key)) + step.status = "done" + step.end_ts = datetime.now(timezone.utc).isoformat() + step.duration_ms = int((time.perf_counter_ns() - started) / 1_000_000) + + finished_real = datetime.now(timezone.utc) + elapsed_ms = int((time.perf_counter_ns() - started) / 1_000_000) + + if not series: + # If nothing succeeded, expose partial error info + raise HTTPException(status_code=502, detail={"message": "No data returned from Tushare", "errors": errors}) + + # Truncate years and sort + for key, arr in series.items(): + # Deduplicate and sort desc by year, then cut to requested years, and return asc + uniq = {item["year"]: item for item in arr} + arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["year"], reverse=True) + arr_limited = arr_sorted_desc[:years] + arr_sorted = sorted(arr_limited, key=lambda x: x["year"]) # ascending by year + series[key] = arr_sorted + + meta = FinancialMeta( + started_at=started_real.isoformat(), + finished_at=finished_real.isoformat(), + elapsed_ms=elapsed_ms, + api_calls_total=api_calls_total, + api_calls_by_group=api_calls_by_group, + current_action=None, + steps=steps, + ) + + return BatchFinancialDataResponse(ts_code=ts_code, name=company_name, series=series, meta=meta) + + +@router.get("/china/{ts_code}/company-profile", response_model=CompanyProfileResponse) +async def get_company_profile( + ts_code: str, + company_name: str = Query(None, description="Company name for better context"), +): + """ + Get company profile for a company using Gemini AI (non-streaming, single response) + """ + import logging + logger = logging.getLogger(__name__) + + logger.info(f"[API] Company profile requested for {ts_code}") + + # Load config + base_cfg = _load_json(BASE_CONFIG_PATH) + gemini_cfg = base_cfg.get("llm", {}).get("gemini", {}) + api_key = gemini_cfg.get("api_key") + + if not api_key: + logger.error("[API] Gemini API key not configured") + raise HTTPException( + status_code=500, + detail="Gemini API key not configured. Set config.json llm.gemini.api_key" + ) + + client = CompanyProfileClient(api_key=api_key) + + # Get company name from ts_code if not provided + if not company_name: + logger.info(f"[API] Fetching company name for {ts_code}") + # Try to get from stock_basic API + try: + base_cfg = _load_json(BASE_CONFIG_PATH) + token = ( + os.environ.get("TUSHARE_TOKEN") + or settings.TUSHARE_TOKEN + or base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key") + ) + if token: + from app.services.tushare_client import TushareClient + tushare_client = TushareClient(token=token) + basic_data = await tushare_client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name") + if basic_data and len(basic_data) > 0: + company_name = basic_data[0].get("name", ts_code) + logger.info(f"[API] Got company name: {company_name}") + else: + company_name = ts_code + else: + company_name = ts_code + except Exception as e: + logger.warning(f"[API] Failed to get company name: {e}") + company_name = ts_code + + logger.info(f"[API] Generating profile for {company_name}") + + # Generate profile using non-streaming API + result = await client.generate_profile( + company_name=company_name, + ts_code=ts_code, + financial_data=None + ) + + logger.info(f"[API] Profile generation completed, success={result.get('success')}") + + return CompanyProfileResponse( + ts_code=ts_code, + company_name=company_name, + content=result.get("content", ""), + model=result.get("model", "gemini-2.5-flash"), + tokens=result.get("tokens", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}), + elapsed_ms=result.get("elapsed_ms", 0), + success=result.get("success", False), + error=result.get("error") + ) diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py new file mode 100644 index 0000000..6f4f823 --- /dev/null +++ b/backend/app/schemas/config.py @@ -0,0 +1,33 @@ +""" +Configuration-related Pydantic schemas +""" +from typing import Dict, Optional, Any +from pydantic import BaseModel, Field + +class DatabaseConfig(BaseModel): + url: str = Field(..., description="数据库连接URL") + +class GeminiConfig(BaseModel): + api_key: str = Field(..., description="Gemini API Key") + base_url: Optional[str] = None + +class DataSourceConfig(BaseModel): + api_key: str = Field(..., description="数据源API Key") + +class ConfigResponse(BaseModel): + database: DatabaseConfig + gemini_api: GeminiConfig + data_sources: Dict[str, DataSourceConfig] + +class ConfigUpdateRequest(BaseModel): + database: Optional[DatabaseConfig] = None + gemini_api: Optional[GeminiConfig] = None + data_sources: Optional[Dict[str, DataSourceConfig]] = None + +class ConfigTestRequest(BaseModel): + config_type: str + config_data: Dict[str, Any] + +class ConfigTestResponse(BaseModel): + success: bool + message: str diff --git a/backend/app/schemas/financial.py b/backend/app/schemas/financial.py new file mode 100644 index 0000000..88ae52b --- /dev/null +++ b/backend/app/schemas/financial.py @@ -0,0 +1,57 @@ +""" +Pydantic schemas for financial APIs +""" +from typing import Dict, List, Optional +from pydantic import BaseModel + + +class YearDataPoint(BaseModel): + year: str + value: Optional[float] + + +class StepRecord(BaseModel): + name: str + start_ts: str # ISO8601 + end_ts: Optional[str] = None + duration_ms: Optional[int] = None + status: str # running|done|error + error: Optional[str] = None + + +class FinancialMeta(BaseModel): + started_at: str # ISO8601 + finished_at: Optional[str] = None + elapsed_ms: Optional[int] = None + api_calls_total: int = 0 + api_calls_by_group: Dict[str, int] = {} + current_action: Optional[str] = None + steps: List[StepRecord] = [] + + +class BatchFinancialDataResponse(BaseModel): + ts_code: str + name: Optional[str] = None + series: Dict[str, List[YearDataPoint]] + meta: Optional[FinancialMeta] = None + + +class FinancialConfigResponse(BaseModel): + api_groups: Dict[str, List[dict]] + + +class TokenUsage(BaseModel): + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + +class CompanyProfileResponse(BaseModel): + ts_code: str + company_name: Optional[str] = None + content: str + model: str + tokens: TokenUsage + elapsed_ms: int + success: bool = True + error: Optional[str] = None diff --git a/backend/app/services/company_profile_client.py b/backend/app/services/company_profile_client.py new file mode 100644 index 0000000..31d8b6d --- /dev/null +++ b/backend/app/services/company_profile_client.py @@ -0,0 +1,162 @@ +""" +Google Gemini API Client for company profile generation +""" +import time +from typing import Dict, List, Optional +import google.generativeai as genai + + +class CompanyProfileClient: + def __init__(self, api_key: str): + """Initialize Gemini client with API key""" + genai.configure(api_key=api_key) + self.model = genai.GenerativeModel("gemini-2.5-flash") + + async def generate_profile( + self, + company_name: str, + ts_code: str, + financial_data: Optional[Dict] = None + ) -> Dict: + """ + Generate company profile using Gemini API (non-streaming) + + Args: + company_name: Company name + ts_code: Stock code + financial_data: Optional financial data for context + + Returns: + Dict with profile content and metadata + """ + start_time = time.perf_counter_ns() + + # Build prompt + prompt = self._build_prompt(company_name, ts_code, financial_data) + + # Call Gemini API (using sync API in async context) + try: + # Run synchronous API call in executor + import asyncio + loop = asyncio.get_event_loop() + response = await loop.run_in_executor( + None, + lambda: self.model.generate_content(prompt) + ) + + # Get token usage + usage_metadata = response.usage_metadata if hasattr(response, 'usage_metadata') else None + + elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000) + + return { + "content": response.text, + "model": "gemini-2.5-flash", + "tokens": { + "prompt_tokens": usage_metadata.prompt_token_count if usage_metadata else 0, + "completion_tokens": usage_metadata.candidates_token_count if usage_metadata else 0, + "total_tokens": usage_metadata.total_token_count if usage_metadata else 0, + } if usage_metadata else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + "elapsed_ms": elapsed_ms, + "success": True, + } + except Exception as e: + elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000) + return { + "content": "", + "model": "gemini-2.5-flash", + "tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + "elapsed_ms": elapsed_ms, + "success": False, + "error": str(e), + } + + def generate_profile_stream( + self, + company_name: str, + ts_code: str, + financial_data: Optional[Dict] = None + ): + """ + Generate company profile using Gemini API with streaming + + Args: + company_name: Company name + ts_code: Stock code + financial_data: Optional financial data for context + + Yields: + Chunks of generated content + """ + import logging + logger = logging.getLogger(__name__) + + logger.info(f"[CompanyProfile] Starting stream generation for {company_name} ({ts_code})") + + # Build prompt + prompt = self._build_prompt(company_name, ts_code, financial_data) + logger.info(f"[CompanyProfile] Prompt built, length: {len(prompt)} chars") + + # Call Gemini API with streaming + try: + logger.info("[CompanyProfile] Calling Gemini API with stream=True") + # Generate streaming response (sync call, but yields chunks) + response_stream = self.model.generate_content(prompt, stream=True) + logger.info("[CompanyProfile] Gemini API stream object created") + + chunk_count = 0 + # Stream chunks + logger.info("[CompanyProfile] Starting to iterate response stream") + for chunk in response_stream: + logger.info(f"[CompanyProfile] Received chunk from Gemini, has text: {hasattr(chunk, 'text')}") + if hasattr(chunk, 'text') and chunk.text: + chunk_count += 1 + text_len = len(chunk.text) + logger.info(f"[CompanyProfile] Chunk {chunk_count}: {text_len} chars") + yield chunk.text + else: + logger.warning(f"[CompanyProfile] Chunk has no text attribute or empty, chunk: {chunk}") + + logger.info(f"[CompanyProfile] Stream iteration completed. Total chunks: {chunk_count}") + + except Exception as e: + logger.error(f"[CompanyProfile] Error during streaming: {type(e).__name__}: {str(e)}", exc_info=True) + yield f"\n\n---\n\n**错误**: {type(e).__name__}: {str(e)}" + + def _build_prompt(self, company_name: str, ts_code: str, financial_data: Optional[Dict] = None) -> str: + """Build prompt for company profile generation""" + prompt = f"""您是一位专业的证券市场分析师。请为公司 {company_name} (股票代码: {ts_code}) 生成一份详细且专业的公司介绍。开头不要自我介绍,直接开始正文。正文用MarkDown输出,尽量说明信息来源,用斜体显示信息来源。在生成内容时,请严格遵循以下要求并采用清晰、结构化的格式: + + 1. **公司概览**: + * 简要介绍公司的性质、核心业务领域及其在行业中的定位。 + * 提炼并阐述公司的核心价值理念。 + + 2. **主营业务**: + * 详细描述公司主要的**产品或服务**。 + * **重要提示**:如果能获取到公司最新的官方**年报**或**财务报告**,请从中提取各主要产品/服务线的**收入金额**和其占公司总收入的**百分比**。请**明确标注数据来源**(例如:"数据来源于XX年年度报告")。 + * **严格禁止**编造或估算任何财务数据。若无法找到公开、准确的财务数据,请**不要**在这一点中提及具体金额或比例,仅描述业务内容。 + + 3. **发展历程**: + * 以时间线或关键事件的形式,概述公司自成立以来的主要**里程碑事件**、重大发展阶段、战略转型或重要成就。 + + 4. **核心团队**: + * 介绍公司**主要管理层和核心技术团队成员**。 + * 对于每位核心成员,提供其**职务、主要工作履历、教育背景**。 + * 如果公开可查,可补充其**出生年份**。 + + 5. **供应链**: + * 描述公司的**主要原材料、部件或服务来源**。 + * 如果公开信息中包含,请列出**主要供应商名称**,并**明确其在总采购金额中的大致占比**。若无此数据,则仅描述采购模式。 + + 6. **主要客户及销售模式**: + * 阐明公司的**销售模式**(例如:直销、经销、线上销售、代理等)。 + * 列出公司的**主要客户群体**或**代表性大客户**。 + * 如果公开信息中包含,请标明**主要客户(或前五大客户)的销售额占公司总销售额的比例**。若无此数据,则仅描述客户类型。 + + 7. **未来展望**: + * 基于公司**公开的官方声明、管理层访谈或战略规划**,总结公司未来的发展方向、战略目标、重点项目或市场预期。请确保此部分内容有可靠的信息来源支持。""" + + if financial_data: + prompt += f"\n\n参考财务数据:\n{financial_data}" + + return prompt diff --git a/backend/app/services/config_manager.py b/backend/app/services/config_manager.py new file mode 100644 index 0000000..283baed --- /dev/null +++ b/backend/app/services/config_manager.py @@ -0,0 +1,87 @@ +""" +Configuration Management Service +""" +import json +import os +from typing import Any, Dict + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.models.system_config import SystemConfig +from app.schemas.config import ConfigResponse, ConfigUpdateRequest, DatabaseConfig, GeminiConfig, DataSourceConfig + +class ConfigManager: + """Manages system configuration by merging a static JSON file with dynamic settings from the database.""" + + def __init__(self, db_session: AsyncSession, config_path: str = None): + self.db = db_session + if config_path is None: + # Default path: backend/ -> project_root/ -> config/config.json + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + self.config_path = os.path.join(project_root, "config", "config.json") + else: + self.config_path = config_path + + def _load_base_config_from_file(self) -> Dict[str, Any]: + """Loads the base configuration from the JSON file.""" + if not os.path.exists(self.config_path): + return {} + try: + with open(self.config_path, "r", encoding="utf-8") as f: + return json.load(f) + except (IOError, json.JSONDecodeError): + return {} + + async def _load_dynamic_config_from_db(self) -> Dict[str, Any]: + """Loads dynamic configuration overrides from the database.""" + db_configs = {} + result = await self.db.execute(select(SystemConfig)) + for record in result.scalars().all(): + db_configs[record.config_key] = record.config_value + return db_configs + + def _merge_configs(self, base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]: + """Deeply merges the override config into the base config.""" + for key, value in overrides.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + base[key] = self._merge_configs(base[key], value) + else: + base[key] = value + return base + + async def get_config(self) -> ConfigResponse: + """Gets the final, merged configuration.""" + base_config = self._load_base_config_from_file() + db_config = await self._load_dynamic_config_from_db() + + merged_config = self._merge_configs(base_config, db_config) + + return ConfigResponse( + database=DatabaseConfig(**merged_config.get("database", {})), + gemini_api=GeminiConfig(**merged_config.get("llm", {}).get("gemini", {})), + data_sources={ + k: DataSourceConfig(**v) + for k, v in merged_config.get("data_sources", {}).items() + } + ) + + async def update_config(self, config_update: ConfigUpdateRequest) -> ConfigResponse: + """Updates configuration in the database and returns the new merged config.""" + update_dict = config_update.dict(exclude_unset=True) + + for key, value in update_dict.items(): + existing_config = await self.db.get(SystemConfig, key) + if existing_config: + # Merge with existing DB value before updating + if isinstance(existing_config.config_value, dict) and isinstance(value, dict): + merged_value = self._merge_configs(existing_config.config_value, value) + existing_config.config_value = merged_value + else: + existing_config.config_value = value + else: + new_config = SystemConfig(config_key=key, config_value=value) + self.db.add(new_config) + + await self.db.commit() + return await self.get_config() diff --git a/backend/app/services/tushare_client.py b/backend/app/services/tushare_client.py new file mode 100644 index 0000000..da901ad --- /dev/null +++ b/backend/app/services/tushare_client.py @@ -0,0 +1,52 @@ +""" +Minimal async client for Tushare Pro API +""" +from typing import Any, Dict, List, Optional +import httpx + +TUSHARE_PRO_URL = "https://api.tushare.pro" + + +class TushareClient: + def __init__(self, token: str): + self.token = token + self._client = httpx.AsyncClient(timeout=30) + + async def query( + self, + api_name: str, + params: Optional[Dict[str, Any]] = None, + fields: Optional[str] = None, + ) -> List[Dict[str, Any]]: + payload = { + "api_name": api_name, + "token": self.token, + "params": params or {}, + } + # default larger page size if not provided + if "limit" not in payload["params"]: + payload["params"]["limit"] = 5000 + if fields: + payload["fields"] = fields + resp = await self._client.post(TUSHARE_PRO_URL, json=payload) + resp.raise_for_status() + data = resp.json() + if data.get("code") != 0: + err = data.get("msg") or "Tushare error" + raise RuntimeError(f"{api_name}: {err}") + fields_def = data.get("data", {}).get("fields", []) + items = data.get("data", {}).get("items", []) + rows: List[Dict[str, Any]] = [] + for it in items: + row = {fields_def[i]: it[i] for i in range(len(fields_def))} + rows.append(row) + return rows + + async def aclose(self): + await self._client.aclose() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.aclose() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6b1eeaa --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +pydantic-settings==2.5.2 +SQLAlchemy==2.0.36 +aiosqlite==0.20.0 +alembic==1.13.3 +google-generativeai==0.8.3 diff --git a/docs/tasks.md b/docs/tasks.md index 1e04bf4..250a722 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -16,9 +16,9 @@ 此阶段专注于实现数据库模型和系统配置API,为上层业务逻辑提供基础。 -- **T2.1 [Backend/DB]**: 根据设计文档,使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。 -- **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本,在数据库中生成上述四张表。 -- **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。 +- [x] **T2.1 [Backend/DB]**: 根据设计文档,使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。 **[完成 - 2025-10-21]** +- [x] **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本,在数据库中生成上述四张表。 **[完成 - 2025-10-21]** +- [x] **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。 **[完成 - 2025-10-21]** - **T2.4 [Backend/API]**: 创建Pydantic Schema,用于配置接口的请求和响应 (`ConfigResponse`, `ConfigUpdateRequest`, `ConfigTestRequest`, `ConfigTestResponse`)。 - **T2.5 [Backend/API]**: 实现`/api/config`的`GET`和`PUT`端点,用于读取和更新系统配置。 - **T2.6 [Backend/API]**: 实现`/api/config/test`的`POST`端点,用于验证数据库连接等配置的有效性。 @@ -26,13 +26,14 @@ ## Phase 3: 前端基础与配置页面 (P1) 此阶段完成前端项目的基本设置,并开发出第一个功能页面——系统配置管理。 +**[完成 - 2025-10-21]** -- **T3.1 [Frontend]**: 集成并配置UI组件库 (Shadcn/UI)。 -- **T3.2 [Frontend]**: 设置前端路由,创建`/`, `/report/[symbol]`, 和 `/config`等页面骨架。 -- **T3.3 [Frontend]**: 引入状态管理库 (Zustand) 和数据请求库 (SWR/React-Query)。 -- **T3.4 [Frontend/UI]**: 开发`ConfigPage`组件,包含用于数据库、Gemini API和数据源配置的表单。 -- **T3.5 [Frontend/API]**: 编写API客户端函数,用于调用后端的`/api/config`系列接口。 -- **T3.6 [Frontend/Feature]**: 将API客户端与`ConfigPage`组件集成,实现前端对系统配置的读取、更新和测试功能。 +- [x] **T3.1 [Frontend]**: 集成并配置UI组件库 (Shadcn/UI)。 +- [x] **T3.2 [Frontend]**: 设置前端路由,创建`/`, `/report/[symbol]`, 和 `/config`等页面骨架。 +- [x] **T3.3 [Frontend]**: 引入状态管理库 (Zustand) 和数据请求库 (SWR/React-Query)。 +- [x] **T3.4 [Frontend/UI]**: 开发`ConfigPage`组件,包含用于数据库、Gemini API和数据源配置的表单。 +- [x] **T3.5 [Frontend/API]**: 编写API客户端函数,用于调用后端的`/api/config`系列接口。 +- [x] **T3.6 [Frontend/Feature]**: 将API客户端与`ConfigPage`组件集成,实现前端对系统配置的读取、更新和测试功能。 ## Phase 4: 核心功能 - 报告生成与进度追踪 (P1) diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 8418867..aba95b6 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,5 +1,18 @@ +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + /** @type {import('next').NextConfig} */ const nextConfig = { + // Explicitly set Turbopack root to this frontend directory to silence multi-lockfile warning + turbopack: { + root: __dirname, + }, + // Increase server timeout for long-running AI requests + experimental: { + proxyTimeout: 120000, // 120 seconds + }, async rewrites() { return [ { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3cede20..29596d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,14 +11,19 @@ "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.545.0", "next": "15.5.5", "react": "19.1.0", "react-dom": "19.1.0", + "react-markdown": "^10.1.0", "recharts": "^3.3.0", - "tailwind-merge": "^3.3.1" + "remark-gfm": "^4.0.1", + "swr": "^2.3.6", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1339,6 +1344,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", @@ -1400,6 +1436,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1976,13 +2042,39 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1997,6 +2089,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz", @@ -2011,7 +2118,6 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2027,6 +2133,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2321,6 +2433,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -2888,6 +3006,16 @@ "node": ">= 0.4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2999,6 +3127,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3016,6 +3154,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -3073,6 +3251,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3099,7 +3287,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -3288,7 +3475,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3308,6 +3494,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3351,6 +3550,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3367,6 +3575,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4032,6 +4253,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4048,6 +4279,12 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4483,6 +4720,56 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4530,6 +4817,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4554,6 +4847,30 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4699,6 +5016,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4758,6 +5085,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -4811,6 +5148,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5374,6 +5723,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5406,6 +5765,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5416,6 +5785,288 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5426,6 +6077,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5490,7 +6704,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5818,6 +7031,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5925,6 +7163,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5983,6 +7231,33 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -6161,6 +7436,72 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -6517,6 +7858,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6651,6 +8002,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6674,6 +8039,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz", + "integrity": "sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.11" + } + }, + "node_modules/style-to-object": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.11.tgz", + "integrity": "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6723,6 +8106,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwind-merge": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", @@ -6838,6 +8234,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -7011,6 +8427,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -7108,6 +8611,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -7267,6 +8798,45 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 4749edb..2b47445 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,14 +12,19 @@ "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.545.0", "next": "15.5.5", "react": "19.1.0", "react-dom": "19.1.0", + "react-markdown": "^10.1.0", "recharts": "^3.3.0", - "tailwind-merge": "^3.3.1" + "remark-gfm": "^4.0.1", + "swr": "^2.3.6", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/frontend/src/app/api/config/route.ts b/frontend/src/app/api/config/route.ts new file mode 100644 index 0000000..9850102 --- /dev/null +++ b/frontend/src/app/api/config/route.ts @@ -0,0 +1,20 @@ +import { NextRequest } from 'next/server'; + +const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://127.0.0.1:8000/api'; + +export async function GET() { + const resp = await fetch(`${BACKEND_BASE}/config`); + const text = await resp.text(); + return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } }); +} + +export async function PUT(req: NextRequest) { + const body = await req.text(); + const resp = await fetch(`${BACKEND_BASE}/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body, + }); + const text = await resp.text(); + return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } }); +} diff --git a/frontend/src/app/api/financials/[...slug]/route.ts b/frontend/src/app/api/financials/[...slug]/route.ts new file mode 100644 index 0000000..f6f8846 --- /dev/null +++ b/frontend/src/app/api/financials/[...slug]/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from 'next/server'; + +const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://127.0.0.1:8000/api'; + +export async function GET(req: NextRequest, { params }: { params: { slug: string[] } }) { + const url = new URL(req.url); + const path = params.slug.join('/'); + const target = `${BACKEND_BASE}/financials/${path}${url.search}`; + const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } }); + const text = await resp.text(); + return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } }); +} diff --git a/frontend/src/app/config/page.tsx b/frontend/src/app/config/page.tsx index f911a45..98268b3 100644 --- a/frontend/src/app/config/page.tsx +++ b/frontend/src/app/config/page.tsx @@ -1,229 +1,162 @@ -"use client"; +'use client'; -import { useEffect, useState } from "react"; +import { useState, useEffect } from 'react'; +import { useConfig, updateConfig, testConfig } from '@/hooks/useApi'; +import { useConfigStore, SystemConfig } from '@/stores/useConfigStore'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -type Config = { - llm?: { - provider?: "gemini" | "openai"; - gemini?: { api_key?: string; base_url?: string }; - openai?: { api_key?: string; base_url?: string }; - }; - data_sources?: { - tushare?: { api_key?: string }; - finnhub?: { api_key?: string }; - jp_source?: { api_key?: string }; - }; - database?: { url?: string }; - prompts?: { info?: string; finance?: string }; -}; - export default function ConfigPage() { - const [cfg, setCfg] = useState(null); + // 从 Zustand store 获取全局状态 + const { config, loading, error, setConfig } = useConfigStore(); + // 使用 SWR hook 加载初始配置 + useConfig(); + + // 本地表单状态 + const [dbUrl, setDbUrl] = useState(''); + const [geminiApiKey, setGeminiApiKey] = useState(''); + const [tushareApiKey, setTushareApiKey] = useState(''); + + // 测试结果状态 + const [dbTestResult, setDbTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [geminiTestResult, setGeminiTestResult] = useState<{ success: boolean; message: string } | null>(null); + + // 保存状态 const [saving, setSaving] = useState(false); - const [msg, setMsg] = useState(null); - const [health, setHealth] = useState("unknown"); - - // form inputs (敏感字段不回显,留空表示保持现有值) - const [provider, setProvider] = useState<"gemini" | "openai">("gemini"); - const [geminiBaseUrl, setGeminiBaseUrl] = useState(""); - const [geminiKey, setGeminiKey] = useState(""); // 留空则保留 - const [openaiBaseUrl, setOpenaiBaseUrl] = useState(""); - const [openaiKey, setOpenaiKey] = useState(""); // 留空则保留 - const [tushareKey, setTushareKey] = useState(""); // 留空则保留 - const [finnhubKey, setFinnhubKey] = useState(""); // 留空则保留 - const [jpKey, setJpKey] = useState(""); // 留空则保留 - const [dbUrl, setDbUrl] = useState(""); - const [promptInfo, setPromptInfo] = useState(""); - const [promptFinance, setPromptFinance] = useState(""); - - async function loadConfig() { - try { - const res = await fetch("/api/config"); - const data: Config = await res.json(); - setCfg(data); - // 非敏感字段可回显 - setProvider((data.llm?.provider as any) ?? "gemini"); - setGeminiBaseUrl(data.llm?.gemini?.base_url ?? ""); - setOpenaiBaseUrl(data.llm?.openai?.base_url ?? ""); - setDbUrl(data.database?.url ?? ""); - setPromptInfo(data.prompts?.info ?? ""); - setPromptFinance(data.prompts?.finance ?? ""); - } catch { - setMsg("加载配置失败"); - } - } - - async function saveConfig() { - if (!cfg) return; - setSaving(true); - setMsg(null); - try { - // 构造覆盖配置:敏感字段若为空则沿用现有值 - const next: Config = { - llm: { - provider, - gemini: { - base_url: geminiBaseUrl, - api_key: geminiKey || cfg.llm?.gemini?.api_key || undefined, - }, - openai: { - base_url: openaiBaseUrl, - api_key: openaiKey || cfg.llm?.openai?.api_key || undefined, - }, - }, - data_sources: { - tushare: { api_key: tushareKey || cfg.data_sources?.tushare?.api_key || undefined }, - finnhub: { api_key: finnhubKey || cfg.data_sources?.finnhub?.api_key || undefined }, - jp_source: { api_key: jpKey || cfg.data_sources?.jp_source?.api_key || undefined }, - }, - database: { url: dbUrl }, - prompts: { info: promptInfo, finance: promptFinance }, - }; - const res = await fetch("/api/config", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(next), - }); - const ok = await res.json(); - if (ok?.status === "ok") { - setMsg("保存成功"); - await loadConfig(); - // 清空敏感输入(避免页面存储) - setGeminiKey(""); - setOpenaiKey(""); - setTushareKey(""); - setFinnhubKey(""); - setJpKey(""); - } else { - setMsg("保存失败"); - } - } catch { - setMsg("保存失败"); - } finally { - setSaving(false); - } - } - - async function testHealth() { - try { - const res = await fetch("/health"); - const h = await res.json(); - setHealth(h?.status ?? "unknown"); - } catch { - setHealth("error"); - } - } + const [saveMessage, setSaveMessage] = useState(''); useEffect(() => { - loadConfig(); - testHealth(); - }, []); + if (config) { + setDbUrl(config.database?.url || ''); + // API Keys 不回显 + } + }, [config]); + + const handleSave = async () => { + setSaving(true); + setSaveMessage('保存中...'); + + const newConfig: Partial = { + database: { url: dbUrl }, + gemini_api: { api_key: geminiApiKey }, + data_sources: { + tushare: { api_key: tushareApiKey }, + }, + }; + + try { + const updated = await updateConfig(newConfig); + setConfig(updated); // 更新全局状态 + setSaveMessage('保存成功!'); + setGeminiApiKey(''); // 清空敏感字段输入 + setTushareApiKey(''); + } catch (e: any) { + setSaveMessage(`保存失败: ${e.message}`); + } finally { + setSaving(false); + setTimeout(() => setSaveMessage(''), 3000); + } + }; + + const handleTestDb = async () => { + const result = await testConfig('database', { url: dbUrl }); + setDbTestResult(result); + }; + + const handleTestGemini = async () => { + const result = await testConfig('gemini', { api_key: geminiApiKey || config?.gemini_api.api_key }); + setGeminiTestResult(result); + }; + + if (loading) return
Loading...
; + if (error) return
Error loading config: {error}
; return (

配置中心

- 切换 LLM、配置数据源与模板;不回显敏感密钥,留空表示保持现值。 + 管理系统配置,包括数据库、API密钥等。敏感密钥不回显,留空表示保持现值。

- 后端健康 - GET /health + 数据库配置 + PostgreSQL 连接设置 - - {health} - + +
+ + setDbUrl(e.target.value)} + placeholder="postgresql+asyncpg://user:pass@host:port/dbname" + className="flex-1" + /> + +
+ {dbTestResult && ( + + {dbTestResult.message} + + )}
- LLM 设置 - Gemini / OpenAI + AI 服务配置 + Google Gemini API 设置 - -
- - -
-
- - setGeminiBaseUrl(e.target.value)} /> -
-
- - setGeminiKey(e.target.value)} /> -
-
- - setOpenaiBaseUrl(e.target.value)} /> -
-
- - setOpenaiKey(e.target.value)} /> + +
+ + setGeminiApiKey(e.target.value)} + placeholder="留空表示保持现值" + className="flex-1" + /> +
+ {geminiTestResult && ( + + {geminiTestResult.message} + + )}
- 数据源密钥 - TuShare / Finnhub / JP + 数据源配置 + Tushare API 设置 - -
- - setTushareKey(e.target.value)} /> -
-
- - setFinnhubKey(e.target.value)} /> -
-
- - setJpKey(e.target.value)} /> + +
+ + setTushareApiKey(e.target.value)} + placeholder="留空表示保持现值" + className="flex-1" + />
- - - 数据库与模板 - 非敏感配置可回显 - - -
- - setDbUrl(e.target.value)} /> -
-
- - setPromptInfo(e.target.value)} /> -
-
- - setPromptFinance(e.target.value)} /> -
-
- - {msg && {msg}} -
-
-
+
+ + {saveMessage && {saveMessage}} +
); -} \ No newline at end of file +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 7fdb065..e84a2a5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,674 +1,56 @@ -"use client"; +'use client'; -/** - * 主页组件 - 上市公司基本面分析平台 - * - * 功能包括: - * - 股票搜索和建议 - * - 财务数据查询和展示 - * - 表格行配置管理 - * - 执行状态显示 - * - 图表可视化 - */ - -import { useState, useMemo } from "react"; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { StatusBar, useStatusBar } from "@/components/ui/status-bar"; -import { createDefaultStepManager } from "@/lib/execution-step-manager"; -import { RowSettingsPanel } from "@/components/ui/row-settings"; -import { useRowConfig } from "@/hooks/use-row-config"; -import { EnhancedTable } from "@/components/ui/enhanced-table"; -import { Notification } from "@/components/ui/notification"; -import { - normalizeTsCode, - flattenApiGroups, - enhanceErrorMessage, - isRetryableError, - formatFinancialValue, - getMetricUnit -} from "@/lib/financial-utils"; -import type { - MarketType, - ChartType, - CompanyInfo, - CompanySuggestion, - RevenueDataPoint, - FinancialMetricConfig, - FinancialDataSeries, - ExecutionStep, - BatchFinancialDataResponse, - FinancialConfigResponse, - SearchApiResponse, - BatchDataRequest -} from "@/types"; -import { - ResponsiveContainer, - BarChart, - Bar, - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, -} from "recharts"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -export default function Home() { - // ============================================================================ - // 基础状态管理 - // ============================================================================ - const [market, setMarket] = useState("cn"); - const [query, setQuery] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [chartType, setChartType] = useState("bar"); +export default function StockInputForm() { + const [symbol, setSymbol] = useState(''); + const [market, setMarket] = useState('china'); + const router = useRouter(); - // ============================================================================ - // 数据状态管理 - // ============================================================================ - const [items, setItems] = useState([]); - const [selected, setSelected] = useState(null); - const [configItems, setConfigItems] = useState([]); - const [metricSeries, setMetricSeries] = useState({}); - const [selectedMetric, setSelectedMetric] = useState("revenue"); - const [selectedMetricName, setSelectedMetricName] = useState("营业收入"); - const [paramToGroup, setParamToGroup] = useState>({}); - const [paramToApi, setParamToApi] = useState>({}); - - // ============================================================================ - // 搜索相关状态 - // ============================================================================ - const [suggestions, setSuggestions] = useState([]); - const [typingTimer, setTypingTimer] = useState(null); - - // ============================================================================ - // 状态栏管理 - // ============================================================================ - const { - statusBarState, - showStatusBar, - showSuccess, - showError, - hideStatusBar - } = useStatusBar(); - - // ============================================================================ - // 执行步骤管理 - // ============================================================================ - const [stepManager] = useState(() => { - return createDefaultStepManager({ - onStepStart: (step: ExecutionStep, index: number, total: number) => { - showStatusBar(step, index, total); - }, - onStepComplete: (_step: ExecutionStep, index: number, total: number) => { - // If there are more steps, update to next step - if (index < total - 1) { - // This will be handled by the next step start - } else { - // All steps completed - showSuccess(); - } - }, - onStepError: (_step: ExecutionStep, _index: number, _total: number, error: Error) => { - // 判断错误是否可重试 - const isRetryable = isRetryableError(error) || stepManager.canRetry(); - showError(error.message, isRetryable); - }, - onComplete: () => { - showSuccess(); - }, - onError: (error: Error) => { - // 判断错误是否可重试 - const isRetryable = isRetryableError(error) || stepManager.canRetry(); - showError(error.message, isRetryable); - } - }); - }); - - // ============================================================================ - // 表格行配置管理 - // ============================================================================ - const [isRowSettingsPanelOpen, setIsRowSettingsPanelOpen] = useState(false); - - // Row configuration management - memoize to prevent infinite re-renders - const rowIds = useMemo(() => - configItems.map(item => item.tushareParam || '').filter(Boolean), - [configItems] - ); - - const { - rowConfigs, - customRows, - updateRowConfig, - saveStatus, - clearSaveStatus, - addCustomRow, - deleteCustomRow, - updateRowOrder - } = useRowConfig(selected?.ts_code || null, rowIds); - - const rowDisplayTexts = useMemo(() => { - const texts = configItems.reduce((acc, item) => { - if (item.tushareParam) { - acc[item.tushareParam] = item.displayText; - } - return acc; - }, {} as Record); - - // 添加自定义行的显示文本 - Object.entries(customRows).forEach(([rowId, customRow]) => { - texts[rowId] = customRow.displayText; - }); - - return texts; - }, [configItems, customRows]); - - // ============================================================================ - // 搜索建议功能 - // ============================================================================ - - /** - * 获取搜索建议 - * @param text - 搜索文本 - */ - async function fetchSuggestions(text: string): Promise { - if (market !== "cn") { - setSuggestions([]); - return; - } - - const searchQuery = (text || "").trim(); - if (!searchQuery) { - setSuggestions([]); - return; - } - - try { - const response = await fetch( - `http://localhost:8000/api/search?query=${encodeURIComponent(searchQuery)}&limit=8` - ); - const data: SearchApiResponse = await response.json(); - const suggestions = Array.isArray(data?.items) ? data.items : []; - setSuggestions(suggestions); - } catch (error) { - console.warn('Failed to fetch suggestions:', error); - setSuggestions([]); - } - } - - // ============================================================================ - // 搜索处理功能 - // ============================================================================ - - /** - * 重试搜索的函数 - */ - const retrySearch = async (): Promise => { - if (stepManager.canRetry()) { - try { - await stepManager.retry(); - } catch { - // 错误已经在stepManager中处理 - } - } else { - // 如果不能重试,重新执行搜索 - await handleSearch(); + const handleSearch = () => { + if (symbol.trim()) { + router.push(`/report/${symbol.trim()}?market=${market}`); } }; - /** - * 处理搜索请求 - */ - async function handleSearch(): Promise { - // 重置状态 - setError(""); - setItems([]); - setMetricSeries({}); - setConfigItems([]); - setSelectedMetric("revenue"); - - // 验证市场支持 - if (market !== "cn") { - setError("目前仅支持 A 股查询,请选择\"中国\"市场。"); - return; - } - - // 获取并验证股票代码 - const tsCode = selected?.ts_code || normalizeTsCode(query); - if (!tsCode) { - setError("请输入有效的 A 股股票代码(如 600519 / 000001 或带后缀 600519.SH)。"); - return; - } - - setLoading(true); - - try { - // 创建搜索执行步骤 - const searchStep: ExecutionStep = { - id: 'fetch_financial_data', - name: '正在读取财务数据', - description: '从Tushare API获取公司财务指标数据', - execute: async () => { - await executeSearchStep(tsCode); - } - }; - - // 清空之前的步骤并添加新的搜索步骤 - stepManager.clearSteps(); - stepManager.addStep(searchStep); - - // 执行搜索步骤 - await stepManager.execute(); - - } catch (error) { - const errorMsg = enhanceErrorMessage(error); - setError(errorMsg); - // 错误处理已经在stepManager的回调中处理 - } finally { - setLoading(false); - } - } - - /** - * 执行搜索步骤的具体逻辑 - * @param tsCode - 股票代码 - */ - async function executeSearchStep(tsCode: string): Promise { - // 1) 获取配置(tushare专用),解析 api_groups -> 扁平 items - const configResponse = await fetch(`http://localhost:8000/api/financial-config`); - const configData: FinancialConfigResponse = await configResponse.json(); - const groups = configData?.api_groups || {}; - - const { items, groupMap, apiMap } = flattenApiGroups(groups); - setConfigItems(items); - setParamToGroup(groupMap); - setParamToApi(apiMap); - - // 2) 批量请求年度序列(同API字段合并读取) - const years = 10; - const metrics = Array.from(new Set(["revenue", ...items.map(i => i.tushareParam)])); - - const batchRequest: BatchDataRequest = { - ts_code: tsCode, - years, - metrics - }; - - const batchResponse = await fetch( - `http://localhost:8000/api/financialdata/${encodeURIComponent(market)}/batch`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(batchRequest), - } - ); - - const batchData: BatchFinancialDataResponse = await batchResponse.json(); - const seriesObj = batchData?.series || {}; - - // 处理数据系列 - const processedSeries: FinancialDataSeries = {}; - for (const metric of metrics) { - const series = Array.isArray(seriesObj[metric]) ? seriesObj[metric] : []; - processedSeries[metric] = [...series].sort((a, b) => Number(a.year) - Number(b.year)); - } - setMetricSeries(processedSeries); - - // 3) 设置选中公司与默认图表序列(收入) - setSelected({ - ts_code: batchData?.ts_code || tsCode, - name: batchData?.name - }); - - const revenueSeries = processedSeries["revenue"] || []; - setItems(revenueSeries.map(d => ({ year: d.year, revenue: d.value }))); - - const revenueName = items.find(i => i.tushareParam === "revenue")?.displayText || "营业收入"; - setSelectedMetricName(revenueName); - - if (revenueSeries.length === 0) { - throw new Error("未查询到数据,请确认代码或稍后重试。"); - } - } - - // ============================================================================ - // 渲染组件 - // ============================================================================ - return ( -
- {/* StatusBar Component */} - - - {/* Configuration Save Status Notification */} - {saveStatus.status !== 'idle' && ( - - )} - - {/* Row Settings Panel */} - setIsRowSettingsPanelOpen(false)} - rowConfigs={rowConfigs} - rowDisplayTexts={rowDisplayTexts} - onConfigChange={updateRowConfig} - onRowOrderChange={updateRowOrder} - onDeleteCustomRow={deleteCustomRow} - enableRowReordering={true} - /> - -
-

上市公司基本面分析

-

- 使用 Next.js + shadcn/ui 构建。你可以在此搜索公司、查看财报与关键指标。 -

-
- - { - const v = e.target.value; - setQuery(v); - setSelected(null); - if (typingTimer) clearTimeout(typingTimer); - const t = setTimeout(() => fetchSuggestions(v), 250); - setTypingTimer(t); - }} - /> - {/* 下拉建议 */} - {suggestions.length > 0 && ( -
- {suggestions.map((s, i) => ( -
{ - setQuery(`${s.ts_code} ${s.name}`); - setSelected({ ts_code: s.ts_code, name: s.name }); - setSuggestions([]); - }} - > - {s.name} - {s.ts_code} -
- ))} -
- )} - -
- {error && {error}} -
- -
- {items.length > 0 && ( - - -
-
- 近10年指标(A股) - 数据来自 Tushare,{selected && selected.name ? `${selected.name} (${selected.ts_code})` : selected?.ts_code} -
-
- -
-
-
- - {/* 使用 Recharts 渲染图表(动态单位) */} - {(() => { - // 获取当前选中指标的信息 - const currentMetricInfo = configItems.find(ci => (ci.tushareParam || "") === selectedMetric); - const metricGroup = currentMetricInfo?.group; - const metricApi = currentMetricInfo?.api; - const metricUnit = getMetricUnit(metricGroup, metricApi, selectedMetric); - - // 构建完整的图例名称 - const legendName = `${selectedMetricName}${metricUnit}`; - - // 根据指标类型确定数据缩放和单位 - const shouldScaleToYi = ( - metricGroup === "income" || - metricGroup === "balancesheet" || - metricGroup === "cashflow" || - selectedMetric === "total_mv" - ); - - const chartData = items.map((d) => { - let scaledValue = typeof d.revenue === "number" ? d.revenue : 0; - - if (shouldScaleToYi) { - // 对于财务报表数据,转换为亿元 - if (selectedMetric === "total_mv") { - // 市值从万元转为亿元 - scaledValue = scaledValue / 1e4; - } else { - // 其他财务数据从元转为亿元 - scaledValue = scaledValue / 1e8; - } - } - - return { - year: d.year, - metricValue: scaledValue, - }; - }); - - return ( -
- - {chartType === "bar" ? ( - - - - `${Math.round(v)}`} /> - { - const v = Number(value); - const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); - const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }); - - if (shouldScaleToYi) { - if (selectedMetric === "total_mv") { - return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName]; - } else { - return [`${nf1.format(v)} 亿元`, selectedMetricName]; - } - } else { - return [`${nf1.format(v)}`, selectedMetricName]; - } - }} /> - - - - ) : ( - - - - `${Math.round(v)}`} /> - { - const v = Number(value); - const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); - const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }); - - if (shouldScaleToYi) { - if (selectedMetric === "total_mv") { - return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName]; - } else { - return [`${nf1.format(v)} 亿元`, selectedMetricName]; - } - } else { - return [`${nf1.format(v)}`, selectedMetricName]; - } - }} /> - - - - )} - -
- ); - })()} - - {/* 增强数据表格 */} - {(() => { - const series = metricSeries; - const allYears = Object.values(series) - .flat() - .map(d => d.year); - if (allYears.length === 0) return null; - const yearsDesc = Array.from(new Set(allYears)).sort((a, b) => Number(b) - Number(a)); - const columns = yearsDesc; - - function valueOf(m: string, year: string): number | null | undefined { - const s = series[m] || []; - const f = s.find(d => d.year === year); - return f ? f.value : undefined; - } - - function fmtCell(m: string, y: string): string { - const v = valueOf(m, y); - const group = paramToGroup[m] || ""; - const api = paramToApi[m] || ""; - return formatFinancialValue(v, group, api, m); - } - - // 行点击切换图表数据源 - function onRowClick(m: string) { - setSelectedMetric(m); - // 指标中文名传给图表 - const rowInfo = configItems.find(ci => (ci.tushareParam || "") === m); - setSelectedMetricName(rowInfo?.displayText || m); - const s = series[m] || []; - setItems(s.map(d => ({ year: d.year, revenue: d.value }))); - } - - // 准备表格数据 - const baseTableData = configItems.map((row, idx) => { - const m = (row.tushareParam || "").trim(); - const values: Record = {}; - - if (m) { - columns.forEach(year => { - values[year] = fmtCell(m, year); - }); - } else { - columns.forEach(year => { - values[year] = "-"; - }); - } - - return { - id: m || `row_${idx}`, - displayText: row.displayText + getMetricUnit(row.group, row.api, row.tushareParam), - values, - group: row.group, - api: row.api, - tushareParam: row.tushareParam, - isCustomRow: false - }; - }); - - // 添加自定义行数据(仅分隔线) - const customRowData = Object.entries(customRows) - .filter(([, customRow]) => customRow.customRowType === 'separator') - .map(([rowId, customRow]) => { - const values: Record = {}; - columns.forEach(year => { - values[year] = "-"; // 分隔线不显示数据 - }); - - return { - id: rowId, - displayText: customRow.displayText, - values, - isCustomRow: true, - customRowType: customRow.customRowType - }; - }); - - // 合并基础数据和自定义行数据 - const tableData = [...baseTableData, ...customRowData]; - - return ( - setIsRowSettingsPanelOpen(true)} - onAddCustomRow={addCustomRow} - onDeleteCustomRow={deleteCustomRow} - onRowOrderChange={updateRowOrder} - enableAnimations={true} - animationDuration={300} - enableVirtualization={configItems.length > 50} - maxVisibleRows={50} - enableRowDragging={true} - /> - ); - })()} -
-
- )} -
+
+ + + 基本面分析报告 + 输入股票代码和市场,生成综合分析报告。 + + +
+ + setSymbol(e.target.value)} + /> +
+
+ + +
+ +
+
); -} \ No newline at end of file +} diff --git a/frontend/src/app/report/[symbol]/page.tsx b/frontend/src/app/report/[symbol]/page.tsx new file mode 100644 index 0000000..6d1c731 --- /dev/null +++ b/frontend/src/app/report/[symbol]/page.tsx @@ -0,0 +1,422 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; +import { useChinaFinancials } from '@/hooks/useApi'; +import { Spinner } from '@/components/ui/spinner'; +import { CheckCircle, XCircle } from 'lucide-react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useEffect, useState, useRef } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { CompanyProfileResponse } from '@/types'; + +export default function ReportPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const symbol = params.symbol as string; + const market = (searchParams.get('market') || '').toLowerCase(); + + const isChina = market === 'china' || market === 'cn'; + + // 规范化中国市场 ts_code:若为6位数字或无后缀,自动推断交易所 + const normalizedTsCode = (() => { + if (!isChina) return symbol; + if (!symbol) return symbol; + if (symbol.includes('.')) return symbol.toUpperCase(); + const onlyDigits = symbol.replace(/\D/g, ''); + if (onlyDigits.length === 6) { + const first = onlyDigits[0]; + if (first === '6') return `${onlyDigits}.SH`; + if (first === '0' || first === '3') return `${onlyDigits}.SZ`; + } + return symbol.toUpperCase(); + })(); + + const { data: financials, error, isLoading } = useChinaFinancials(isChina ? normalizedTsCode : undefined); + + // 公司简介状态(一次性加载) + const [profileContent, setProfileContent] = useState(''); + const [profileLoading, setProfileLoading] = useState(false); + const [profileError, setProfileError] = useState(null); + const fetchedRef = useRef(false); // 防止重复请求 + + // 当财务数据加载完成后,加载公司简介 + useEffect(() => { + if (!isChina || isLoading || error || !financials || fetchedRef.current) { + return; + } + + fetchedRef.current = true; // 标记已请求 + + const fetchProfile = async () => { + setProfileLoading(true); + setProfileError(null); + setProfileContent(''); + + try { + const response = await fetch( + `/api/financials/china/${normalizedTsCode}/company-profile?company_name=${encodeURIComponent(financials.name || normalizedTsCode)}` + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: CompanyProfileResponse = await response.json(); + + if (data.success) { + setProfileContent(data.content); + } else { + setProfileError(data.error || '生成失败'); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '加载失败'; + setProfileError(errorMessage); + } finally { + setProfileLoading(false); + } + }; + + fetchProfile(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isChina, isLoading, error, financials, normalizedTsCode]); + + return ( +
+
+ {/* 左侧:报告信息卡片 */} + + + 报告页面 + + +
+ 股票代码: + {normalizedTsCode} +
+
+ 交易市场: + {market} +
+
+ 公司名称: + + {isLoading ? ( + + + 加载中... + + ) : financials?.name ? ( + financials.name + ) : ( + - + )} + +
+
+
+ + {/* 右侧:任务状态 */} + {isChina && ( + + + 任务状态 + + + {/* 财务数据任务 */} +
+ {isLoading ? ( + + ) : error ? ( + + ) : financials ? ( + + ) : null} +
+
财务数据获取
+
{normalizedTsCode} · {market}
+
+
+ + {/* 公司简介任务 */} + {!isLoading && !error && financials && ( +
+ {profileLoading ? ( + + ) : profileError ? ( + + ) : profileContent ? ( + + ) : null} +
+
公司简介生成
+
{normalizedTsCode} · Gemini AI
+
+
+ )} +
+
+ )} +
+ + {isChina && ( + + + 财务数据 + 公司简介 + 执行详情 + + + +

财务数据(来自 Tushare)

+
+ {isLoading ? ( + + ) : error ? ( + + ) : ( + + )} +
+ {isLoading + ? '正在读取 Tushare 数据…' + : error + ? '读取失败' + : '读取完成'} +
+
+ {error &&

加载失败

} + + {isLoading && ( +
+ 加载中 + +
+ )} + + + + {financials && ( +
+ {(() => { + const series = financials?.series ?? {}; + const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null }>; + const years = Array.from(new Set(allPoints.map((p) => p?.year).filter(Boolean))).sort(); + if (years.length === 0) { + return

暂无可展示的数据

; + } + return ( + + + + + {years.map((y) => ( + + ))} + + + + {Object.entries(series).map(([metric, points]) => ( + + + {years.map((y) => { + const pointsArray = points as Array<{ year?: string; value?: number | null }>; + const v = pointsArray.find((p) => p?.year === y)?.value; + return ; + })} + + ))} + +
指标{y}
{metric}{v ?? '-'}
+ ); + })()} +
+ )} +
+ + +

公司简介(来自 Gemini AI)

+ + {!financials && ( +

请等待财务数据加载完成...

+ )} + + {financials && ( + <> +
+ {profileLoading ? ( + + ) : profileError ? ( + + ) : profileContent ? ( + + ) : null} +
+ {profileLoading + ? '正在生成公司简介...' + : profileError + ? '生成失败' + : profileContent + ? '生成完成' + : ''} +
+
+ + {profileError && ( +

加载失败: {profileError}

+ )} + + {(profileLoading || profileContent) && ( +
+
+
+

, + h2: ({node, ...props}) =>

, + h3: ({node, ...props}) =>

, + p: ({node, ...props}) =>

, + ul: ({node, ...props}) =>