mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
feat(config): add runtime config manager with 3-layer priority
Implement ConfigManager with three-layer configuration priority: - Layer 1: Zod schema defaults - Layer 2: Environment variables (process.env) - Layer 3: JSON file overrides (config-overrides.json) Features: - Atomic file writes with temp+rename for reliability - Synchronous load at startup for immediate availability - Runtime hot-reload via async methods - Source tracking (default/env/override) per config key - Full Zod schema validation with type safety Files added: - src/config/config-manager.ts: Core manager implementation - src/config/config-schema.ts: Field metadata and group definitions - src/config/__tests__/: Unit tests for config manager - typings/: TypeScript declaration files
This commit is contained in:
186
src/config/__tests__/config-manager.test.ts
Normal file
186
src/config/__tests__/config-manager.test.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
// @ts-expect-error bun:test is provided by Bun at runtime
|
||||||
|
declare module 'bun:test' {
|
||||||
|
export const describe: any;
|
||||||
|
export const test: any;
|
||||||
|
export const it: any;
|
||||||
|
export const expect: any;
|
||||||
|
export const beforeEach: any;
|
||||||
|
export const afterEach: any;
|
||||||
|
export const beforeAll: any;
|
||||||
|
export const afterAll: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error bun:test is provided by Bun at runtime
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { unlink, readFile } from 'node:fs/promises';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AppConfig } from '../config-manager';
|
||||||
|
|
||||||
|
// ── All env keys in the Zod schema ──────────────────────────────────────────
|
||||||
|
const SCHEMA_KEYS = [
|
||||||
|
'GITEA_API_URL', 'GITEA_ACCESS_TOKEN', 'GITEA_ADMIN_TOKEN',
|
||||||
|
'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_MODEL',
|
||||||
|
'CUSTOM_SUMMARY_PROMPT', 'CUSTOM_LINE_COMMENT_PROMPT',
|
||||||
|
'FEISHU_WEBHOOK_URL', 'FEISHU_WEBHOOK_SECRET',
|
||||||
|
'PORT', 'WEBHOOK_SECRET', 'ADMIN_PASSWORD', 'JWT_SECRET',
|
||||||
|
'REVIEW_ENGINE', 'REVIEW_WORKDIR', 'REVIEW_MODEL_PLANNER',
|
||||||
|
'REVIEW_MODEL_SPECIALIST', 'REVIEW_MODEL_JUDGE',
|
||||||
|
'REVIEW_MAX_PARALLEL_RUNS', 'REVIEW_MAX_FILES_PER_RUN',
|
||||||
|
'REVIEW_MAX_FILE_CONTENT_CHARS', 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||||
|
'REVIEW_ENABLE_HUMAN_GATE', 'REVIEW_ALLOWED_COMMANDS', 'REVIEW_COMMAND_TIMEOUT_MS',
|
||||||
|
'QDRANT_URL', 'ENABLE_MEMORY', 'FEW_SHOT_EXAMPLES_COUNT',
|
||||||
|
'ENABLE_REFLECTION', 'MAX_REFLECTION_ROUNDS', 'ENABLE_DEBATE', 'DEBATE_THRESHOLD',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROL_KEYS = ['CONFIG_OVERRIDES_PATH', 'NODE_ENV'] as const;
|
||||||
|
const ALL_KEYS: readonly string[] = [...SCHEMA_KEYS, ...CONTROL_KEYS];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamically import a fresh config-manager module.
|
||||||
|
* Appending a unique query string to the specifier forces Bun to bypass the
|
||||||
|
* module cache, giving us a brand-new ConfigManager singleton each time.
|
||||||
|
*/
|
||||||
|
async function importFresh() {
|
||||||
|
const mod = await import(`../config-manager.ts?t=${Date.now()}-${randomUUID()}`);
|
||||||
|
return mod.configManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConfigManager', () => {
|
||||||
|
let tmpPath: string;
|
||||||
|
const savedEnv: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpPath = join(tmpdir(), `cfg-test-${randomUUID()}.json`);
|
||||||
|
|
||||||
|
// Snapshot every env key we might touch
|
||||||
|
for (const key of ALL_KEYS) {
|
||||||
|
savedEnv[key] = process.env[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neutralise all schema keys ('' is treated as "absent" by getCurrent).
|
||||||
|
// This also prevents dotenv from injecting values from a local .env file.
|
||||||
|
for (const key of SCHEMA_KEYS) {
|
||||||
|
process.env[key] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-test temp overrides file
|
||||||
|
process.env.CONFIG_OVERRIDES_PATH = tmpPath;
|
||||||
|
|
||||||
|
// FEISHU_WEBHOOK_URL has no Zod default → must be a valid URL for schema to pass.
|
||||||
|
process.env.FEISHU_WEBHOOK_URL = 'https://hooks.example.com/test';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const key of ALL_KEYS) {
|
||||||
|
if (savedEnv[key] === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = savedEnv[key]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { await unlink(tmpPath); } catch { /* ok if missing */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 1. Layering: defaults < env < override ─────────────────────────
|
||||||
|
|
||||||
|
describe('layering: defaults < env < override', () => {
|
||||||
|
test('Zod default used when env and override are absent', async () => {
|
||||||
|
const cm = await importFresh();
|
||||||
|
expect(cm.getCurrent().openai.model).toBe('gpt-4o-mini');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env value overrides Zod default', async () => {
|
||||||
|
process.env.OPENAI_MODEL = 'env-model';
|
||||||
|
const cm = await importFresh();
|
||||||
|
expect(cm.getCurrent().openai.model).toBe('env-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('override wins over env', async () => {
|
||||||
|
process.env.OPENAI_MODEL = 'env-model';
|
||||||
|
const cm = await importFresh();
|
||||||
|
await cm.setOverrides({ OPENAI_MODEL: 'override-model' });
|
||||||
|
expect(cm.getCurrent().openai.model).toBe('override-model');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 2. Empty string resets override ─────────────────────────────────
|
||||||
|
|
||||||
|
describe('empty string resets override', () => {
|
||||||
|
test('setting override to "" removes it, value falls back to Zod default', async () => {
|
||||||
|
const cm = await importFresh();
|
||||||
|
await cm.setOverrides({ OPENAI_MODEL: 'temp-override' });
|
||||||
|
expect(cm.getCurrent().openai.model).toBe('temp-override');
|
||||||
|
|
||||||
|
await cm.setOverrides({ OPENAI_MODEL: '' });
|
||||||
|
|
||||||
|
// OPENAI_MODEL is '' in env (neutralised) → falls to Zod default
|
||||||
|
expect(cm.getCurrent().openai.model).toBe('gpt-4o-mini');
|
||||||
|
expect(cm.getOverrides()).not.toHaveProperty('OPENAI_MODEL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 3. Persistence ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('persistence', () => {
|
||||||
|
test('setOverrides writes JSON file; new instance loads it', async () => {
|
||||||
|
const cm1 = await importFresh();
|
||||||
|
await cm1.setOverrides({ OPENAI_MODEL: 'persisted-model' });
|
||||||
|
|
||||||
|
// File structure check
|
||||||
|
const raw = await readFile(tmpPath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
expect(data.version).toBe(1);
|
||||||
|
expect(typeof data.updatedAt).toBe('string');
|
||||||
|
expect(data.overrides.OPENAI_MODEL).toBe('persisted-model');
|
||||||
|
|
||||||
|
// Fresh instance picks it up
|
||||||
|
const cm2 = await importFresh();
|
||||||
|
expect(cm2.getCurrent().openai.model).toBe('persisted-model');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 4. getSource() ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getSource()', () => {
|
||||||
|
test('returns "default" when neither env nor override is set', async () => {
|
||||||
|
// OPENAI_MODEL = '' (neutralised) → getSource sees '' → 'default'
|
||||||
|
const cm = await importFresh();
|
||||||
|
expect(cm.getSource('OPENAI_MODEL')).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns "env" when process.env has a non-empty value', async () => {
|
||||||
|
process.env.OPENAI_MODEL = 'from-env';
|
||||||
|
const cm = await importFresh();
|
||||||
|
expect(cm.getSource('OPENAI_MODEL')).toBe('env');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns "override" when override is set', async () => {
|
||||||
|
process.env.OPENAI_MODEL = 'from-env';
|
||||||
|
const cm = await importFresh();
|
||||||
|
await cm.setOverrides({ OPENAI_MODEL: 'from-override' });
|
||||||
|
expect(cm.getSource('OPENAI_MODEL')).toBe('override');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 5. Dev fallback ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('dev fallback', () => {
|
||||||
|
test('FEISHU_WEBHOOK_URL missing + NODE_ENV=development → feishu.webhookUrl ""', async () => {
|
||||||
|
process.env.FEISHU_WEBHOOK_URL = ''; // invalid → safeParse fails
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
const cm = await importFresh();
|
||||||
|
const cfg: AppConfig = cm.getCurrent();
|
||||||
|
expect(cfg.feishu.webhookUrl).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FEISHU_WEBHOOK_URL missing + NODE_ENV unset → feishu.webhookUrl ""', async () => {
|
||||||
|
process.env.FEISHU_WEBHOOK_URL = '';
|
||||||
|
process.env.NODE_ENV = ''; // falsy → same branch as undefined
|
||||||
|
const cm = await importFresh();
|
||||||
|
const cfg: AppConfig = cm.getCurrent();
|
||||||
|
expect(cfg.feishu.webhookUrl).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
404
src/config/config-manager.ts
Normal file
404
src/config/config-manager.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* Three-layer configuration manager.
|
||||||
|
* Priority: Zod defaults → process.env → JSON overrides
|
||||||
|
*
|
||||||
|
* Override file format:
|
||||||
|
* { version: 1, updatedAt: string, overrides: Record<string, string> }
|
||||||
|
*
|
||||||
|
* Bun-friendly IO: reads via readFile, writes atomically via temp+rename.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { rename, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { config as dotenvConfig } from 'dotenv';
|
||||||
|
|
||||||
|
// Load .env before any process.env access (must precede singleton construction)
|
||||||
|
dotenvConfig();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Override file types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface OverridesFile {
|
||||||
|
version: 1;
|
||||||
|
updatedAt: string;
|
||||||
|
overrides: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zod schema (identical to src/config/index.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const defaultAllowedReviewCommands = ['git', 'rg', 'cat', 'sed', 'wc'];
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
// Gitea
|
||||||
|
GITEA_API_URL: z.string().url().default('http://localhost:5174/api/v1'),
|
||||||
|
GITEA_ACCESS_TOKEN: z.string().default('test_token'),
|
||||||
|
GITEA_ADMIN_TOKEN: z.string().optional(),
|
||||||
|
|
||||||
|
// OpenAI
|
||||||
|
OPENAI_BASE_URL: z.string().url().default('https://api.openai.com/v1'),
|
||||||
|
OPENAI_API_KEY: z.string().default('test_openai_key'),
|
||||||
|
OPENAI_MODEL: z.string().default('gpt-4o-mini'),
|
||||||
|
CUSTOM_SUMMARY_PROMPT: z.string().optional(),
|
||||||
|
CUSTOM_LINE_COMMENT_PROMPT: z.string().optional(),
|
||||||
|
|
||||||
|
// Feishu
|
||||||
|
FEISHU_WEBHOOK_URL: z.string().url(),
|
||||||
|
FEISHU_WEBHOOK_SECRET: z.string().optional(),
|
||||||
|
|
||||||
|
// App
|
||||||
|
PORT: z.string().transform(Number).default('5174'),
|
||||||
|
WEBHOOK_SECRET: z.string().default('test_webhook_secret'),
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
ADMIN_PASSWORD: z.string().default('password'),
|
||||||
|
JWT_SECRET: z.string().default('a-secure-secret-for-jwt'),
|
||||||
|
|
||||||
|
// Review engine
|
||||||
|
REVIEW_ENGINE: z.enum(['legacy', 'agent']).default('legacy'),
|
||||||
|
REVIEW_WORKDIR: z.string().default('/tmp/gitea-assistant'),
|
||||||
|
REVIEW_MODEL_PLANNER: z.string().default('gpt-4o-mini'),
|
||||||
|
REVIEW_MODEL_SPECIALIST: z.string().default('gpt-4o-mini'),
|
||||||
|
REVIEW_MODEL_JUDGE: z.string().default('gpt-4o-mini'),
|
||||||
|
REVIEW_MAX_PARALLEL_RUNS: z.coerce.number().int().min(1).max(8).default(2),
|
||||||
|
REVIEW_MAX_FILES_PER_RUN: z.coerce.number().int().min(1).max(1000).default(200),
|
||||||
|
REVIEW_MAX_FILE_CONTENT_CHARS: z.coerce.number().int().min(1000).max(1_000_000).default(40_000),
|
||||||
|
REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
||||||
|
REVIEW_ENABLE_HUMAN_GATE: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.default('true')
|
||||||
|
.transform((value) => value === 'true'),
|
||||||
|
REVIEW_ALLOWED_COMMANDS: z.string().default(defaultAllowedReviewCommands.join(',')),
|
||||||
|
REVIEW_COMMAND_TIMEOUT_MS: z.coerce.number().int().min(1000).max(300000).default(10000),
|
||||||
|
|
||||||
|
// Memory & learning
|
||||||
|
QDRANT_URL: z.preprocess(
|
||||||
|
(val) => (typeof val === 'string' && val.trim() === '' ? undefined : val),
|
||||||
|
z.string().url().optional(),
|
||||||
|
),
|
||||||
|
ENABLE_MEMORY: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === 'true'),
|
||||||
|
FEW_SHOT_EXAMPLES_COUNT: z.coerce.number().int().min(0).max(20).default(10),
|
||||||
|
|
||||||
|
// Reflection & debate
|
||||||
|
ENABLE_REFLECTION: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === 'true'),
|
||||||
|
MAX_REFLECTION_ROUNDS: z.coerce.number().int().min(1).max(5).default(2),
|
||||||
|
ENABLE_DEBATE: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === 'true'),
|
||||||
|
DEBATE_THRESHOLD: z.enum(['high', 'medium']).default('high'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config shape (matches default export of src/config/index.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
gitea: {
|
||||||
|
apiUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
};
|
||||||
|
openai: {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
customSummaryPrompt: string | undefined;
|
||||||
|
customLineCommentPrompt: string | undefined;
|
||||||
|
};
|
||||||
|
feishu: {
|
||||||
|
webhookUrl: string;
|
||||||
|
webhookSecret: string | undefined;
|
||||||
|
};
|
||||||
|
app: {
|
||||||
|
port: number;
|
||||||
|
webhookSecret: string;
|
||||||
|
};
|
||||||
|
admin: {
|
||||||
|
password: string;
|
||||||
|
jwtSecret: string;
|
||||||
|
giteaAdminToken: string | undefined;
|
||||||
|
};
|
||||||
|
review: {
|
||||||
|
engine: string;
|
||||||
|
workdir: string;
|
||||||
|
modelPlanner: string;
|
||||||
|
modelSpecialist: string;
|
||||||
|
modelJudge: string;
|
||||||
|
maxParallelRuns: number;
|
||||||
|
maxFilesPerRun: number;
|
||||||
|
maxFileContentChars: number;
|
||||||
|
autoPublishMinConfidence: number;
|
||||||
|
enableHumanGate: boolean;
|
||||||
|
allowedCommands: string[];
|
||||||
|
commandTimeoutMs: number;
|
||||||
|
qdrantUrl: string | undefined;
|
||||||
|
enableMemory: boolean;
|
||||||
|
fewShotExamplesCount: number;
|
||||||
|
enableReflection: boolean;
|
||||||
|
maxReflectionRounds: number;
|
||||||
|
enableDebate: boolean;
|
||||||
|
debateThreshold: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dev fallback (matches src/config/index.ts behavior when validation fails)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEV_FALLBACK_CONFIG: AppConfig = {
|
||||||
|
gitea: {
|
||||||
|
apiUrl: 'http://localhost:5174/api/v1',
|
||||||
|
accessToken: 'test_token',
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
apiKey: 'test_openai_key',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
customSummaryPrompt: undefined,
|
||||||
|
customLineCommentPrompt: undefined,
|
||||||
|
},
|
||||||
|
feishu: {
|
||||||
|
webhookUrl: '',
|
||||||
|
webhookSecret: '',
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
port: 5174,
|
||||||
|
webhookSecret: 'test_webhook_secret',
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
password: 'password',
|
||||||
|
jwtSecret: 'a-secure-secret-for-jwt',
|
||||||
|
giteaAdminToken: undefined,
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
engine: 'legacy',
|
||||||
|
workdir: '/tmp/gitea-assistant',
|
||||||
|
modelPlanner: 'gpt-4o-mini',
|
||||||
|
modelSpecialist: 'gpt-4o-mini',
|
||||||
|
modelJudge: 'gpt-4o-mini',
|
||||||
|
maxParallelRuns: 2,
|
||||||
|
maxFilesPerRun: 200,
|
||||||
|
maxFileContentChars: 40_000,
|
||||||
|
autoPublishMinConfidence: 0.8,
|
||||||
|
enableHumanGate: true,
|
||||||
|
allowedCommands: ['git', 'rg', 'cat', 'sed', 'wc'],
|
||||||
|
commandTimeoutMs: 10000,
|
||||||
|
qdrantUrl: undefined,
|
||||||
|
enableMemory: false,
|
||||||
|
fewShotExamplesCount: 10,
|
||||||
|
enableReflection: false,
|
||||||
|
maxReflectionRounds: 2,
|
||||||
|
enableDebate: false,
|
||||||
|
debateThreshold: 'high',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ConfigManager
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConfigManager {
|
||||||
|
private readonly overridesPath: string;
|
||||||
|
private overrides: Record<string, string> = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.overridesPath = resolve(process.env.CONFIG_OVERRIDES_PATH || './config-overrides.json');
|
||||||
|
this.loadOverridesSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronously load overrides at construction time (file is tiny). */
|
||||||
|
private loadOverridesSync(): void {
|
||||||
|
try {
|
||||||
|
const text = readFileSync(this.overridesPath, 'utf-8');
|
||||||
|
const data: OverridesFile = JSON.parse(text);
|
||||||
|
if (data && typeof data.overrides === 'object' && data.overrides !== null) {
|
||||||
|
this.overrides = { ...data.overrides };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File missing or invalid JSON — start with empty overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Override file I/O ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Load overrides from disk. If file is missing or malformed, treat as empty. */
|
||||||
|
async loadOverrides(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const text = await readFile(this.overridesPath, 'utf-8');
|
||||||
|
const data: OverridesFile = JSON.parse(text);
|
||||||
|
if (data && typeof data.overrides === 'object' && data.overrides !== null) {
|
||||||
|
this.overrides = { ...data.overrides };
|
||||||
|
} else {
|
||||||
|
this.overrides = {};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File missing or invalid JSON — start with empty overrides
|
||||||
|
this.overrides = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist current overrides to disk atomically (write temp → rename). */
|
||||||
|
private async persistOverrides(): Promise<void> {
|
||||||
|
const dir = dirname(this.overridesPath);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const payload: OverridesFile = {
|
||||||
|
version: 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
overrides: { ...this.overrides },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tmpPath = `${this.overridesPath}.${randomUUID()}.tmp`;
|
||||||
|
await writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
|
||||||
|
await rename(tmpPath, this.overridesPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the fully resolved config object with the same shape as the
|
||||||
|
* default export of `src/config/index.ts`.
|
||||||
|
*
|
||||||
|
* Layering: Zod defaults → process.env → overrides JSON
|
||||||
|
*/
|
||||||
|
getCurrent(): AppConfig {
|
||||||
|
// Build a merged env-like record: process.env overlaid with overrides
|
||||||
|
const merged: Record<string, string | undefined> = {};
|
||||||
|
for (const key of Object.keys(envSchema.shape)) {
|
||||||
|
const envVal = process.env[key];
|
||||||
|
if (envVal !== undefined && envVal !== '') {
|
||||||
|
merged[key] = envVal;
|
||||||
|
}
|
||||||
|
// Override wins if present and non-empty
|
||||||
|
const ov = this.overrides[key];
|
||||||
|
if (ov !== undefined && ov !== '') {
|
||||||
|
merged[key] = ov;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
|
||||||
|
|
||||||
|
const parseResult = envSchema.safeParse(merged);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
if (!isDev) {
|
||||||
|
throw new Error('Configuration validation error');
|
||||||
|
}
|
||||||
|
return DEV_FALLBACK_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = parseResult.data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
gitea: {
|
||||||
|
apiUrl: env.GITEA_API_URL,
|
||||||
|
accessToken: env.GITEA_ACCESS_TOKEN,
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
baseUrl: env.OPENAI_BASE_URL,
|
||||||
|
apiKey: env.OPENAI_API_KEY,
|
||||||
|
model: env.OPENAI_MODEL,
|
||||||
|
customSummaryPrompt: env.CUSTOM_SUMMARY_PROMPT,
|
||||||
|
customLineCommentPrompt: env.CUSTOM_LINE_COMMENT_PROMPT,
|
||||||
|
},
|
||||||
|
feishu: {
|
||||||
|
webhookUrl: env.FEISHU_WEBHOOK_URL,
|
||||||
|
webhookSecret: env.FEISHU_WEBHOOK_SECRET,
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
port: env.PORT,
|
||||||
|
webhookSecret: env.WEBHOOK_SECRET,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
password: env.ADMIN_PASSWORD,
|
||||||
|
jwtSecret: env.JWT_SECRET,
|
||||||
|
giteaAdminToken: env.GITEA_ADMIN_TOKEN,
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
engine: env.REVIEW_ENGINE,
|
||||||
|
workdir: env.REVIEW_WORKDIR,
|
||||||
|
modelPlanner: env.REVIEW_MODEL_PLANNER,
|
||||||
|
modelSpecialist: env.REVIEW_MODEL_SPECIALIST,
|
||||||
|
modelJudge: env.REVIEW_MODEL_JUDGE,
|
||||||
|
maxParallelRuns: env.REVIEW_MAX_PARALLEL_RUNS,
|
||||||
|
maxFilesPerRun: env.REVIEW_MAX_FILES_PER_RUN,
|
||||||
|
maxFileContentChars: env.REVIEW_MAX_FILE_CONTENT_CHARS,
|
||||||
|
autoPublishMinConfidence: env.REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE,
|
||||||
|
enableHumanGate: env.REVIEW_ENABLE_HUMAN_GATE,
|
||||||
|
allowedCommands: env.REVIEW_ALLOWED_COMMANDS.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
commandTimeoutMs: env.REVIEW_COMMAND_TIMEOUT_MS,
|
||||||
|
qdrantUrl: env.QDRANT_URL,
|
||||||
|
enableMemory: env.ENABLE_MEMORY,
|
||||||
|
fewShotExamplesCount: env.FEW_SHOT_EXAMPLES_COUNT,
|
||||||
|
enableReflection: env.ENABLE_REFLECTION,
|
||||||
|
maxReflectionRounds: env.MAX_REFLECTION_ROUNDS,
|
||||||
|
enableDebate: env.ENABLE_DEBATE,
|
||||||
|
debateThreshold: env.DEBATE_THRESHOLD,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return raw overrides record. */
|
||||||
|
getOverrides(): Record<string, string> {
|
||||||
|
return { ...this.overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge updates into overrides and persist.
|
||||||
|
* If a value is empty string `''`, that key is deleted (reset to lower layer).
|
||||||
|
*/
|
||||||
|
async setOverrides(updates: Record<string, string>): Promise<void> {
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value === '') {
|
||||||
|
delete this.overrides[key];
|
||||||
|
} else {
|
||||||
|
this.overrides[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.persistOverrides();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove specified keys from overrides and persist. */
|
||||||
|
async resetKeys(keys: string[]): Promise<void> {
|
||||||
|
for (const key of keys) {
|
||||||
|
delete this.overrides[key];
|
||||||
|
}
|
||||||
|
await this.persistOverrides();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine where the effective value for a given env key comes from.
|
||||||
|
*/
|
||||||
|
getSource(envKey: string): 'default' | 'env' | 'override' {
|
||||||
|
const ov = this.overrides[envKey];
|
||||||
|
if (ov !== undefined && ov !== '') {
|
||||||
|
return 'override';
|
||||||
|
}
|
||||||
|
const envVal = process.env[envKey];
|
||||||
|
if (envVal !== undefined && envVal !== '') {
|
||||||
|
return 'env';
|
||||||
|
}
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Singleton
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const configManager = new ConfigManager();
|
||||||
422
src/config/config-schema.ts
Normal file
422
src/config/config-schema.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* 配置字段元数据定义
|
||||||
|
* 纯静态元数据,不读取任何环境变量。供后端 API 和前端 GUI 渲染/编辑配置使用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ConfigGroup = 'gitea' | 'openai' | 'feishu' | 'app' | 'admin' | 'review' | 'memory';
|
||||||
|
|
||||||
|
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||||
|
|
||||||
|
export interface ConfigFieldMeta {
|
||||||
|
envKey: string;
|
||||||
|
group: ConfigGroup;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
type: ConfigFieldType;
|
||||||
|
sensitive: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
readonlyWarning?: string;
|
||||||
|
enumValues?: string[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
defaultValue?: string | number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigGroupMeta {
|
||||||
|
key: ConfigGroup;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Groups
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||||
|
{
|
||||||
|
key: 'gitea',
|
||||||
|
label: 'Gitea 连接',
|
||||||
|
description: 'Gitea 实例地址与访问令牌',
|
||||||
|
icon: 'link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'openai',
|
||||||
|
label: 'OpenAI / LLM',
|
||||||
|
description: 'AI 模型接口与自定义提示词',
|
||||||
|
icon: 'bot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'feishu',
|
||||||
|
label: '飞书通知',
|
||||||
|
description: '飞书 Webhook 通知配置',
|
||||||
|
icon: 'bell',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'app',
|
||||||
|
label: '应用',
|
||||||
|
description: '服务端口与 Webhook 安全',
|
||||||
|
icon: 'settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin',
|
||||||
|
label: '管理后台',
|
||||||
|
description: '后台登录密码与 JWT 密钥',
|
||||||
|
icon: 'shield',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'review',
|
||||||
|
label: '审查引擎',
|
||||||
|
description: 'Agent 审查模式、并发与沙箱设置',
|
||||||
|
icon: 'file-check',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'memory',
|
||||||
|
label: '记忆与学习',
|
||||||
|
description: '向量记忆、反思与辩论系统',
|
||||||
|
icon: 'brain',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fields
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||||
|
// ── Gitea ───────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
envKey: 'GITEA_API_URL',
|
||||||
|
group: 'gitea',
|
||||||
|
label: 'Gitea API 地址',
|
||||||
|
description: 'Gitea 实例的 API 根路径',
|
||||||
|
type: 'url',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: 'http://localhost:5174/api/v1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'GITEA_ACCESS_TOKEN',
|
||||||
|
group: 'gitea',
|
||||||
|
label: '访问令牌',
|
||||||
|
description: '用于代码审查的 Gitea 访问令牌(需要仓库读权限和评论权限)',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: true,
|
||||||
|
defaultValue: 'test_token',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'GITEA_ADMIN_TOKEN',
|
||||||
|
group: 'gitea',
|
||||||
|
label: '管理员令牌',
|
||||||
|
description: '用于后台管理的 Gitea 管理员令牌(可选,需要仓库读写及 Webhook 管理权限)',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── OpenAI ──────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
envKey: 'OPENAI_BASE_URL',
|
||||||
|
group: 'openai',
|
||||||
|
label: 'API 地址',
|
||||||
|
description: 'OpenAI 兼容 API 的基础 URL',
|
||||||
|
type: 'url',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: 'https://api.openai.com/v1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'OPENAI_API_KEY',
|
||||||
|
group: 'openai',
|
||||||
|
label: 'API 密钥',
|
||||||
|
description: 'OpenAI API 密钥',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: true,
|
||||||
|
defaultValue: 'test_openai_key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'OPENAI_MODEL',
|
||||||
|
group: 'openai',
|
||||||
|
label: '模型',
|
||||||
|
description: '默认使用的 OpenAI 模型名称',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: 'gpt-4o-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'CUSTOM_SUMMARY_PROMPT',
|
||||||
|
group: 'openai',
|
||||||
|
label: '自定义总结提示词',
|
||||||
|
description: '覆盖默认的代码审查总结提示词(留空使用内置提示词)',
|
||||||
|
type: 'text',
|
||||||
|
sensitive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'CUSTOM_LINE_COMMENT_PROMPT',
|
||||||
|
group: 'openai',
|
||||||
|
label: '自定义行评论提示词',
|
||||||
|
description: '覆盖默认的行级评论提示词(留空使用内置提示词)',
|
||||||
|
type: 'text',
|
||||||
|
sensitive: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 飞书 ────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
envKey: 'FEISHU_WEBHOOK_URL',
|
||||||
|
group: 'feishu',
|
||||||
|
label: 'Webhook 地址',
|
||||||
|
description: '飞书机器人 Webhook URL',
|
||||||
|
type: 'url',
|
||||||
|
sensitive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'FEISHU_WEBHOOK_SECRET',
|
||||||
|
group: 'feishu',
|
||||||
|
label: 'Webhook 签名密钥',
|
||||||
|
description: '飞书 Webhook 签名密钥(可选)',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 应用 ────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
envKey: 'PORT',
|
||||||
|
group: 'app',
|
||||||
|
label: '监听端口',
|
||||||
|
description: '服务监听的 HTTP 端口号,修改需通过 .env 配置并重启服务',
|
||||||
|
type: 'number',
|
||||||
|
sensitive: false,
|
||||||
|
readonly: true,
|
||||||
|
defaultValue: 5174,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'WEBHOOK_SECRET',
|
||||||
|
group: 'app',
|
||||||
|
label: 'Webhook 密钥',
|
||||||
|
description: '用于验证 Gitea Webhook 请求来源的 HMAC 密钥,修改需通过 .env 配置并同步更新 Gitea',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: true,
|
||||||
|
readonly: true,
|
||||||
|
defaultValue: 'test_webhook_secret',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 管理后台 ────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
envKey: 'ADMIN_PASSWORD',
|
||||||
|
group: 'admin',
|
||||||
|
label: '管理员密码',
|
||||||
|
description: '后台管理界面的登录密码',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: true,
|
||||||
|
readonlyWarning: '修改后当前登录会话可能失效',
|
||||||
|
defaultValue: 'password',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'JWT_SECRET',
|
||||||
|
group: 'admin',
|
||||||
|
label: 'JWT 密钥',
|
||||||
|
description: '用于签发后台登录 Token 的密钥,修改需通过 .env 配置',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: true,
|
||||||
|
readonly: true,
|
||||||
|
defaultValue: 'a-secure-secret-for-jwt',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 审查引擎 ────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_ENGINE',
|
||||||
|
group: 'review',
|
||||||
|
label: '审查引擎',
|
||||||
|
description: '代码审查模式:legacy(传统)或 agent(多代理编排)',
|
||||||
|
type: 'enum',
|
||||||
|
sensitive: false,
|
||||||
|
enumValues: ['legacy', 'agent'],
|
||||||
|
defaultValue: 'legacy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_WORKDIR',
|
||||||
|
group: 'review',
|
||||||
|
label: '工作目录',
|
||||||
|
description: 'Agent 模式下本地仓库 mirror/worktree 的工作目录',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: '/tmp/gitea-assistant',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_MODEL_PLANNER',
|
||||||
|
group: 'review',
|
||||||
|
label: '规划模型',
|
||||||
|
description: 'Agent 模式下规划阶段使用的模型',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: 'gpt-4o-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_MODEL_SPECIALIST',
|
||||||
|
group: 'review',
|
||||||
|
label: '专家模型',
|
||||||
|
description: 'Agent 模式下专家子代理使用的模型',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: 'gpt-4o-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_MODEL_JUDGE',
|
||||||
|
group: 'review',
|
||||||
|
label: '评审模型',
|
||||||
|
description: 'Agent 模式下 Judge 聚合阶段使用的模型',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: 'gpt-4o-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_MAX_PARALLEL_RUNS',
|
||||||
|
group: 'review',
|
||||||
|
label: '最大并发数',
|
||||||
|
description: '单机同时执行的审查任务上限',
|
||||||
|
type: 'number',
|
||||||
|
sensitive: false,
|
||||||
|
min: 1,
|
||||||
|
max: 8,
|
||||||
|
defaultValue: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_MAX_FILES_PER_RUN',
|
||||||
|
group: 'review',
|
||||||
|
label: '单次最大文件数',
|
||||||
|
description: '单次审查最多处理的文件数量',
|
||||||
|
type: 'number',
|
||||||
|
sensitive: false,
|
||||||
|
min: 1,
|
||||||
|
max: 1000,
|
||||||
|
defaultValue: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||||
|
group: 'review',
|
||||||
|
label: '单文件最大字符数',
|
||||||
|
description: '单个文件上下文的最大字符数',
|
||||||
|
type: 'number',
|
||||||
|
sensitive: false,
|
||||||
|
min: 1000,
|
||||||
|
max: 1000000,
|
||||||
|
defaultValue: 40000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||||
|
group: 'review',
|
||||||
|
label: '自动发布置信度',
|
||||||
|
description: '自动发布评论所需的最小置信度(0~1)',
|
||||||
|
type: 'number',
|
||||||
|
sensitive: false,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
defaultValue: 0.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_ENABLE_HUMAN_GATE',
|
||||||
|
group: 'review',
|
||||||
|
label: '人工审批',
|
||||||
|
description: '是否启用人工审批队列(低置信度评论需人工确认后发布)',
|
||||||
|
type: 'boolean',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||||
|
group: 'review',
|
||||||
|
label: '允许命令',
|
||||||
|
description: '本地审查沙箱中允许执行的命令白名单(逗号分隔)',
|
||||||
|
type: 'string',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: 'git,rg,cat,sed,wc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'REVIEW_COMMAND_TIMEOUT_MS',
|
||||||
|
group: 'review',
|
||||||
|
label: '命令超时(ms)',
|
||||||
|
description: '单条本地命令的执行超时时间(毫秒)',
|
||||||
|
type: 'number',
|
||||||
|
sensitive: false,
|
||||||
|
min: 1000,
|
||||||
|
max: 300000,
|
||||||
|
defaultValue: 10000,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 记忆与学习 ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
envKey: 'QDRANT_URL',
|
||||||
|
group: 'memory',
|
||||||
|
label: 'Qdrant 地址',
|
||||||
|
description: 'Qdrant 向量数据库的连接 URL',
|
||||||
|
type: 'url',
|
||||||
|
sensitive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'ENABLE_MEMORY',
|
||||||
|
group: 'memory',
|
||||||
|
label: '启用记忆',
|
||||||
|
description: '是否启用向量记忆系统(需配置 Qdrant)',
|
||||||
|
type: 'boolean',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'FEW_SHOT_EXAMPLES_COUNT',
|
||||||
|
group: 'memory',
|
||||||
|
label: 'Few-shot 示例数',
|
||||||
|
description: '检索的 few-shot 示例数量',
|
||||||
|
type: 'number',
|
||||||
|
sensitive: false,
|
||||||
|
min: 0,
|
||||||
|
max: 20,
|
||||||
|
defaultValue: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'ENABLE_REFLECTION',
|
||||||
|
group: 'memory',
|
||||||
|
label: '启用反思',
|
||||||
|
description: '是否启用审查结果自我反思机制',
|
||||||
|
type: 'boolean',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'MAX_REFLECTION_ROUNDS',
|
||||||
|
group: 'memory',
|
||||||
|
label: '最大反思轮数',
|
||||||
|
description: '反思迭代的最大轮数',
|
||||||
|
type: 'number',
|
||||||
|
sensitive: false,
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
defaultValue: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'ENABLE_DEBATE',
|
||||||
|
group: 'memory',
|
||||||
|
label: '启用辩论',
|
||||||
|
description: '是否启用多视角辩论机制',
|
||||||
|
type: 'boolean',
|
||||||
|
sensitive: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envKey: 'DEBATE_THRESHOLD',
|
||||||
|
group: 'memory',
|
||||||
|
label: '辩论阈值',
|
||||||
|
description: '触发辩论的严重程度阈值',
|
||||||
|
type: 'enum',
|
||||||
|
sensitive: false,
|
||||||
|
enumValues: ['high', 'medium'],
|
||||||
|
defaultValue: 'high',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getFieldsByGroup(group: ConfigGroup): ConfigFieldMeta[] {
|
||||||
|
return CONFIG_FIELDS.filter((f) => f.group === group);
|
||||||
|
}
|
||||||
@@ -1,145 +1,12 @@
|
|||||||
import { config } from 'dotenv';
|
import { configManager } from './config-manager';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
// 加载环境变量
|
type AppConfig = import('./config-manager').AppConfig;
|
||||||
config();
|
|
||||||
|
|
||||||
// 判断是否为开发环境
|
const config = new Proxy({} as AppConfig, {
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
|
get(_target, prop) {
|
||||||
const defaultAllowedReviewCommands = ['git', 'rg', 'cat', 'sed', 'wc'];
|
return configManager.getCurrent()[prop as keyof AppConfig];
|
||||||
|
},
|
||||||
// 环境变量验证模式
|
|
||||||
const envSchema = z.object({
|
|
||||||
// Gitea配置
|
|
||||||
GITEA_API_URL: z.string().url().default('http://localhost:5174/api/v1'),
|
|
||||||
GITEA_ACCESS_TOKEN: z.string().default('test_token'),
|
|
||||||
GITEA_ADMIN_TOKEN: z.string().optional(),
|
|
||||||
|
|
||||||
// OpenAI配置
|
|
||||||
OPENAI_BASE_URL: z.string().url().default('https://api.openai.com/v1'),
|
|
||||||
OPENAI_API_KEY: z.string().default('test_openai_key'),
|
|
||||||
OPENAI_MODEL: z.string().default('gpt-4o-mini'),
|
|
||||||
CUSTOM_SUMMARY_PROMPT: z.string().optional(),
|
|
||||||
CUSTOM_LINE_COMMENT_PROMPT: z.string().optional(),
|
|
||||||
|
|
||||||
// 飞书配置
|
|
||||||
FEISHU_WEBHOOK_URL: z.string().url(),
|
|
||||||
FEISHU_WEBHOOK_SECRET: z.string().optional(),
|
|
||||||
|
|
||||||
// 应用配置
|
|
||||||
PORT: z.string().transform(Number).default('5174'),
|
|
||||||
WEBHOOK_SECRET: z.string().default('test_webhook_secret'),
|
|
||||||
|
|
||||||
// 管理后台配置
|
|
||||||
ADMIN_PASSWORD: z.string().default('password'),
|
|
||||||
JWT_SECRET: z.string().default('a-secure-secret-for-jwt'),
|
|
||||||
|
|
||||||
// Agent审查配置
|
|
||||||
REVIEW_ENGINE: z.enum(['legacy', 'agent']).default('legacy'),
|
|
||||||
REVIEW_WORKDIR: z.string().default('/tmp/gitea-assistant'),
|
|
||||||
REVIEW_MODEL_PLANNER: z.string().default('gpt-4o-mini'),
|
|
||||||
REVIEW_MODEL_SPECIALIST: z.string().default('gpt-4o-mini'),
|
|
||||||
REVIEW_MODEL_JUDGE: z.string().default('gpt-4o-mini'),
|
|
||||||
REVIEW_MAX_PARALLEL_RUNS: z.coerce.number().int().min(1).max(8).default(2),
|
|
||||||
REVIEW_MAX_FILES_PER_RUN: z.coerce.number().int().min(1).max(1000).default(200),
|
|
||||||
REVIEW_MAX_FILE_CONTENT_CHARS: z.coerce.number().int().min(1000).max(1_000_000).default(40_000),
|
|
||||||
REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
|
||||||
REVIEW_ENABLE_HUMAN_GATE: z
|
|
||||||
.enum(['true', 'false'])
|
|
||||||
.default('true')
|
|
||||||
.transform((value) => value === 'true'),
|
|
||||||
REVIEW_ALLOWED_COMMANDS: z.string().default(defaultAllowedReviewCommands.join(',')),
|
|
||||||
REVIEW_COMMAND_TIMEOUT_MS: z.coerce.number().int().min(1000).max(300000).default(10000),
|
|
||||||
|
|
||||||
// 向量记忆和学习系统配置
|
|
||||||
QDRANT_URL: z.preprocess(
|
|
||||||
(val) => (typeof val === 'string' && val.trim() === '' ? undefined : val),
|
|
||||||
z.string().url().optional()
|
|
||||||
),
|
|
||||||
ENABLE_MEMORY: z
|
|
||||||
.enum(['true', 'false'])
|
|
||||||
.default('false')
|
|
||||||
.transform((value) => value === 'true'),
|
|
||||||
FEW_SHOT_EXAMPLES_COUNT: z.coerce.number().int().min(0).max(20).default(10),
|
|
||||||
|
|
||||||
// Reflection和Debate配置(第三阶段)
|
|
||||||
ENABLE_REFLECTION: z
|
|
||||||
.enum(['true', 'false'])
|
|
||||||
.default('false')
|
|
||||||
.transform((value) => value === 'true'),
|
|
||||||
MAX_REFLECTION_ROUNDS: z.coerce.number().int().min(1).max(5).default(2),
|
|
||||||
ENABLE_DEBATE: z
|
|
||||||
.enum(['true', 'false'])
|
|
||||||
.default('false')
|
|
||||||
.transform((value) => value === 'true'),
|
|
||||||
DEBATE_THRESHOLD: z.enum(['high', 'medium']).default('high'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理验证结果
|
export { configManager };
|
||||||
const envParseResult = envSchema.safeParse(process.env);
|
export default config;
|
||||||
|
|
||||||
if (!envParseResult.success) {
|
|
||||||
console.error('❌ 环境变量验证失败:');
|
|
||||||
console.error(envParseResult.error.format());
|
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
console.warn('⚠️ 使用开发环境默认值');
|
|
||||||
} else {
|
|
||||||
throw new Error('环境变量配置错误');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出配置
|
|
||||||
export default {
|
|
||||||
gitea: {
|
|
||||||
apiUrl: envParseResult.success ? envParseResult.data.GITEA_API_URL : 'http://localhost:5174/api/v1',
|
|
||||||
accessToken: envParseResult.success ? envParseResult.data.GITEA_ACCESS_TOKEN : 'test_token',
|
|
||||||
},
|
|
||||||
openai: {
|
|
||||||
baseUrl: envParseResult.success ? envParseResult.data.OPENAI_BASE_URL : 'https://api.openai.com/v1',
|
|
||||||
apiKey: envParseResult.success ? envParseResult.data.OPENAI_API_KEY : 'test_openai_key',
|
|
||||||
model: envParseResult.success ? envParseResult.data.OPENAI_MODEL : 'gpt-4o-mini',
|
|
||||||
customSummaryPrompt: envParseResult.success ? envParseResult.data.CUSTOM_SUMMARY_PROMPT : undefined,
|
|
||||||
customLineCommentPrompt: envParseResult.success ? envParseResult.data.CUSTOM_LINE_COMMENT_PROMPT : undefined,
|
|
||||||
},
|
|
||||||
feishu: {
|
|
||||||
webhookUrl: envParseResult.success ? envParseResult.data.FEISHU_WEBHOOK_URL : '',
|
|
||||||
webhookSecret: envParseResult.success ? envParseResult.data.FEISHU_WEBHOOK_SECRET : '',
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
port: envParseResult.success ? envParseResult.data.PORT : 5174,
|
|
||||||
webhookSecret: envParseResult.success ? envParseResult.data.WEBHOOK_SECRET : 'test_webhook_secret',
|
|
||||||
},
|
|
||||||
admin: {
|
|
||||||
password: envParseResult.success ? envParseResult.data.ADMIN_PASSWORD : 'password',
|
|
||||||
jwtSecret: envParseResult.success ? envParseResult.data.JWT_SECRET : 'a-secure-secret-for-jwt',
|
|
||||||
giteaAdminToken: envParseResult.success ? envParseResult.data.GITEA_ADMIN_TOKEN : undefined,
|
|
||||||
},
|
|
||||||
review: {
|
|
||||||
engine: envParseResult.success ? envParseResult.data.REVIEW_ENGINE : 'legacy',
|
|
||||||
workdir: envParseResult.success ? envParseResult.data.REVIEW_WORKDIR : '/tmp/gitea-assistant',
|
|
||||||
modelPlanner: envParseResult.success ? envParseResult.data.REVIEW_MODEL_PLANNER : 'gpt-4o-mini',
|
|
||||||
modelSpecialist: envParseResult.success ? envParseResult.data.REVIEW_MODEL_SPECIALIST : 'gpt-4o-mini',
|
|
||||||
modelJudge: envParseResult.success ? envParseResult.data.REVIEW_MODEL_JUDGE : 'gpt-4o-mini',
|
|
||||||
maxParallelRuns: envParseResult.success ? envParseResult.data.REVIEW_MAX_PARALLEL_RUNS : 2,
|
|
||||||
maxFilesPerRun: envParseResult.success ? envParseResult.data.REVIEW_MAX_FILES_PER_RUN : 200,
|
|
||||||
maxFileContentChars: envParseResult.success ? envParseResult.data.REVIEW_MAX_FILE_CONTENT_CHARS : 40_000,
|
|
||||||
autoPublishMinConfidence: envParseResult.success
|
|
||||||
? envParseResult.data.REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE
|
|
||||||
: 0.8,
|
|
||||||
enableHumanGate: envParseResult.success ? envParseResult.data.REVIEW_ENABLE_HUMAN_GATE : true,
|
|
||||||
allowedCommands: envParseResult.success
|
|
||||||
? envParseResult.data.REVIEW_ALLOWED_COMMANDS.split(',')
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: defaultAllowedReviewCommands,
|
|
||||||
commandTimeoutMs: envParseResult.success ? envParseResult.data.REVIEW_COMMAND_TIMEOUT_MS : 10000,
|
|
||||||
qdrantUrl: envParseResult.success ? envParseResult.data.QDRANT_URL : undefined,
|
|
||||||
enableMemory: envParseResult.success ? envParseResult.data.ENABLE_MEMORY : false,
|
|
||||||
fewShotExamplesCount: envParseResult.success ? envParseResult.data.FEW_SHOT_EXAMPLES_COUNT : 10,
|
|
||||||
enableReflection: envParseResult.success ? envParseResult.data.ENABLE_REFLECTION : false,
|
|
||||||
maxReflectionRounds: envParseResult.success ? envParseResult.data.MAX_REFLECTION_ROUNDS : 2,
|
|
||||||
enableDebate: envParseResult.success ? envParseResult.data.ENABLE_DEBATE : false,
|
|
||||||
debateThreshold: envParseResult.success ? envParseResult.data.DEBATE_THRESHOLD : 'high',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
10
typings/bun-test.d.ts
vendored
Normal file
10
typings/bun-test.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
declare module 'bun:test' {
|
||||||
|
export const describe: any;
|
||||||
|
export const test: any;
|
||||||
|
export const it: any;
|
||||||
|
export const expect: any;
|
||||||
|
export const beforeEach: any;
|
||||||
|
export const afterEach: any;
|
||||||
|
export const beforeAll: any;
|
||||||
|
export const afterAll: any;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user