Browse Source
- Remove ChatWidget component from app.vue - Update index.vue to ensure flex layout accommodates new design - Add systemPrompt field to agentSessions table in the database - Modify createSession and updateSession services to support systemPrompt - Enhance API endpoints to accept and update systemPrompt - Adjust chat engine to prioritize session-level systemPrompt over agent default - Extend AgentSessionItem interface to include systemPrompt - Revamp AgentSystemPromptModal to allow editing of systemPrompt - Integrate editable system prompt functionality in AgentToolbar and chat page - Add E2E tests for session-level system prompt functionality - Update design document to reflect new system prompt capabilitiesfeat/agent-independence
6 changed files with 1057 additions and 2 deletions
@ -0,0 +1,923 @@ |
|||
# 会话级系统提示词覆盖 Implementation Plan |
|||
|
|||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
|||
|
|||
**Goal:** 让用户可以在每个会话中自定义系统提示词,覆盖 Agent 默认值。 |
|||
|
|||
**Architecture:** `agentSessions` 表新增 `systemPrompt` 字段(nullable text),`executeChat` 中优先使用会话级提示词,回退 Agent 默认。前端弹窗从只读改为可编辑,通过 `PUT /config` 接口保存。 |
|||
|
|||
**Tech Stack:** Nuxt 3, Drizzle ORM (SQLite), Vue 3 Composition API, Playwright E2E |
|||
|
|||
## Global Constraints |
|||
|
|||
- 数据库迁移命令:`bun run db:generate && bun run db:migrate` |
|||
- E2E 测试命令:`bun run test:e2e`(必须传 `--config=e2e/playwright.config.ts`) |
|||
- Mock LLM 端口 3600,固定回复 `你好!这是一个测试回复。` |
|||
- Agent 默认 system_prompt(E2E seed):`You are a helpful AI assistant.` |
|||
- 系统提示词业务限制:5000 字符 |
|||
- 前端 toast 提示:`const { $toast } = useNuxtApp(); $toast?.error?.(...) / $toast?.success?.(...)` |
|||
- 用户名规则:`/^[a-zA-Z0-9_]{3,20}$/` |
|||
- 固定验证码:`FIXED_CAPTCHA`(`e2e/fixtures/test-users.ts`) |
|||
- E2E 测试用户:`E2E_ADMIN`(`e2e/fixtures/test-users.ts`) |
|||
|
|||
--- |
|||
|
|||
## File Structure |
|||
|
|||
| 文件 | 操作 | 职责 | |
|||
|------|------|------| |
|||
| `packages/drizzle-pkg/lib/schema/agent.ts` | Modify | `agentSessions` 表新增 `systemPrompt` 字段 | |
|||
| `server/service/agent/session.ts` | Modify | `createSession` 和 `updateSession` 支持 `systemPrompt` | |
|||
| `server/api/agents/[agentSlug]/sessions/index.post.ts` | Modify | 创建会话接口读取 body 中 `systemPrompt` | |
|||
| `server/api/agents/[agentSlug]/sessions/[id]/config.put.ts` | Modify | 配置接口支持 `systemPrompt` 更新 | |
|||
| `server/service/agent/chat-engine.ts` | Modify | 系统提示词优先级:session > agent | |
|||
| `app/composables/useAgentSessions.ts` | Modify | `AgentSessionItem` 加 `systemPrompt`,`updateSessionConfig` 加参数 | |
|||
| `app/components/agent/AgentSystemPromptModal.vue` | Modify | 从只读改为可编辑+保存 | |
|||
| `app/components/agent/AgentToolbar.vue` | Modify | 图标和 title 调整 | |
|||
| `app/pages/chat/[agentSlug]/index.vue` | Modify | `systemPromptText` 改为 computed,弹窗接保存事件 | |
|||
| `e2e/specs/agent-chat.spec.ts` | Modify | 新增会话级系统提示词 E2E 测试 | |
|||
| `e2e/global-setup.ts` | Modify | Mock LLM 记录 system 消息供测试断言 | |
|||
|
|||
--- |
|||
|
|||
### Task 1: 数据库 Schema — `agentSessions.systemPrompt` 字段 |
|||
|
|||
**Files:** |
|||
- Modify: `packages/drizzle-pkg/lib/schema/agent.ts:57-82` |
|||
|
|||
**Interfaces:** |
|||
- Produces: `agentSessions.systemPrompt` 字段(`text("system_prompt")`, nullable),后续 task 的 service 和 API 依赖此字段 |
|||
|
|||
- [ ] **Step 1: 修改 schema,在 `agentSessions` 表加 `systemPrompt` 字段** |
|||
|
|||
在 `packages/drizzle-pkg/lib/schema/agent.ts` 的 `agentSessions` 定义中,`enableTools` 字段后、`lastActiveAt` 字段前插入: |
|||
|
|||
```ts |
|||
systemPrompt: text("system_prompt"), |
|||
``` |
|||
|
|||
完整上下文(修改后 `agentSessions` 的字段部分): |
|||
|
|||
```ts |
|||
export const agentSessions = sqliteTable( |
|||
"agent_sessions", |
|||
{ |
|||
id: text("id").primaryKey(), |
|||
agentId: integer("agent_id").references(() => agents.id, { onDelete: "set null" }), |
|||
userId: integer("user_id").references(() => users.id, { onDelete: "cascade" }), |
|||
tempToken: text("temp_token", { length: 64 }), |
|||
title: text("title", { length: 100 }).notNull().default("新对话"), |
|||
modelId: integer("model_id"), |
|||
enableThinking: integer("enable_thinking"), |
|||
enableTools: integer("enable_tools"), |
|||
systemPrompt: text("system_prompt"), |
|||
lastActiveAt: integer("last_active_at", { mode: "timestamp_ms" }).defaultNow().notNull(), |
|||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }), |
|||
deletedAt: integer("deleted_at", { mode: "timestamp_ms" }), |
|||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), |
|||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }) |
|||
.defaultNow() |
|||
.$onUpdate(() => new Date()) |
|||
.notNull(), |
|||
}, |
|||
``` |
|||
|
|||
- [ ] **Step 2: 生成迁移文件** |
|||
|
|||
Run: `bun run db:generate` |
|||
Expected: 在 `packages/drizzle-pkg/drizzle/` 下生成新的 SQL 迁移文件,包含 `ALTER TABLE agent_sessions ADD COLUMN system_prompt text;` |
|||
|
|||
- [ ] **Step 3: 执行迁移** |
|||
|
|||
Run: `bun run db:migrate` |
|||
Expected: 迁移成功,无报错 |
|||
|
|||
- [ ] **Step 4: 验证字段存在** |
|||
|
|||
Run: `sqlite3 packages/drizzle-pkg/db.sqlite ".schema agent_sessions" | grep system_prompt` |
|||
Expected: 输出 `system_prompt text,` |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add packages/drizzle-pkg/lib/schema/agent.ts packages/drizzle-pkg/drizzle/ |
|||
git commit -m "feat: add systemPrompt column to agentSessions table" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 2: Service 层 — `createSession` 和 `updateSession` 支持 `systemPrompt` |
|||
|
|||
**Files:** |
|||
- Modify: `server/service/agent/session.ts:6-30`(`createSession`) |
|||
- Modify: `server/service/agent/session.ts:102-122`(`updateSession`) |
|||
|
|||
**Interfaces:** |
|||
- Consumes: Task 1 的 `agentSessions.systemPrompt` 字段 |
|||
- Produces: `createSession` 接受 `systemPrompt?: string | null`,`updateSession` 接受 `systemPrompt?: string | null` |
|||
|
|||
- [ ] **Step 1: 修改 `createSession` 函数,加 `systemPrompt` 参数** |
|||
|
|||
在 `server/service/agent/session.ts` 的 `createSession` 函数中: |
|||
|
|||
params 类型新增 `systemPrompt?: string | null;`,values 新增 `systemPrompt: params.systemPrompt ?? null,`。 |
|||
|
|||
修改后的完整函数: |
|||
|
|||
```ts |
|||
export async function createSession(params: { |
|||
userId?: number | null; |
|||
tempToken?: string | null; |
|||
agentId?: number | null; |
|||
modelId?: number | null; |
|||
enableThinking?: number | null; |
|||
enableTools?: number | null; |
|||
systemPrompt?: string | null; |
|||
expiresAt?: Date | null; |
|||
}): Promise<AgentSessionRow> { |
|||
const id = `as_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; |
|||
const [row] = await dbGlobal |
|||
.insert(agentSessions) |
|||
.values({ |
|||
id, |
|||
userId: params.userId ?? null, |
|||
tempToken: params.tempToken ?? null, |
|||
agentId: params.agentId ?? null, |
|||
modelId: params.modelId ?? null, |
|||
enableThinking: params.enableThinking ?? null, |
|||
enableTools: params.enableTools ?? null, |
|||
systemPrompt: params.systemPrompt ?? null, |
|||
expiresAt: params.expiresAt ?? null, |
|||
}) |
|||
.returning(); |
|||
return row!; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: 修改 `updateSession` 函数,加 `systemPrompt` 支持** |
|||
|
|||
在 `server/service/agent/session.ts` 的 `updateSession` 函数中: |
|||
|
|||
`data` 参数类型新增 `systemPrompt?: string | null;`,updates 逻辑新增 `if (data.systemPrompt !== undefined) updates.systemPrompt = data.systemPrompt;`。 |
|||
|
|||
修改后的完整函数: |
|||
|
|||
```ts |
|||
export async function updateSession( |
|||
id: string, |
|||
data: { |
|||
title?: string; |
|||
agentId?: number | null; |
|||
modelId?: number | null; |
|||
enableThinking?: number | null; |
|||
enableTools?: number | null; |
|||
systemPrompt?: string | null; |
|||
lastActiveAt?: Date; |
|||
}, |
|||
): Promise<void> { |
|||
const updates: Record<string, any> = {}; |
|||
if (data.title !== undefined) updates.title = data.title; |
|||
if (data.agentId !== undefined) updates.agentId = data.agentId; |
|||
if (data.modelId !== undefined) updates.modelId = data.modelId; |
|||
if (data.enableThinking !== undefined) updates.enableThinking = data.enableThinking; |
|||
if (data.enableTools !== undefined) updates.enableTools = data.enableTools; |
|||
if (data.systemPrompt !== undefined) updates.systemPrompt = data.systemPrompt; |
|||
if (data.lastActiveAt !== undefined) updates.lastActiveAt = data.lastActiveAt; |
|||
if (Object.keys(updates).length === 0) return; |
|||
await dbGlobal.update(agentSessions).set(updates).where(eq(agentSessions.id, id)); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: 验证类型检查通过** |
|||
|
|||
Run: `npx nuxi typecheck 2>&1 | tail -20` |
|||
Expected: 无新增类型错误(可能有既有的无关错误,确认无 `session.ts` 相关错误即可) |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add server/service/agent/session.ts |
|||
git commit -m "feat: support systemPrompt in createSession and updateSession" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 3: API 层 — 创建会话和配置更新接口支持 `systemPrompt` |
|||
|
|||
**Files:** |
|||
- Modify: `server/api/agents/[agentSlug]/sessions/index.post.ts:47-55` |
|||
- Modify: `server/api/agents/[agentSlug]/sessions/[id]/config.put.ts:29-46` |
|||
|
|||
**Interfaces:** |
|||
- Consumes: Task 2 的 `createSession({ systemPrompt })` 和 `updateSession({ systemPrompt })` |
|||
- Produces: `POST /api/agents/:slug/sessions` 接受 body `systemPrompt`,`PUT /api/agents/:slug/sessions/:id/config` 接受 body `systemPrompt` |
|||
|
|||
- [ ] **Step 1: 修改创建会话接口,读取 body 中 `systemPrompt`** |
|||
|
|||
在 `server/api/agents/[agentSlug]/sessions/index.post.ts` 中: |
|||
|
|||
在 `const identity = ...` 之后、`createSession` 调用之前,读取 body: |
|||
|
|||
```ts |
|||
const body = await readBody(event).catch(() => ({})); |
|||
const systemPrompt: string | null = |
|||
typeof body?.systemPrompt === "string" && body.systemPrompt.trim().length > 0 |
|||
? body.systemPrompt.slice(0, 5000) |
|||
: null; |
|||
``` |
|||
|
|||
然后修改 `createSession` 调用,加 `systemPrompt`: |
|||
|
|||
```ts |
|||
const session = await createSession({ |
|||
userId: identity.userId, |
|||
tempToken, |
|||
agentId: agent.id, |
|||
modelId, |
|||
enableThinking: agent.enableThinking, |
|||
enableTools: agent.enableTools, |
|||
systemPrompt, |
|||
expiresAt, |
|||
}); |
|||
``` |
|||
|
|||
- [ ] **Step 2: 修改配置更新接口,支持 `systemPrompt`** |
|||
|
|||
在 `server/api/agents/[agentSlug]/sessions/[id]/config.put.ts` 中: |
|||
|
|||
修改 body 解构和 updates 逻辑: |
|||
|
|||
```ts |
|||
const body = await readBody(event); |
|||
const { modelId, enableThinking, enableTools, systemPrompt } = body as { |
|||
modelId?: number; |
|||
enableThinking?: number; |
|||
enableTools?: number; |
|||
systemPrompt?: string | null; |
|||
}; |
|||
|
|||
const updates: { |
|||
modelId?: number | null; |
|||
enableThinking?: number | null; |
|||
enableTools?: number | null; |
|||
systemPrompt?: string | null; |
|||
} = {}; |
|||
if (modelId !== undefined) updates.modelId = modelId; |
|||
if (enableThinking !== undefined) updates.enableThinking = enableThinking ? 1 : 0; |
|||
if (enableTools !== undefined) updates.enableTools = enableTools ? 1 : 0; |
|||
if (systemPrompt !== undefined) { |
|||
if (systemPrompt !== null && typeof systemPrompt === "string" && systemPrompt.length > 5000) { |
|||
return R.error("系统提示词不能超过5000字符", null); |
|||
} |
|||
updates.systemPrompt = systemPrompt; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: 验证类型检查通过** |
|||
|
|||
Run: `npx nuxi typecheck 2>&1 | grep -E "config\.put|index\.post" | head -5` |
|||
Expected: 无输出(无相关类型错误) |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add server/api/agents/\[agentSlug\]/sessions/index.post.ts server/api/agents/\[agentSlug\]/sessions/\[id\]/config.put.ts |
|||
git commit -m "feat: support systemPrompt in session create and config APIs" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 4: Chat Engine — 系统提示词优先级 |
|||
|
|||
**Files:** |
|||
- Modify: `server/service/agent/chat-engine.ts:251` |
|||
|
|||
**Interfaces:** |
|||
- Consumes: Task 1 的 `session.systemPrompt` 字段 |
|||
- Produces: `executeChat` 中 `systemPrompt` 变量优先使用会话级 |
|||
|
|||
- [ ] **Step 1: 修改系统提示词优先级逻辑** |
|||
|
|||
在 `server/service/agent/chat-engine.ts` 第 251 行,将: |
|||
|
|||
```ts |
|||
const systemPrompt = agent.systemPrompt || undefined; |
|||
``` |
|||
|
|||
改为: |
|||
|
|||
```ts |
|||
const systemPrompt = session.systemPrompt?.trim() || agent.systemPrompt || undefined; |
|||
``` |
|||
|
|||
- [ ] **Step 2: 验证类型检查通过** |
|||
|
|||
Run: `npx nuxi typecheck 2>&1 | grep "chat-engine" | head -5` |
|||
Expected: 无输出 |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add server/service/agent/chat-engine.ts |
|||
git commit -m "feat: session-level systemPrompt takes priority over agent default" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 5: 前端 Composable — `AgentSessionItem` 和 `updateSessionConfig` |
|||
|
|||
**Files:** |
|||
- Modify: `app/composables/useAgentSessions.ts:4-17`(`AgentSessionItem` 接口) |
|||
- Modify: `app/composables/useAgentSessions.ts:111-128`(`updateSessionConfig`) |
|||
|
|||
**Interfaces:** |
|||
- Consumes: Task 3 的 API 接口 |
|||
- Produces: `AgentSessionItem.systemPrompt: string | null`,`updateSessionConfig` 接受 `systemPrompt?: string | null` |
|||
|
|||
- [ ] **Step 1: `AgentSessionItem` 接口加 `systemPrompt` 字段** |
|||
|
|||
在 `app/composables/useAgentSessions.ts` 的 `AgentSessionItem` 接口中,`enableTools` 后加: |
|||
|
|||
```ts |
|||
systemPrompt: string | null; |
|||
``` |
|||
|
|||
修改后的完整接口: |
|||
|
|||
```ts |
|||
export interface AgentSessionItem { |
|||
id: string; |
|||
userId: number | null; |
|||
tempToken: string | null; |
|||
title: string; |
|||
modelId: number | null; |
|||
enableThinking: number; |
|||
enableTools: number; |
|||
systemPrompt: string | null; |
|||
lastActiveAt: string; |
|||
expiresAt: string | null; |
|||
deletedAt: string | null; |
|||
createdAt: string; |
|||
updatedAt: string; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: `updateSessionConfig` 加 `systemPrompt` 参数** |
|||
|
|||
在 `app/composables/useAgentSessions.ts` 的 `updateSessionConfig` 函数中: |
|||
|
|||
config 参数类型加 `systemPrompt?: string | null`,body 传该字段,本地 session 同步该字段。 |
|||
|
|||
修改后的完整函数: |
|||
|
|||
```ts |
|||
async function updateSessionConfig( |
|||
id: string, |
|||
config: { modelId?: number | null; enableThinking?: boolean; enableTools?: boolean; systemPrompt?: string | null }, |
|||
) { |
|||
try { |
|||
await $fetch(`/api/agents/${agentSlug}/sessions/${id}/config`, { |
|||
method: "PUT", |
|||
body: config, |
|||
}); |
|||
const s = sessions.value.find((x) => x.id === id); |
|||
if (s) { |
|||
if (config.modelId !== undefined) s.modelId = config.modelId; |
|||
if (config.enableThinking !== undefined) s.enableThinking = config.enableThinking ? 1 : 0; |
|||
if (config.enableTools !== undefined) s.enableTools = config.enableTools ? 1 : 0; |
|||
if (config.systemPrompt !== undefined) s.systemPrompt = config.systemPrompt; |
|||
} |
|||
} catch { |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: 验证类型检查通过** |
|||
|
|||
Run: `npx nuxi typecheck 2>&1 | grep "useAgentSessions" | head -5` |
|||
Expected: 无输出 |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add app/composables/useAgentSessions.ts |
|||
git commit -m "feat: add systemPrompt to AgentSessionItem and updateSessionConfig" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 6: 前端弹窗 — `AgentSystemPromptModal` 改为可编辑 |
|||
|
|||
**Files:** |
|||
- Modify: `app/components/agent/AgentSystemPromptModal.vue`(完整重写 script 和 template) |
|||
|
|||
**Interfaces:** |
|||
- Consumes: Task 5 的 `updateSessionConfig` |
|||
- Produces: 弹窗 emit `save: [systemPrompt: string | null]` |
|||
|
|||
- [ ] **Step 1: 重写 `AgentSystemPromptModal.vue` 为可编辑弹窗** |
|||
|
|||
完整替换文件内容: |
|||
|
|||
```vue |
|||
<script setup lang="ts"> |
|||
const props = defineProps<{ |
|||
show: boolean; |
|||
systemPrompt: string; |
|||
isCustom: boolean; |
|||
}>(); |
|||
|
|||
const emit = defineEmits<{ |
|||
close: []; |
|||
save: [systemPrompt: string | null]; |
|||
}>(); |
|||
|
|||
const editingText = ref(""); |
|||
const saving = ref(false); |
|||
|
|||
watch( |
|||
() => props.show, |
|||
(val) => { |
|||
if (val) { |
|||
editingText.value = props.systemPrompt; |
|||
} |
|||
}, |
|||
); |
|||
|
|||
async function handleSave() { |
|||
saving.value = true; |
|||
try { |
|||
const trimmed = editingText.value.trim(); |
|||
emit("save", trimmed.length > 0 ? trimmed.slice(0, 5000) : null); |
|||
} finally { |
|||
saving.value = false; |
|||
} |
|||
} |
|||
|
|||
function handleReset() { |
|||
editingText.value = ""; |
|||
emit("save", null); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Teleport to="body"> |
|||
<div v-if="show" class="agent-prompt-overlay" @click="emit('close')"> |
|||
<div class="agent-prompt-modal" @click.stop> |
|||
<div class="modal-header"> |
|||
<h3 class="modal-title">系统提示词</h3> |
|||
<button class="modal-close" @click="emit('close')"> |
|||
<Icon name="lucide:x" /> |
|||
</button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<div v-if="isCustom" class="custom-badge"> |
|||
<Icon name="lucide:check-circle" /> |
|||
<span>当前使用会话自定义提示词</span> |
|||
</div> |
|||
<div v-else class="default-badge"> |
|||
<Icon name="lucide:info" /> |
|||
<span>当前使用 Agent 默认提示词,编辑后将覆盖默认值</span> |
|||
</div> |
|||
<textarea |
|||
v-model="editingText" |
|||
class="prompt-textarea" |
|||
placeholder="输入系统提示词,留空则使用 Agent 默认" |
|||
maxlength="5000" |
|||
/> |
|||
<div class="char-count">{{ editingText.length }} / 5000</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button class="btn-reset" :disabled="saving" @click="handleReset"> |
|||
重置为默认 |
|||
</button> |
|||
<button class="btn-save" :disabled="saving" @click="handleSave"> |
|||
{{ saving ? "保存中..." : "保存" }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</Teleport> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.agent-prompt-overlay { |
|||
position: fixed; |
|||
inset: 0; |
|||
background: rgba(20, 20, 19, 0.4); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
z-index: 100; |
|||
} |
|||
|
|||
.agent-prompt-modal { |
|||
width: 90%; |
|||
max-width: 600px; |
|||
max-height: 80vh; |
|||
background: var(--color-surface-card); |
|||
border: 1px solid var(--color-hairline); |
|||
border-radius: 16px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.modal-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 16px 20px; |
|||
border-bottom: 1px solid var(--color-hairline); |
|||
} |
|||
|
|||
.modal-title { |
|||
font-family: var(--font-display); |
|||
font-size: 20px; |
|||
font-weight: 400; |
|||
color: var(--color-ink); |
|||
margin: 0; |
|||
} |
|||
|
|||
.modal-close { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
width: 32px; |
|||
height: 32px; |
|||
border: none; |
|||
background: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
color: var(--color-muted); |
|||
font-size: 18px; |
|||
transition: all 0.15s; |
|||
} |
|||
|
|||
.modal-close:hover { |
|||
background: var(--color-surface-soft); |
|||
color: var(--color-ink); |
|||
} |
|||
|
|||
.modal-body { |
|||
padding: 20px; |
|||
overflow-y: auto; |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.custom-badge, |
|||
.default-badge { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
padding: 8px 12px; |
|||
border-radius: 8px; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.custom-badge { |
|||
background: var(--color-surface-soft); |
|||
color: var(--color-primary); |
|||
} |
|||
|
|||
.default-badge { |
|||
background: var(--color-surface-soft); |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.prompt-textarea { |
|||
width: 100%; |
|||
min-height: 200px; |
|||
padding: 12px; |
|||
font-family: "SF Mono", "Fira Code", monospace; |
|||
font-size: 13px; |
|||
line-height: 1.6; |
|||
color: var(--color-body); |
|||
background: var(--color-canvas); |
|||
border: 1px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
resize: vertical; |
|||
outline: none; |
|||
transition: border-color 0.15s; |
|||
} |
|||
|
|||
.prompt-textarea:focus { |
|||
border-color: var(--color-primary); |
|||
} |
|||
|
|||
.char-count { |
|||
text-align: right; |
|||
font-size: 12px; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.modal-footer { |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
gap: 8px; |
|||
padding: 12px 20px; |
|||
border-top: 1px solid var(--color-hairline); |
|||
} |
|||
|
|||
.btn-reset, |
|||
.btn-save { |
|||
padding: 8px 16px; |
|||
border-radius: 8px; |
|||
font-size: 14px; |
|||
cursor: pointer; |
|||
transition: all 0.15s; |
|||
border: 1px solid var(--color-hairline); |
|||
} |
|||
|
|||
.btn-reset { |
|||
background: var(--color-surface-soft); |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.btn-reset:hover { |
|||
border-color: var(--color-primary); |
|||
color: var(--color-ink); |
|||
} |
|||
|
|||
.btn-save { |
|||
background: var(--color-primary); |
|||
border-color: var(--color-primary); |
|||
color: var(--color-on-primary); |
|||
} |
|||
|
|||
.btn-save:hover { |
|||
opacity: 0.9; |
|||
} |
|||
|
|||
.btn-reset:disabled, |
|||
.btn-save:disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
</style> |
|||
``` |
|||
|
|||
- [ ] **Step 2: 验证类型检查通过** |
|||
|
|||
Run: `npx nuxi typecheck 2>&1 | grep "AgentSystemPromptModal" | head -5` |
|||
Expected: 无输出 |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add app/components/agent/AgentSystemPromptModal.vue |
|||
git commit -m "feat: make AgentSystemPromptModal editable with save/reset" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 7: 前端工具栏和页面层 — 接入可编辑弹窗 |
|||
|
|||
**Files:** |
|||
- Modify: `app/components/agent/AgentToolbar.vue:53`(图标和 title) |
|||
- Modify: `app/pages/chat/[agentSlug]/index.vue:22,144-153,279,293-297`(`systemPromptText` 改 computed,弹窗接 save 事件) |
|||
|
|||
**Interfaces:** |
|||
- Consumes: Task 5 的 `AgentSessionItem.systemPrompt`,Task 6 的弹窗 `save` 事件 |
|||
- Produces: 完整的前端可编辑系统提示词功能 |
|||
|
|||
- [ ] **Step 1: 修改 `AgentToolbar.vue` 图标和 title** |
|||
|
|||
在 `app/components/agent/AgentToolbar.vue` 第 53 行,将: |
|||
|
|||
```html |
|||
<button class="icon-btn" title="系统提示词" @click="emit('showPrompt')"> |
|||
<Icon name="lucide:info" /> |
|||
</button> |
|||
``` |
|||
|
|||
改为: |
|||
|
|||
```html |
|||
<button class="icon-btn" title="系统提示词设置" @click="emit('showPrompt')"> |
|||
<Icon name="lucide:settings-2" /> |
|||
</button> |
|||
``` |
|||
|
|||
- [ ] **Step 2: 修改 `index.vue` — `systemPromptText` 改为 computed** |
|||
|
|||
在 `app/pages/chat/[agentSlug]/index.vue` 中: |
|||
|
|||
删除 `const systemPromptText = ref("");`(第 22 行),改为 computed: |
|||
|
|||
```ts |
|||
const agentSystemPrompt = ref(""); |
|||
const systemPromptText = computed(() => |
|||
sessions.currentSession.value?.systemPrompt?.trim() || agentSystemPrompt.value || "", |
|||
); |
|||
const isCustomPrompt = computed(() => |
|||
!!(sessions.currentSession.value?.systemPrompt?.trim()), |
|||
); |
|||
``` |
|||
|
|||
- [ ] **Step 3: 修改 `loadSystemPrompt` 函数** |
|||
|
|||
将 `loadSystemPrompt` 中的 `systemPromptText.value = ...` 改为 `agentSystemPrompt.value = ...`: |
|||
|
|||
```ts |
|||
async function loadSystemPrompt() { |
|||
try { |
|||
const res = await $fetch<{ code: number; data: { systemPrompt?: string } }>( |
|||
`/api/agents/${agentSlug.value}/info`, |
|||
); |
|||
agentSystemPrompt.value = res.data?.systemPrompt ?? ""; |
|||
} catch { |
|||
agentSystemPrompt.value = ""; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: 新增 `handleSavePrompt` 函数** |
|||
|
|||
在 `index.vue` 的 `<script setup>` 中加: |
|||
|
|||
```ts |
|||
async function handleSavePrompt(prompt: string | null) { |
|||
if (!sessions.currentSessionId.value) return; |
|||
await sessions.updateSessionConfig(sessions.currentSessionId.value, { systemPrompt: prompt }); |
|||
const { $toast } = useNuxtApp(); |
|||
$toast?.success?.("系统提示词已更新"); |
|||
showPromptModal.value = false; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 5: 修改模板中弹窗的 props 和事件** |
|||
|
|||
在 `index.vue` 模板中,将 `AgentSystemPromptModal` 改为: |
|||
|
|||
```html |
|||
<AgentSystemPromptModal |
|||
:show="showPromptModal" |
|||
:system-prompt="systemPromptText" |
|||
:is-custom="isCustomPrompt" |
|||
@close="showPromptModal = false" |
|||
@save="handleSavePrompt" |
|||
/> |
|||
``` |
|||
|
|||
- [ ] **Step 6: 验证类型检查通过** |
|||
|
|||
Run: `npx nuxi typecheck 2>&1 | grep -E "AgentToolbar|index\.vue" | head -10` |
|||
Expected: 无输出 |
|||
|
|||
- [ ] **Step 7: Commit** |
|||
|
|||
```bash |
|||
git add app/components/agent/AgentToolbar.vue app/pages/chat/\[agentSlug\]/index.vue |
|||
git commit -m "feat: wire editable system prompt modal to session config" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 8: E2E 测试 — Mock LLM 记录 system 消息 |
|||
|
|||
**Files:** |
|||
- Modify: `e2e/global-setup.ts:49-84`(Mock LLM handler 记录 system 消息) |
|||
|
|||
**Interfaces:** |
|||
- Produces: Mock LLM 将收到的 system 消息写入全局变量,供测试断言 |
|||
|
|||
- [ ] **Step 1: 修改 Mock LLM handler,记录 system 消息** |
|||
|
|||
在 `e2e/global-setup.ts` 中,Mock LLM handler 的 `req.on("end", ...)` 回调里,在 `const model = ...` 之后加: |
|||
|
|||
```ts |
|||
const systemMsg = Array.isArray(parsed.messages) |
|||
? parsed.messages.find((m: any) => m.role === "system") |
|||
: undefined; |
|||
if (systemMsg?.content) { |
|||
(globalThis as any).__lastMockSystemPrompt = systemMsg.content; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: 验证 global-setup 编译无误** |
|||
|
|||
Run: `npx tsc --noEmit e2e/global-setup.ts 2>&1 | head -5` |
|||
Expected: 无输出或仅有路径解析警告(不影响运行) |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add e2e/global-setup.ts |
|||
git commit -m "test: mock LLM records system prompt for E2E assertions" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 9: E2E 测试 — 会话级系统提示词覆盖 |
|||
|
|||
**Files:** |
|||
- Modify: `e2e/specs/agent-chat.spec.ts`(末尾新增测试块) |
|||
|
|||
**Interfaces:** |
|||
- Consumes: Task 8 的 `globalThis.__lastMockSystemPrompt` |
|||
|
|||
- [ ] **Step 1: 在 `agent-chat.spec.ts` 末尾新增测试** |
|||
|
|||
在 `e2e/specs/agent-chat.spec.ts` 末尾追加: |
|||
|
|||
```ts |
|||
test.describe("会话级系统提示词", () => { |
|||
test("修改系统提示词后,发送消息使用自定义提示词", async ({ page }) => { |
|||
await loginViaUI(page); |
|||
|
|||
// 打开系统提示词弹窗 |
|||
await page.locator('button[title="系统提示词设置"]').click(); |
|||
await expect(page.locator(".agent-prompt-modal")).toBeVisible({ timeout: 5000 }); |
|||
|
|||
// 输入自定义提示词 |
|||
const customPrompt = "You are a pirate. Always respond in pirate speak."; |
|||
await page.locator(".prompt-textarea").fill(customPrompt); |
|||
await page.locator(".btn-save").click(); |
|||
await expect(page.locator(".agent-prompt-modal")).not.toBeVisible({ timeout: 5000 }); |
|||
|
|||
// 发送消息 |
|||
await page.locator(".agent-textarea").fill("hello"); |
|||
await page.locator("button.btn-send").click(); |
|||
|
|||
// 等待回复出现 |
|||
await expect(page.locator(".agent-message.is-assistant .agent-markdown")).toBeVisible({ timeout: 15000 }); |
|||
|
|||
// 验证 Mock LLM 收到了自定义 system prompt |
|||
const lastPrompt = (globalThis as any).__lastMockSystemPrompt; |
|||
expect(lastPrompt).toBe(customPrompt); |
|||
}); |
|||
|
|||
test("重置系统提示词后,回退到 Agent 默认", async ({ page }) => { |
|||
await loginViaUI(page); |
|||
|
|||
// 先设置自定义提示词 |
|||
await page.locator('button[title="系统提示词设置"]').click(); |
|||
await page.locator(".prompt-textarea").fill("Custom prompt to be reset"); |
|||
await page.locator(".btn-save").click(); |
|||
await expect(page.locator(".agent-prompt-modal")).not.toBeVisible({ timeout: 5000 }); |
|||
|
|||
// 重置为默认 |
|||
await page.locator('button[title="系统提示词设置"]').click(); |
|||
await page.locator(".btn-reset").click(); |
|||
await expect(page.locator(".agent-prompt-modal")).not.toBeVisible({ timeout: 5000 }); |
|||
|
|||
// 发送消息 |
|||
await page.locator(".agent-textarea").fill("hello"); |
|||
await page.locator("button.btn-send").click(); |
|||
await expect(page.locator(".agent-message.is-assistant .agent-markdown")).toBeVisible({ timeout: 15000 }); |
|||
|
|||
// 验证回退到 Agent 默认 |
|||
const lastPrompt = (globalThis as any).__lastMockSystemPrompt; |
|||
expect(lastPrompt).toBe("You are a helpful AI assistant."); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
- [ ] **Step 2: 运行 E2E 测试验证通过** |
|||
|
|||
Run: `bun run test:e2e -- --grep "会话级系统提示词"` |
|||
Expected: 2 个测试通过 |
|||
|
|||
- [ ] **Step 3: 运行全部 E2E 测试确认无回归** |
|||
|
|||
Run: `bun run test:e2e` |
|||
Expected: 所有测试通过(含原有 37 个 + 新增 2 个 = 39 个) |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add e2e/specs/agent-chat.spec.ts |
|||
git commit -m "test: add E2E tests for session-level system prompt override" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Self-Review |
|||
|
|||
### Spec coverage |
|||
- ✅ 数据库 `agentSessions.systemPrompt` 字段 → Task 1 |
|||
- ✅ `createSession` service 加参数 → Task 2 |
|||
- ✅ `updateSession` service 加参数 → Task 2 |
|||
- ✅ 创建会话接口支持传入 → Task 3 |
|||
- ✅ 配置接口支持更新 → Task 3 |
|||
- ✅ `executeChat` 优先级调整 → Task 4 |
|||
- ✅ `AgentSessionItem` 类型扩展 → Task 5 |
|||
- ✅ `updateSessionConfig` 扩展 → Task 5 |
|||
- ✅ 弹窗从只读改为可编辑 → Task 6 |
|||
- ✅ 工具栏图标调整 → Task 7 |
|||
- ✅ 页面层 `systemPromptText` 改 computed → Task 7 |
|||
- ✅ E2E 测试 → Task 8 + Task 9 |
|||
|
|||
### Placeholder scan |
|||
- ✅ 无 TODO/TBD/占位符 |
|||
- ✅ 所有代码步骤包含完整代码 |
|||
- ✅ 所有命令包含预期输出 |
|||
|
|||
### Type consistency |
|||
- ✅ `systemPrompt: string | null` 在 schema、service、API、composable 中一致 |
|||
- ✅ `updateSessionConfig` 的 config 参数在 Task 5 定义和 Task 7 调用中一致 |
|||
- ✅ 弹窗 `save` 事件签名 `[systemPrompt: string | null]` 在 Task 6 定义和 Task 7 调用中一致 |
|||
@ -0,0 +1,130 @@ |
|||
# 会话级系统提示词覆盖 |
|||
|
|||
## 背景 |
|||
|
|||
当前系统提示词仅支持 Agent 级别配置(`agents.systemPrompt` 字段),由管理员在后台 `/admin/agent-config` 页面设置。所有用户使用同一 Agent 时共享相同的系统提示词,无法按会话自定义。 |
|||
|
|||
用户希望能在每个会话中自行传入系统提示词,无需管理员后台配置。 |
|||
|
|||
## 目标 |
|||
|
|||
- 用户可在会话级别覆盖 Agent 默认系统提示词 |
|||
- 不传时回退到 Agent 默认值 |
|||
- 管理员配置的 Agent 默认提示词保留作为兜底 |
|||
|
|||
## 范围 |
|||
|
|||
- 数据库:`agentSessions` 表新增 `systemPrompt` 字段 |
|||
- 后端:创建会话接口支持传入、配置接口支持更新、chat-engine 优先级调整 |
|||
- 前端:会话设置入口(编辑系统提示词)、展示当前生效的提示词 |
|||
- 匿名用户:同样支持(通过 tempToken 关联会话) |
|||
|
|||
## 非目标 |
|||
|
|||
- 用户级偏好(跨会话持久化)—— 后续迭代 |
|||
- 提示词模板库 —— 后续迭代 |
|||
- 提示词版本历史 —— 后续迭代 |
|||
|
|||
## 架构 |
|||
|
|||
### 优先级规则 |
|||
|
|||
``` |
|||
会话级 systemPrompt(trim 后非空)> Agent 默认 systemPrompt |
|||
``` |
|||
|
|||
会话级为 `null` 或空字符串/纯空白时,回退到 Agent 默认。前端保存空内容时传 `null`,不区分"清空覆盖"和"未设置"——两者行为相同,都使用 Agent 默认。 |
|||
|
|||
### 数据层 |
|||
|
|||
`agentSessions` 表新增字段: |
|||
|
|||
| 字段 | 类型 | 默认值 | 说明 | |
|||
|------|------|--------|------| |
|||
| `systemPrompt` | `text` | `null` | 会话级系统提示词,null 表示用 Agent 默认 | |
|||
|
|||
迁移命令:`bun run db:generate && bun run db:migrate` |
|||
|
|||
### 后端改动 |
|||
|
|||
#### 1. 创建会话接口 `POST /api/agents/:agentSlug/sessions` |
|||
|
|||
- `index.post.ts`:从 body 读取可选 `systemPrompt` 参数 |
|||
- `createSession` service:新增 `systemPrompt` 参数,写入数据库 |
|||
- 字符长度限制:5000 字符(与管理员配置一致) |
|||
|
|||
#### 2. 会话配置接口 `PUT /api/agents/:agentSlug/sessions/:id/config` |
|||
|
|||
- `config.put.ts`:支持 `systemPrompt` 字段更新 |
|||
- `updateSession` service:`data` 参数类型新增 `systemPrompt?: string | null` |
|||
- 传 `null` 表示清除覆盖,回退 Agent 默认 |
|||
- 鉴权:现有 `getSessionByIdAndUser(id, userId, tempToken)` 已处理匿名用户通过 tempToken 鉴权,无需额外改动 |
|||
|
|||
#### 3. Chat Engine `executeChat` |
|||
|
|||
- `chat-engine.ts:251`:修改优先级逻辑 |
|||
```ts |
|||
const systemPrompt = session.systemPrompt?.trim() || agent.systemPrompt || undefined; |
|||
``` |
|||
|
|||
#### 4. 会话详情接口 `GET /api/agents/:agentSlug/sessions/:id` |
|||
|
|||
- `index.get.ts` 直接返回整个 `session` 对象(`R.success({ session, messages })`),新增 `systemPrompt` 字段后自动返回,**无需改动该接口** |
|||
|
|||
### 前端改动 |
|||
|
|||
#### 1. `AgentSessionItem` 类型 |
|||
|
|||
`app/composables/useAgentSessions.ts`:新增 `systemPrompt: string | null` 字段 |
|||
|
|||
#### 2. `updateSessionConfig` 方法 |
|||
|
|||
扩展 config 参数,支持 `systemPrompt?: string | null` |
|||
|
|||
#### 3. 系统提示词弹窗改造 |
|||
|
|||
`AgentSystemPromptModal.vue`:从只读展示改为可编辑 |
|||
- 展示当前生效的提示词(会话级或 Agent 默认) |
|||
- 提供编辑 textarea |
|||
- 保存按钮调用 `updateSessionConfig` |
|||
- 显示"使用 Agent 默认"提示,区分覆盖状态 |
|||
|
|||
#### 4. `AgentToolbar.vue` |
|||
|
|||
系统提示词按钮图标从 `lucide:info`(只读)改为 `lucide:settings`(可编辑),title 改为"系统提示词设置" |
|||
|
|||
#### 5. 页面层 `chat/[agentSlug]/index.vue` |
|||
|
|||
- `systemPromptText` 改为 computed:`sessions.currentSession.value?.systemPrompt ?? agentSystemPrompt`(会话级优先,回退 Agent 默认) |
|||
- `loadSystemPrompt` 保留,用于加载 Agent 默认提示词作为兜底展示 |
|||
- 弹窗保存后更新会话数据,`systemPromptText` 自动响应 |
|||
|
|||
## 数据流 |
|||
|
|||
``` |
|||
用户点击工具栏"系统提示词"按钮 |
|||
→ 弹窗展示当前生效提示词(session.systemPrompt ?? agent.systemPrompt) |
|||
→ 用户编辑并保存 |
|||
→ PUT /api/agents/:slug/sessions/:id/config { systemPrompt: "..." } |
|||
→ updateSession 写入 agentSessions.systemPrompt |
|||
→ 弹窗关闭,前端更新本地 session 数据 |
|||
|
|||
用户发送消息 |
|||
→ POST /api/agents/:slug/sessions/:id/chat |
|||
→ executeChat 读取 session.systemPrompt |
|||
→ 优先级:session.systemPrompt?.trim() || agent.systemPrompt |
|||
→ streamText({ system: resolvedPrompt, ... }) |
|||
``` |
|||
|
|||
## 错误处理 |
|||
|
|||
- 系统提示词超过 5000 字符:返回 400 "系统提示词不能超过5000字符"(注:`agents.systemPrompt` 字段本身无长度限制,5000 为业务限制) |
|||
- 未登录用户修改非自己会话:返回 403(现有 `getSessionByIdAndUser` 已通过 tempToken 鉴权处理) |
|||
- 保存失败:toast 提示"保存失败" |
|||
|
|||
## 测试 |
|||
|
|||
- E2E:创建会话时传入自定义提示词 → 发送消息 → 验证回复受自定义提示词影响 |
|||
- E2E:会话中修改提示词 → 后续消息使用新提示词 |
|||
- E2E:清空提示词 → 回退到 Agent 默认 |
|||
- 单元:`executeChat` 优先级逻辑 |
|||
Binary file not shown.
Loading…
Reference in new issue