diff --git a/.env.template b/.env.template index bb5c737..2c99b59 100644 --- a/.env.template +++ b/.env.template @@ -3,7 +3,20 @@ POSTGRES_PASSWORD=postgrespassword POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 POSTGRES_DB=kilostar -# 必须填写一个高熵随机字符串,建议生成命令: +# JWT 签名密钥,必须填写一个高熵随机字符串,建议生成命令: # python -c "import secrets; print(secrets.token_urlsafe(32))" # 留空或填 "secret" / "114514" / "changethiskey12345" 等弱值会被拒绝。 SECRET_KEY= + +# 数据加密密钥(Fernet),用于加密 provider apikey / tool config 中的敏感字段。 +# 与 SECRET_KEY 独立,生成命令: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +KILOSTAR_SECRET_KEY= + +# CORS 允许的源(逗号分隔,默认 "*" 允许所有源但禁用 credentials)。 +# 生产建议显式列出:KILOSTAR_CORS_ORIGINS=https://app.example.com,https://admin.example.com +KILOSTAR_CORS_ORIGINS=* + +# 日志格式:开发默认彩色 console;生产可设为 json 以便 ELK/Loki 采集。 +# KILOSTAR_LOG_FORMAT=json +# KILOSTAR_LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index 225702f..3237735 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,8 @@ wheels/ # Virtual environments .venv -.idea \ No newline at end of file +.idea +# Local runtime data (MCP registry, etc.) +data/ +tmp/ +.env diff --git a/Makefile b/Makefile index 59a74f1..8d2623f 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,23 @@ run: uv run main.py + +clean-cache: + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true + +# Alembic 数据库迁移:m="message" 控制 revision 描述 +db-revision: + uv run alembic revision --autogenerate -m "$(m)" + +db-upgrade: + uv run alembic upgrade head + +db-downgrade: + uv run alembic downgrade -1 + +db-history: + uv run alembic history --verbose + +db-stamp-head: + uv run alembic stamp head + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..0929523 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,58 @@ +# A generic, single database configuration. + +[alembic] +# 迁移脚本目录 +script_location = alembic + +# 默认时间戳模板 +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# 时区 +timezone = Asia/Shanghai + +# Path to alembic version_path(多环境可以分目录) +version_locations = alembic/versions + +# 数据库 URL:留空,由 env.py 从环境变量动态读取 +sqlalchemy.url = + +[post_write_hooks] +# 默认不做格式化;如需可启用 black +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 88 REVISION_SCRIPT_FILENAME + +[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/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..a1d6d46 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,97 @@ +"""Alembic 迁移环境。 + +设计要点: +1. 数据库 URL 从 ``POSTGRES_*`` 环境变量动态拼装,不污染 ``alembic.ini``; +2. 复用项目本身的 ORM metadata:``BaseDataModel.metadata`` 让 autogenerate + 能识别全部表; +3. 与运行期保持一致使用 ``asyncpg`` 异步驱动 —— 通过 ``async_engine_from_config`` + 建立 AsyncEngine,再借 ``run_sync`` 把 alembic 的 sync migration 执行入口 + 挂载进去。这样无需额外引入 psycopg 等同步驱动。 +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from kilostar.core.postgres_database.model.base import BaseDataModel # noqa: E402 +from kilostar.core.postgres_database import model as _model_pkg # noqa: F401,E402 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +def _build_db_url() -> str: + """从 ``POSTGRES_*`` 环境变量拼装一个 asyncpg URL。""" + user = os.environ.get("POSTGRES_USER", "postgres") + password = os.environ.get("POSTGRES_PASSWORD", "postgrespassword") + host = os.environ.get("POSTGRES_HOST", "127.0.0.1") + port = os.environ.get("POSTGRES_PORT", "5432") + db = os.environ.get("POSTGRES_DB", "kilostar") + return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db}" + + +config.set_main_option("sqlalchemy.url", _build_db_url()) + +target_metadata = BaseDataModel.metadata + + +def run_migrations_offline() -> None: + """离线模式:不连库,直接把 SQL 渲染到 stdout。""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def _do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def _run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(_do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(_run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${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, 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: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/2026_05_31_0000-0001_initial_initial_baseline.py b/alembic/versions/2026_05_31_0000-0001_initial_initial_baseline.py new file mode 100644 index 0000000..4977d53 --- /dev/null +++ b/alembic/versions/2026_05_31_0000-0001_initial_initial_baseline.py @@ -0,0 +1,29 @@ +"""initial baseline + +Revision ID: 0001_initial +Revises: +Create Date: 2026-05-31 00:00:00 + +这是 Alembic 接入时的占位 baseline。 + +- 全新部署:``alembic upgrade head`` 会跑过这条 no-op, + 然后由后续 ``alembic revision --autogenerate`` 生成真正的建表脚本, + 或在首次部署时由应用 ``Base.metadata.create_all`` 直接建表,再 ``alembic stamp head``。 +- 已有数据库:直接 ``alembic stamp 0001_initial`` 标定基线,再做后续 autogenerate。 +""" + +from typing import Sequence, Union + + +revision: str = "0001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/README.md b/alembic/versions/README.md new file mode 100644 index 0000000..b11d17c --- /dev/null +++ b/alembic/versions/README.md @@ -0,0 +1,20 @@ +# Alembic versions + +迁移脚本会被自动生成到这个目录。 + +常用命令(在项目根目录运行): + +- 生成 baseline(首次接入,已有数据库): + `alembic stamp head` + +- 自动检测 ORM 与 DB 差异并生成迁移: + `alembic revision --autogenerate -m "your message"` + +- 应用所有未执行的迁移: + `alembic upgrade head` + +- 回滚一个版本: + `alembic downgrade -1` + +- 查看历史: + `alembic history --verbose` diff --git a/changelogs/ROADMAP.md b/changelogs/ROADMAP.md index b348ed4..d34a194 100644 --- a/changelogs/ROADMAP.md +++ b/changelogs/ROADMAP.md @@ -1,4 +1,3 @@ -# Roadmap --- ## [v0.1.0Alpha] - 2026/4/28 diff --git a/docker-compose.yml b/docker-compose.yml index 4f47305..21486c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,9 +24,9 @@ services: db: condition: service_healthy environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgrespassword} - - POSTGRES_HOST=db - - POSTGRES_PORT=5432 - - POSTGRES_DB=kilostar - - SECRET_KEY=${SECRET_KEY:?SECRET_KEY must be set; generate one via: python -c \"import secrets;print(secrets.token_urlsafe(32))\"} + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgrespassword} + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DB: kilostar + SECRET_KEY: ${SECRET_KEY} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5228b15..b8a51be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import i18n from './i18n'; import { TopBar } from './components/Layout/TopBar'; import { CollapsibleSidebar } from './components/Layout/CollapsibleSidebar'; import { SettingsLayout } from './components/Settings/SettingsLayout'; @@ -22,6 +23,7 @@ function App() { workTab, agentTab, applyTheme, + locale, } = useAppStore(); const { loadSessions } = useChatStore(); @@ -35,6 +37,13 @@ function App() { return () => mediaQuery.removeEventListener('change', handler); }, [applyTheme]); + // Sync persisted locale to i18next on mount + useEffect(() => { + if (locale && i18n.language !== locale) { + i18n.changeLanguage(locale); + } + }, [locale]); + // Check auth and load sessions useEffect(() => { const token = localStorage.getItem('token'); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b82b652..432deff 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -10,12 +10,15 @@ export const apiClient = axios.create({ }, }); -// Interceptor to attach token to requests if we have one +// Interceptor to attach token and locale to requests apiClient.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } + // 把用户语言偏好透传给后端,让 Agent prompt 和错误消息都能本地化 + const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh'; + config.headers['Accept-Language'] = lang; return config; }); diff --git a/frontend/src/components/Agent/ProvidersSettings.tsx b/frontend/src/components/Agent/ProvidersSettings.tsx index 3d5e8df..e548733 100644 --- a/frontend/src/components/Agent/ProvidersSettings.tsx +++ b/frontend/src/components/Agent/ProvidersSettings.tsx @@ -1,9 +1,11 @@ import { useState, useEffect } from 'react'; -import { Box, Plus, X, Server } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Box, Plus, X, Server, Loader2, Boxes } from 'lucide-react'; import type { Provider } from '../../types'; import apiClient from '../../api/client'; export function ProvidersSettings() { + const { t } = useTranslation(); const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); @@ -29,7 +31,7 @@ export function ProvidersSettings() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.provider_title || !formData.provider_url || !formData.provider_apikey) { - setError('Please fill in all fields.'); + setError(t('agent.providerFillAll')); return; } setSubmitLoading(true); @@ -39,7 +41,7 @@ export function ProvidersSettings() { await fetchProviders(); setIsModalOpen(false); } catch (err) { - setError('Failed to add provider. Please check your inputs and try again.'); + setError(t('agent.providerAddFailed')); } finally { setSubmitLoading(false); } @@ -49,20 +51,24 @@ export function ProvidersSettings() {
-

Provider Management

-

Configure external AI model providers

+

{t('agent.providerManagement')}

+

{t('agent.providerDesc')}

{loading ? ( -
Loading providers...
+
+ + {t('common.loading')} +
) : providers.length === 0 ? ( -
- No providers configured yet +
+ + {t('agent.noProviders')}
) : (
@@ -80,19 +86,19 @@ export function ProvidersSettings() {
{provider.status === 'Connected' && } - {provider.status || 'Unknown'} + {provider.status || t('common.unknown')}
-

Endpoint

-

{provider.provider_url || 'Default'}

+

{t('agent.endpoint')}

+

{provider.provider_url || t('common.default')}

- + + if (!confirm(t('agent.deleteProviderConfirm'))) return; + try { await apiClient.delete(`/api/v1/provider/${provider.provider_title}`); fetchProviders(); } catch { alert(t('common.deleteFailed')); } + }} className="px-3 py-1.5 text-xs font-medium text-danger bg-danger-bg hover:bg-danger-bg/80 rounded-lg transition-colors border border-danger/20">{t('common.delete')}
))} @@ -106,14 +112,14 @@ export function ProvidersSettings() {
-

Add New Provider

+

{t('agent.addNewProvider')}

{error &&
{error}
}
- + setFormData({...formData, [field]: e.target.value})} - placeholder={field === 'provider_title' ? 'My OpenAI' : field === 'provider_url' ? 'https://api.openai.com/v1' : 'sk-...'} + placeholder={field === 'provider_title' ? t('agent.providerTitlePlaceholder') : field === 'provider_url' ? t('agent.baseUrlPlaceholder') : t('agent.apiKeyPlaceholder')} className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono" />
))}
- - + +
diff --git a/frontend/src/components/Agent/WorkerIndividualSettings.tsx b/frontend/src/components/Agent/WorkerIndividualSettings.tsx index f670bb7..0efad13 100644 --- a/frontend/src/components/Agent/WorkerIndividualSettings.tsx +++ b/frontend/src/components/Agent/WorkerIndividualSettings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import apiClient from '../../api/client'; -import { Save, Plus, Edit2, Trash2, X, Bot } from 'lucide-react'; +import { Save, Plus, Edit2, Trash2, X, Bot, Loader2, Users } from 'lucide-react'; import type { Provider } from '../../types'; interface WorkerIndividual { @@ -18,6 +19,7 @@ interface WorkerIndividual { } export function WorkerIndividualSettings() { + const { t } = useTranslation(); const [providers, setProviders] = useState([]); const [workers, setWorkers] = useState([]); const [systemNodes, setSystemNodes] = useState([]); @@ -62,7 +64,7 @@ export function WorkerIndividualSettings() { }; })); } catch (err: any) { - setError('Failed to load data'); + setError(t('agent.loadFailed')); } finally { setLoading(false); } @@ -93,8 +95,8 @@ export function WorkerIndividualSettings() { }; const handleDelete = async (agent_id: string) => { - if (!confirm('Delete this agent?')) return; - try { await apiClient.delete(`/api/v1/agent/worker/${agent_id}`); fetchData(); } catch { alert('Failed'); } + if (!confirm(t('agent.deleteWorkerConfirm'))) return; + try { await apiClient.delete(`/api/v1/agent/worker/${agent_id}`); fetchData(); } catch { alert(t('common.deleteFailed')); } }; const handleModalSave = async (e: React.FormEvent) => { @@ -123,31 +125,31 @@ export function WorkerIndividualSettings() { setIsEditing(false); fetchData(); } catch (err: any) { - setModalMessage(err.response?.data?.detail || err.message || 'Failed to save'); + setModalMessage(err.response?.data?.detail || err.message || t('common.saveFailed')); } finally { setSubmitLoading(false); } }; const getTypeBadge = (type: string, isSystem?: boolean) => { - if (isSystem) return System; + if (isSystem) return {t('agent.system')}; const colors: Record = { ordinary_individual: 'bg-bg-secondary text-text-muted', skill_individual: 'bg-success-bg text-success', special_individual: 'bg-warning-bg text-warning', }; - return {type.replace('_', ' ')}; + return {t(`agent.type.${type}`, type.replace('_', ' '))}; }; return (
-

Individual

-

Manage system nodes and custom workers

+

{t('agent.individual')}

+

{t('agent.individualDesc')}

@@ -155,17 +157,23 @@ export function WorkerIndividualSettings() {
{loading ? ( -
Loading...
+
+ + {t('common.loading')} +
) : (workers.length === 0 && systemNodes.length === 0) ? ( -
No individuals found.
+
+ + {t('agent.noIndividuals')} +
) : ( - - - - + + + + @@ -212,47 +220,47 @@ export function WorkerIndividualSettings() { {/* Modal */} {isEditing && (
-
+
-

{(editData as any).is_system ? 'Edit System Node' : (isNew ? 'Create Worker' : 'Edit Worker')}

+

{(editData as any).is_system ? t('agent.editSystemNode') : (isNew ? t('agent.createWorker') : t('agent.editWorker'))}

- + setEditData({...editData, agent_name: e.target.value})} className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" disabled={(editData as any).is_system} />
- +
- +
- + {(() => { const sp = providers.find(p => p.provider_title === editData.provider_title); const models = sp?.provider_models || []; return ( ); @@ -262,40 +270,40 @@ export function WorkerIndividualSettings() { {!(editData as any).is_system && ( <>
- +
NameTypeProvider / ModelActions{t('agent.name')}{t('agent.type')}{t('agent.providerModel')}{t('common.actions')}