diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..35d285e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `app/`: FastAPI backend. Key areas: `routers/` (HTTP routes), `services/` (business logic), `models/` (DB entities), `schemas/` (request/response models), `prompts/` (LLM prompt templates), `utils/` (helpers). +- `admin-ui/`: React + TypeScript admin frontend (`src/components`, `src/lib`, `src/api.ts`). Build output is `admin-ui/dist` and is served by backend routes (`/admin`, `/setup`). +- `tests/`: Python test suite for API and services (`test_*.py`). +- `scripts/`: cross-platform bootstrap/run/stop scripts (`*.sh`, `*.ps1`, `stop.bat`). + +## Build, Test, and Development Commands +- Windows quick start: + - `./scripts/check-env.ps1 -Mode Bootstrap` checks required tools. + - `./scripts/bootstrap.ps1` creates `.venv`, installs Python deps, installs/builds frontend. + - `./scripts/dev-up.ps1` starts backend on `:8000`. + - `./scripts/dev-up.ps1 -DevUI` also starts Vite dev server on `:5173`. + - `./scripts/stop.ps1` stops started processes. +- Frontend only (from repo root): + - `npm --prefix admin-ui run dev` for UI development. + - `npm --prefix admin-ui run build` for production build. +- Tests: + - `python -m pytest tests -q` runs backend tests. + +## Coding Style & Naming Conventions +- Python: PEP 8, 4-space indentation, `snake_case` for functions/modules, `PascalCase` for classes, explicit type hints on new/changed public functions. +- TypeScript/React: follow existing style (2-space indentation, double quotes, trailing commas). Components use `PascalCase` file names (for example `SetupWizard.tsx`); utilities use lower camel/snake consistent with existing files. +- Keep routers thin; put logic in `app/services/`. + +## Testing Guidelines +- Add tests in `tests/` with names `test_.py` and functions `test_()`. +- Prefer deterministic service/unit tests; mock external HTTP/LLM calls. +- For route changes, include FastAPI `TestClient` coverage for success and error paths. + +## Commit & Pull Request Guidelines +- Current history mixes plain summaries and Conventional Commit prefixes (for example `feat: ...`, `fix: ...`). Prefer Conventional Commits for new work: `feat(scope): ...`, `fix(scope): ...`, `chore: ...`. +- Keep commits focused and runnable. +- PRs should include: + - purpose and scope + - test evidence (commands run + results) + - screenshots/GIFs for `admin-ui` changes + - config/env changes (update `.env.example` when needed) + +## Security & Configuration Tips +- Never commit real secrets. Use `.env` locally and keep `.env.example` in sync with required keys. +- Validate callback/public URL settings via the setup flow before enabling external integrations. diff --git a/admin-ui/src/App.tsx b/admin-ui/src/App.tsx index 4bba5e0..a16e1c6 100644 --- a/admin-ui/src/App.tsx +++ b/admin-ui/src/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, startTransition, useDeferredValue, useEffect, useState } from "react"; +import { FormEvent, startTransition, useDeferredValue, useEffect, useState } from "react"; import { api } from "./api"; import { LoginShell } from "./components/LoginShell"; @@ -46,6 +46,7 @@ function App() { const deferredUserQuery = useDeferredValue(userQuery); const [users, setUsers] = useState([]); const [usersLoading, setUsersLoading] = useState(false); + const [selectedChannel, setSelectedChannel] = useState("wecom"); const [selectedUserId, setSelectedUserId] = useState(""); const [memoryDraft, setMemoryDraft] = useState(EMPTY_MEMORY); const [memoryLoading, setMemoryLoading] = useState(false); @@ -99,7 +100,8 @@ function App() { setProactiveConfig(proactive); setUsers(userPayload.items); if (userPayload.items[0] && !selectedUserId) { - setSelectedUserId(userPayload.items[0].wecom_user_id); + setSelectedChannel(userPayload.items[0].channel); + setSelectedUserId(userPayload.items[0].external_user_id); } }) .catch((error: Error) => { @@ -123,9 +125,10 @@ function App() { return; } - const stillExists = payload.items.some((item) => item.wecom_user_id === selectedUserId); + const stillExists = payload.items.some((item) => item.channel === selectedChannel && item.external_user_id === selectedUserId); if (!stillExists) { - setSelectedUserId(payload.items[0].wecom_user_id); + setSelectedChannel(payload.items[0].channel); + setSelectedUserId(payload.items[0].external_user_id); } }) .catch((error: Error) => { @@ -134,7 +137,7 @@ function App() { .finally(() => { setUsersLoading(false); }); - }, [authenticated, deferredUserQuery, selectedUserId, setupStatus?.setup_completed]); + }, [authenticated, deferredUserQuery, selectedChannel, selectedUserId, setupStatus?.setup_completed]); useEffect(() => { if (!authenticated || !setupStatus?.setup_completed || !selectedUserId) { @@ -143,7 +146,7 @@ function App() { setMemoryLoading(true); void api - .getUserMemory(selectedUserId) + .getUserMemory(selectedChannel, selectedUserId) .then((payload) => { setMemoryDraft(normalizeUserMemory(payload)); }) @@ -153,7 +156,7 @@ function App() { .finally(() => { setMemoryLoading(false); }); - }, [authenticated, selectedUserId, setupStatus?.setup_completed]); + }, [authenticated, selectedChannel, selectedUserId, setupStatus?.setup_completed]); async function handleLogin(event: FormEvent) { event.preventDefault(); @@ -204,7 +207,8 @@ function App() { try { const payload = { user_message: previewMessage, - wecom_user_id: selectedUserId || undefined, + channel: selectedChannel || undefined, + external_user_id: selectedUserId || undefined, draft_config: personaConfig, }; const response = mode === "prompt" ? await api.previewPrompt(payload) : await api.previewReply(payload); @@ -228,7 +232,7 @@ function App() { setMemorySaving(true); try { - const saved = await api.saveUserMemory(selectedUserId, memoryDraft); + const saved = await api.saveUserMemory(selectedChannel, selectedUserId, memoryDraft); setMemoryDraft(normalizeUserMemory(saved)); setStatusMessage("用户记忆已保存。"); } catch (error) { @@ -259,8 +263,8 @@ function App() { const response = mode === "preview" - ? await api.previewProactiveChat(saved.target_wecom_user_id) - : await api.runProactiveChatOnce(saved.target_wecom_user_id); + ? await api.previewProactiveChat(saved.target_channel, saved.target_external_user_id) + : await api.runProactiveChatOnce(saved.target_channel, saved.target_external_user_id); setProactivePrompt(response.prompt); setProactiveReply(response.reply); @@ -366,7 +370,7 @@ function App() { } function updateProactiveField( - field: "enabled" | "target_wecom_user_id" | "tone_hint", + field: "enabled" | "target_channel" | "target_external_user_id" | "tone_hint", value: boolean | string, ) { setProactiveConfig((current) => @@ -424,7 +428,7 @@ function App() { function handleEnterAdmin() { window.history.replaceState({}, "", ADMIN_PATH); if (!authenticated) { - setStatusMessage("请先登录管理员后台后再继续编辑配置。"); + setStatusMessage("请先登录管理员后台。"); return; } setStatusMessage("环境校验完成,已进入管理后台。"); @@ -444,45 +448,20 @@ function App() { } if (!setupStatus.setup_completed) { - return ( - - ); + return ; } if (window.location.pathname === SETUP_PATH) { - return ( - - ); + return ; } if (!authenticated) { - return ( - - ); + return ; } return (
- void handleLogout()} - /> + void handleLogout()} /> { @@ -503,7 +482,10 @@ function App() { setUserQuery(value); }); }} - onSelectUser={setSelectedUserId} + onSelectUser={(channel, externalUserId) => { + setSelectedChannel(channel); + setSelectedUserId(externalUserId); + }} />
@@ -563,7 +545,10 @@ function App() { previewReply={proactiveReply} deliveryStatus={proactiveDeliveryStatus} onToggleEnabled={(value) => updateProactiveField("enabled", value)} - onTargetUserChange={(value) => updateProactiveField("target_wecom_user_id", value)} + onTargetUserChange={(channel, externalUserId) => { + updateProactiveField("target_channel", channel); + updateProactiveField("target_external_user_id", externalUserId); + }} onWindowToggle={(key, enabled) => updateProactiveWindow(key, { enabled })} onWindowTimeChange={(key, value) => updateProactiveWindow(key, { time: value })} onQuietHoursToggle={(value) => updateQuietHours("enabled", value)} diff --git a/admin-ui/src/api.ts b/admin-ui/src/api.ts index 9b336f2..f8afd9f 100644 --- a/admin-ui/src/api.ts +++ b/admin-ui/src/api.ts @@ -55,7 +55,8 @@ export const api = { }, previewPrompt(payload: { user_message: string; - wecom_user_id?: string | null; + channel?: string | null; + external_user_id?: string | null; draft_config?: PersonaConfig; }): Promise { return request("/admin-api/persona/preview-prompt", { @@ -68,7 +69,8 @@ export const api = { }, previewReply(payload: { user_message: string; - wecom_user_id?: string | null; + channel?: string | null; + external_user_id?: string | null; draft_config?: PersonaConfig; }): Promise { return request("/admin-api/persona/preview-reply", { @@ -87,11 +89,11 @@ export const api = { params.set("limit", "30"); return request(`/admin-api/users?${params.toString()}`); }, - getUserMemory(wecomUserId: string): Promise { - return request(`/admin-api/users/${encodeURIComponent(wecomUserId)}/memory`); + getUserMemory(channel: string, externalUserId: string): Promise { + return request(`/admin-api/users/${encodeURIComponent(channel)}/${encodeURIComponent(externalUserId)}/memory`); }, - saveUserMemory(wecomUserId: string, payload: UserMemory): Promise { - return request(`/admin-api/users/${encodeURIComponent(wecomUserId)}/memory`, { + saveUserMemory(channel: string, externalUserId: string, payload: UserMemory): Promise { + return request(`/admin-api/users/${encodeURIComponent(channel)}/${encodeURIComponent(externalUserId)}/memory`, { method: "PUT", body: JSON.stringify(payload), }); @@ -105,19 +107,25 @@ export const api = { body: JSON.stringify(payload), }).then(normalizeProactiveChatConfig); }, - previewProactiveChat(wecomUserId?: string): Promise { + previewProactiveChat(channel?: string, externalUserId?: string): Promise { return request("/admin-api/proactive-chat/preview", { method: "POST", - body: JSON.stringify({ wecom_user_id: wecomUserId || undefined }), + body: JSON.stringify({ + channel: channel || undefined, + external_user_id: externalUserId || undefined, + }), }).then((response) => ({ ...response, config: normalizeProactiveChatConfig(response.config), })); }, - runProactiveChatOnce(wecomUserId?: string): Promise { + runProactiveChatOnce(channel?: string, externalUserId?: string): Promise { return request("/admin-api/proactive-chat/run-once", { method: "POST", - body: JSON.stringify({ wecom_user_id: wecomUserId || undefined }), + body: JSON.stringify({ + channel: channel || undefined, + external_user_id: externalUserId || undefined, + }), }).then((response) => ({ ...response, config: normalizeProactiveChatConfig(response.config), diff --git a/admin-ui/src/components/MemoryDesk.tsx b/admin-ui/src/components/MemoryDesk.tsx index 59665fe..6088d58 100644 --- a/admin-ui/src/components/MemoryDesk.tsx +++ b/admin-ui/src/components/MemoryDesk.tsx @@ -1,4 +1,4 @@ -import type { UserMemory } from "../types"; +import type { UserMemory } from "../types"; import { KeyValueEditor, TextListEditor } from "./Editors"; type MemoryDeskProps = { @@ -17,15 +17,17 @@ type MemoryDeskProps = { export function MemoryDesk(props: MemoryDeskProps) { const { draft, loading, saving, onMemoryFieldChange, onKeyValueChange, onMilestonesChange, onSave } = props; + const userLabel = draft.external_user_id ? `${draft.channel}:${draft.external_user_id}` : "未选择用户"; + return (

Selected User

-

{draft.wecom_user_id || "未选择用户"}

+

{userLabel}

-
@@ -35,52 +37,21 @@ export function MemoryDesk(props: MemoryDeskProps) {
- onKeyValueChange("basic_info", nextValue)} - /> - onKeyValueChange("emotional_patterns", nextValue)} - /> - onKeyValueChange("preferences", nextValue)} - /> + onKeyValueChange("basic_info", nextValue)} /> + onKeyValueChange("emotional_patterns", nextValue)} /> + onKeyValueChange("preferences", nextValue)} />
- +
@@ -99,9 +70,7 @@ export function MemoryDesk(props: MemoryDeskProps) {

{conversation.agent_message}

))} - {!draft.recent_conversations?.length ? ( -

这个用户还没有历史对话,保存记忆后可直接用于回复预览。

- ) : null} + {!draft.recent_conversations?.length ?

这个用户还没有历史对话。

: null}
diff --git a/admin-ui/src/components/ProactiveStudio.tsx b/admin-ui/src/components/ProactiveStudio.tsx index 0b453fc..b9edb2b 100644 --- a/admin-ui/src/components/ProactiveStudio.tsx +++ b/admin-ui/src/components/ProactiveStudio.tsx @@ -1,4 +1,4 @@ -import type { ProactiveChatConfig, UserSummary } from "../types"; +import type { ProactiveChatConfig, UserSummary } from "../types"; type ProactiveStudioProps = { config: ProactiveChatConfig; @@ -9,7 +9,7 @@ type ProactiveStudioProps = { previewReply: string; deliveryStatus: string; onToggleEnabled: (value: boolean) => void; - onTargetUserChange: (value: string) => void; + onTargetUserChange: (channel: string, externalUserId: string) => void; onWindowToggle: (key: string, enabled: boolean) => void; onWindowTimeChange: (key: string, value: string) => void; onQuietHoursToggle: (value: boolean) => void; @@ -43,6 +43,8 @@ export function ProactiveStudio(props: ProactiveStudioProps) { onRunOnce, } = props; + const targetValue = config.target_external_user_id ? `${config.target_channel}::${config.target_external_user_id}` : ""; + return (
@@ -64,11 +66,22 @@ export function ProactiveStudio(props: ProactiveStudioProps) {