Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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_<feature>.py` and functions `test_<behavior>()`.
- 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.
73 changes: 29 additions & 44 deletions admin-ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -46,6 +46,7 @@ function App() {
const deferredUserQuery = useDeferredValue(userQuery);
const [users, setUsers] = useState<UserSummary[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const [selectedChannel, setSelectedChannel] = useState<string>("wecom");
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [memoryDraft, setMemoryDraft] = useState<UserMemory>(EMPTY_MEMORY);
const [memoryLoading, setMemoryLoading] = useState(false);
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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) {
Expand All @@ -143,7 +146,7 @@ function App() {

setMemoryLoading(true);
void api
.getUserMemory(selectedUserId)
.getUserMemory(selectedChannel, selectedUserId)
.then((payload) => {
setMemoryDraft(normalizeUserMemory(payload));
})
Expand All @@ -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<HTMLFormElement>) {
event.preventDefault();
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -424,7 +428,7 @@ function App() {
function handleEnterAdmin() {
window.history.replaceState({}, "", ADMIN_PATH);
if (!authenticated) {
setStatusMessage("请先登录管理员后台后再继续编辑配置。");
setStatusMessage("请先登录管理员后台。");
return;
}
setStatusMessage("环境校验完成,已进入管理后台。");
Expand All @@ -444,45 +448,20 @@ function App() {
}

if (!setupStatus.setup_completed) {
return (
<SetupWizard
initialStatus={setupStatus}
authenticated={authenticated}
onStatusChange={handleSetupStatusChange}
onEnterAdmin={handleEnterAdmin}
/>
);
return <SetupWizard initialStatus={setupStatus} authenticated={authenticated} onStatusChange={handleSetupStatusChange} onEnterAdmin={handleEnterAdmin} />;
}

if (window.location.pathname === SETUP_PATH) {
return (
<SetupWizard
initialStatus={setupStatus}
authenticated={authenticated}
onStatusChange={handleSetupStatusChange}
onEnterAdmin={handleEnterAdmin}
/>
);
return <SetupWizard initialStatus={setupStatus} authenticated={authenticated} onStatusChange={handleSetupStatusChange} onEnterAdmin={handleEnterAdmin} />;
}

if (!authenticated) {
return (
<LoginShell
loginPassword={loginPassword}
loginError={loginError}
onPasswordChange={setLoginPassword}
onSubmit={handleLogin}
/>
);
return <LoginShell loginPassword={loginPassword} loginError={loginError} onPasswordChange={setLoginPassword} onSubmit={handleLogin} />;
}

return (
<main className="shell">
<StudioTopbar
statusMessage={statusMessage}
onOpenSetup={handleOpenSetup}
onLogout={() => void handleLogout()}
/>
<StudioTopbar statusMessage={statusMessage} onOpenSetup={handleOpenSetup} onLogout={() => void handleLogout()} />
<StudioTabs
activeTab={activeTab}
onChange={(tab) => {
Expand All @@ -503,7 +482,10 @@ function App() {
setUserQuery(value);
});
}}
onSelectUser={setSelectedUserId}
onSelectUser={(channel, externalUserId) => {
setSelectedChannel(channel);
setSelectedUserId(externalUserId);
}}
/>

<section className="main-panel">
Expand Down Expand Up @@ -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)}
Expand Down
28 changes: 18 additions & 10 deletions admin-ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PreviewResponse> {
return request<PreviewResponse>("/admin-api/persona/preview-prompt", {
Expand All @@ -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<PreviewResponse> {
return request<PreviewResponse>("/admin-api/persona/preview-reply", {
Expand All @@ -87,11 +89,11 @@ export const api = {
params.set("limit", "30");
return request(`/admin-api/users?${params.toString()}`);
},
getUserMemory(wecomUserId: string): Promise<UserMemory> {
return request(`/admin-api/users/${encodeURIComponent(wecomUserId)}/memory`);
getUserMemory(channel: string, externalUserId: string): Promise<UserMemory> {
return request(`/admin-api/users/${encodeURIComponent(channel)}/${encodeURIComponent(externalUserId)}/memory`);
},
saveUserMemory(wecomUserId: string, payload: UserMemory): Promise<UserMemory> {
return request(`/admin-api/users/${encodeURIComponent(wecomUserId)}/memory`, {
saveUserMemory(channel: string, externalUserId: string, payload: UserMemory): Promise<UserMemory> {
return request(`/admin-api/users/${encodeURIComponent(channel)}/${encodeURIComponent(externalUserId)}/memory`, {
method: "PUT",
body: JSON.stringify(payload),
});
Expand All @@ -105,19 +107,25 @@ export const api = {
body: JSON.stringify(payload),
}).then(normalizeProactiveChatConfig);
},
previewProactiveChat(wecomUserId?: string): Promise<ProactiveChatResponse> {
previewProactiveChat(channel?: string, externalUserId?: string): Promise<ProactiveChatResponse> {
return request<ProactiveChatResponse>("/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<ProactiveChatResponse> {
runProactiveChatOnce(channel?: string, externalUserId?: string): Promise<ProactiveChatResponse> {
return request<ProactiveChatResponse>("/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),
Expand Down
55 changes: 12 additions & 43 deletions admin-ui/src/components/MemoryDesk.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { UserMemory } from "../types";
import type { UserMemory } from "../types";
import { KeyValueEditor, TextListEditor } from "./Editors";

type MemoryDeskProps = {
Expand All @@ -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 (
<div className="memory-grid">
<section className="panel">
<div className="section-header">
<div>
<p className="section-kicker">Selected User</p>
<h2>{draft.wecom_user_id || "未选择用户"}</h2>
<h2>{userLabel}</h2>
</div>
<button className="primary-button" onClick={onSave} disabled={saving || !draft.wecom_user_id}>
<button className="primary-button" onClick={onSave} disabled={saving || !draft.external_user_id}>
{saving ? "保存中..." : "保存记忆"}
</button>
</div>
Expand All @@ -35,52 +37,21 @@ export function MemoryDesk(props: MemoryDeskProps) {
<div className="field-grid">
<label className="field">
<span>昵称</span>
<input
value={draft.nickname}
onChange={(event) => onMemoryFieldChange("nickname", event.target.value)}
placeholder="比如:阿李"
/>
<input value={draft.nickname} onChange={(event) => onMemoryFieldChange("nickname", event.target.value)} placeholder="比如:阿杰" />
</label>
<label className="field">
<span>头像链接</span>
<input
value={draft.avatar_url}
onChange={(event) => onMemoryFieldChange("avatar_url", event.target.value)}
placeholder="可选"
/>
<input value={draft.avatar_url} onChange={(event) => onMemoryFieldChange("avatar_url", event.target.value)} placeholder="可选" />
</label>
</div>

<div className="editor-grid memory-editors">
<KeyValueEditor
label="基础信息"
items={draft.basic_info}
keyPlaceholder="字段名"
valuePlaceholder="字段值"
onChange={(nextValue) => onKeyValueChange("basic_info", nextValue)}
/>
<KeyValueEditor
label="情感模式"
items={draft.emotional_patterns}
keyPlaceholder="情绪场景"
valuePlaceholder="描述"
onChange={(nextValue) => onKeyValueChange("emotional_patterns", nextValue)}
/>
<KeyValueEditor
label="偏好"
items={draft.preferences}
keyPlaceholder="偏好类型"
valuePlaceholder="偏好内容"
onChange={(nextValue) => onKeyValueChange("preferences", nextValue)}
/>
<KeyValueEditor label="基础信息" items={draft.basic_info} keyPlaceholder="字段名" valuePlaceholder="字段值" onChange={(nextValue) => onKeyValueChange("basic_info", nextValue)} />
<KeyValueEditor label="情感模式" items={draft.emotional_patterns} keyPlaceholder="情绪场景" valuePlaceholder="描述" onChange={(nextValue) => onKeyValueChange("emotional_patterns", nextValue)} />
<KeyValueEditor label="偏好" items={draft.preferences} keyPlaceholder="偏好类型" valuePlaceholder="偏好内容" onChange={(nextValue) => onKeyValueChange("preferences", nextValue)} />
</div>

<TextListEditor
label="关系里程碑"
items={draft.relationship_milestones}
placeholder="新增一个里程碑,比如:第一次说想你"
onChange={onMilestonesChange}
/>
<TextListEditor label="关系里程碑" items={draft.relationship_milestones} placeholder="新增一个里程碑,比如:第一次说想你" onChange={onMilestonesChange} />
</section>

<section className="panel contrast-panel">
Expand All @@ -99,9 +70,7 @@ export function MemoryDesk(props: MemoryDeskProps) {
<p>{conversation.agent_message}</p>
</article>
))}
{!draft.recent_conversations?.length ? (
<p className="empty-state">这个用户还没有历史对话,保存记忆后可直接用于回复预览。</p>
) : null}
{!draft.recent_conversations?.length ? <p className="empty-state">这个用户还没有历史对话。</p> : null}
</div>
</section>
</div>
Expand Down
Loading