Browse Source

feat: add systemPrompt column to agentSessions table

Co-authored-by: CodeFree <codefree@chinatelcom.cn>
feat/agent-independence
npmrun 2 months ago
parent
commit
588a423b86
  1. 1
      app/pages/chat/[agentSlug]/index.vue
  2. 38
      app/pages/index.vue
  3. 55
      e2e/global-setup.ts
  4. 33
      e2e/global-teardown.ts
  5. 99
      e2e/specs/diagnose.spec.ts
  6. BIN
      packages/drizzle-pkg/db.sqlite
  7. 1
      packages/drizzle-pkg/lib/schema/agent.ts
  8. 1
      packages/drizzle-pkg/migrations/0001_gifted_yellow_claw.sql
  9. 2509
      packages/drizzle-pkg/migrations/meta/0001_snapshot.json
  10. 7
      packages/drizzle-pkg/migrations/meta/_journal.json

1
app/pages/chat/[agentSlug]/index.vue

@ -178,6 +178,7 @@ async function handleSend(content: string) {
} }
if (sessions.currentSessionId.value) { if (sessions.currentSessionId.value) {
const sid = sessions.currentSessionId.value; const sid = sessions.currentSessionId.value;
chat.setCurrentSessionId(sid);
const inst = chat.getInstance(sid); const inst = chat.getInstance(sid);
const wasNewSession = inst.messages.value.filter((m) => m.role === "assistant").length === 0; const wasNewSession = inst.messages.value.filter((m) => m.role === "assistant").length === 0;
sessions.touchSessionOrder(sid); sessions.touchSessionOrder(sid);

38
app/pages/index.vue

@ -9,7 +9,41 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="flex items-center justify-center h-screen"> <div class="redirect-page">
<div class="text-gray-400">正在跳转...</div> <div class="redirect-spinner" />
<p class="redirect-text">正在跳转…</p>
</div> </div>
</template> </template>
<style scoped>
.redirect-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.redirect-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-hairline, #e6dfd8);
border-top-color: var(--color-primary, #cc785c);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 14px;
}
.redirect-text {
margin: 0;
font-size: 14px;
color: var(--color-muted, #6c6a64);
letter-spacing: 0.02em;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

55
e2e/global-setup.ts

@ -8,11 +8,34 @@ const ROOT = path.resolve(import.meta.dirname, "..");
const OUTPUT_DIR = path.join(ROOT, ".output"); const OUTPUT_DIR = path.join(ROOT, ".output");
const TEST_DB = path.join(ROOT, "packages", "drizzle-pkg", "db.test.sqlite"); const TEST_DB = path.join(ROOT, "packages", "drizzle-pkg", "db.test.sqlite");
const MOCK_LLM_PORT = Number(process.env.MOCK_LLM_PORT) || 3600; const MOCK_LLM_PORT = Number(process.env.MOCK_LLM_PORT) || 3600;
const PREVIEW_PORT = Number(process.env.NITRO_PORT) || 3400;
const MOCK_REPLY = "你好!这是一个测试回复。"; const MOCK_REPLY = "你好!这是一个测试回复。";
let mockLlmServer: Server | null = null; let mockLlmServer: Server | null = null;
/**
* 杀掉占用指定端口的进程,避免上次崩溃遗留的进程导致 EADDRINUSE。
* 使用 fuser 优雅杀掉,失败则尝试 lsof + kill。
*/
function killPortOccupants(port: number) {
try {
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { stdio: "pipe" });
} catch {
// fuser 不可用时回退到 lsof
try {
const out = execSync(`lsof -t -i :${port} 2>/dev/null || true`, {
stdio: "pipe",
}).toString().trim();
if (out) {
for (const pid of out.split("\n")) {
try { process.kill(Number(pid), "SIGKILL"); } catch { /* 已退出 */ }
}
}
} catch { /* 忽略 */ }
}
}
function startMockLlm(): Promise<void> { function startMockLlm(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
mockLlmServer = createServer((req, res) => { mockLlmServer = createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*"); res.setHeader("Access-Control-Allow-Headers", "*");
@ -68,6 +91,23 @@ function startMockLlm(): Promise<void> {
res.writeHead(404, { "Content-Type": "application/json" }); res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" })); res.end(JSON.stringify({ error: "not found" }));
}); });
// 处理端口占用错误:再次清理后重试一次
mockLlmServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
log(`Port ${MOCK_LLM_PORT} in use, retrying cleanup...`);
killPortOccupants(MOCK_LLM_PORT);
setTimeout(() => {
mockLlmServer?.listen(MOCK_LLM_PORT, () => {
log(`[mock-llm] listening on http://localhost:${MOCK_LLM_PORT} (after retry)`);
resolve();
});
}, 1000);
} else {
reject(err);
}
});
mockLlmServer.listen(MOCK_LLM_PORT, () => { mockLlmServer.listen(MOCK_LLM_PORT, () => {
log(`[mock-llm] listening on http://localhost:${MOCK_LLM_PORT}`); log(`[mock-llm] listening on http://localhost:${MOCK_LLM_PORT}`);
resolve(); resolve();
@ -103,6 +143,12 @@ function loadEnvTest() {
} }
async function main() { async function main() {
// 0. 清理上次崩溃可能遗留的进程(mock LLM + preview server)
log(`Cleaning up port occupants: ${MOCK_LLM_PORT}, ${PREVIEW_PORT}...`);
killPortOccupants(MOCK_LLM_PORT);
killPortOccupants(PREVIEW_PORT);
await new Promise((resolve) => setTimeout(resolve, 500));
// 1. 加载 .env.test // 1. 加载 .env.test
loadEnvTest(); loadEnvTest();
@ -257,6 +303,13 @@ async function seedLlmData(dbPath: string) {
args: [String(Number(modelId))], args: [String(Number(modelId))],
}); });
// 插入 default agent(seed 脚本只做 bootstrap admin,不含 agent seed)
await client.execute({
sql: `INSERT INTO agents (slug, name, description, system_prompt, default_model_id, title_strategy, enable_thinking, enable_tools, max_step_count, is_default, is_callable, sort_order, enabled)
VALUES ('default', '默认助手', '通用 AI 助手', 'You are a helpful AI assistant.', ?, 'llm', 0, 1, 6, 1, 0, 0, 1)`,
args: [Number(modelId)],
});
log(`LLM seed complete: provider=${providerId}, model=${modelId}`); log(`LLM seed complete: provider=${providerId}, model=${modelId}`);
} finally { } finally {
client.close(); client.close();

33
e2e/global-teardown.ts

@ -1,16 +1,40 @@
import { readFileSync, existsSync, unlinkSync } from "node:fs"; import { readFileSync, existsSync, unlinkSync } from "node:fs";
import { execSync } from "node:child_process";
import path from "node:path"; import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, ".."); const ROOT = path.resolve(import.meta.dirname, "..");
const PID_FILE = path.join(ROOT, ".e2e-server-pid"); const PID_FILE = path.join(ROOT, ".e2e-server-pid");
const MOCK_LLM_PORT = Number(process.env.MOCK_LLM_PORT) || 3600;
const PREVIEW_PORT = Number(process.env.NITRO_PORT) || 3400;
/**
* 杀掉占用指定端口的进程,确保即使 setup 崩溃也能清理遗留进程。
*/
function killPortOccupants(port: number) {
try {
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { stdio: "pipe" });
} catch {
try {
const out = execSync(`lsof -t -i :${port} 2>/dev/null || true`, {
stdio: "pipe",
}).toString().trim();
if (out) {
for (const pid of out.split("\n")) {
try { process.kill(Number(pid), "SIGKILL"); } catch { /* 已退出 */ }
}
}
} catch { /* 忽略 */ }
}
}
async function main() { async function main() {
// 1. 通过 PID 文件优雅停止 preview server
if (existsSync(PID_FILE)) { if (existsSync(PID_FILE)) {
const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10); const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
if (pid) { if (pid) {
try { try {
process.kill(pid, "SIGTERM"); process.kill(pid, "SIGTERM");
console.log(`[global-teardown] Stopped server (PID: ${pid})`); console.log(`[global-teardown] Stopped preview server (PID: ${pid})`);
} catch { } catch {
// 进程可能已退出 // 进程可能已退出
} }
@ -18,7 +42,12 @@ async function main() {
unlinkSync(PID_FILE); unlinkSync(PID_FILE);
} }
// mock LLM 服务器在 global-setup 进程中运行,进程退出时自动关闭 // 2. 兜底清理:杀掉占用端口的残留进程(mock LLM + preview server)
// 防止 setup 崩溃后 mock LLM 进程遗留导致下次 EADDRINUSE
console.log(`[global-teardown] Cleaning up ports: ${MOCK_LLM_PORT}, ${PREVIEW_PORT}`);
killPortOccupants(MOCK_LLM_PORT);
killPortOccupants(PREVIEW_PORT);
console.log("[global-teardown] Done"); console.log("[global-teardown] Done");
} }

99
e2e/specs/diagnose.spec.ts

@ -0,0 +1,99 @@
import { test, expect } from "@playwright/test";
import { E2E_ADMIN, FIXED_CAPTCHA } from "../fixtures/test-users";
test("诊断: 发送消息流程", async ({ page }) => {
const consoleMessages: string[] = [];
const pageErrors: string[] = [];
const networkRequests: string[] = [];
page.on("console", (msg) => {
consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
});
page.on("pageerror", (err) => {
pageErrors.push(err.message);
});
page.on("request", (req) => {
if (req.url().includes("/api/")) {
networkRequests.push(`REQ ${req.method()} ${req.url()}`);
}
});
page.on("response", (res) => {
if (res.url().includes("/api/agents") || res.url().includes("/api/llm")) {
networkRequests.push(`RES ${res.status()} ${res.url()}`);
}
});
await page.goto("/auth/login");
await page.fill("#login-username", E2E_ADMIN.username);
await page.fill("#login-password", E2E_ADMIN.password);
await page.fill("#login-captcha", FIXED_CAPTCHA);
await page.click('button[type="submit"]');
await page.waitForURL("/chat/default", { timeout: 15000 });
await expect(page.locator(".agent-textarea")).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(2000);
const configRes = await page.evaluate(async () => {
const r = await fetch("/api/config/me");
return { status: r.status, body: await r.json() };
});
console.log("=== /api/config/me ===", JSON.stringify(configRes));
const modelsRes = await page.evaluate(async () => {
const r = await fetch("/api/llm/models");
return { status: r.status, body: await r.json() };
});
console.log("=== /api/llm/models ===", JSON.stringify(modelsRes));
const sessionsRes = await page.evaluate(async () => {
const r = await fetch("/api/agents/default/sessions");
return { status: r.status, body: await r.json() };
});
console.log("=== /api/agents/default/sessions ===", JSON.stringify(sessionsRes));
const agentInfoRes = await page.evaluate(async () => {
const r = await fetch("/api/agents/default/info");
return { status: r.status, body: await r.json() };
});
console.log("=== /api/agents/default/info ===", JSON.stringify(agentInfoRes));
const newChatBtn = page.locator(".new-chat-btn").first();
if (await newChatBtn.isVisible()) {
await newChatBtn.click();
await page.waitForTimeout(1000);
}
console.log("=== Before send ===");
console.log("Console messages:", consoleMessages.slice(-10));
console.log("Page errors:", pageErrors);
console.log("Network requests:", networkRequests);
const testMessage = "你好,这是一条测试消息";
await page.fill(".agent-textarea", testMessage);
const btnSend = page.locator(".btn-send");
const isDisabled = await btnSend.isDisabled();
console.log(`Send button disabled: ${isDisabled}`);
await page.click(".btn-send");
await page.waitForTimeout(3000);
console.log("=== After send (3s) ===");
console.log("Console messages:", consoleMessages.slice(-20));
console.log("Page errors:", pageErrors);
console.log("Network requests (last 10):", networkRequests.slice(-10));
const userMessages = await page.locator(".agent-message.is-user .user-bubble").count();
const assistantMessages = await page.locator(".agent-message.is-assistant").count();
const allMessages = await page.locator(".agent-message").count();
console.log(`User messages: ${userMessages}, Assistant messages: ${assistantMessages}, All: ${allMessages}`);
const textareaValue = await page.locator(".agent-textarea").inputValue();
console.log(`Textarea value after send: "${textareaValue}"`);
const toastText = await page.locator(".toast, [class*='toast'], [class*='Toast']").count();
console.log(`Toast elements: ${toastText}`);
const bodyText = await page.locator("body").innerText();
console.log(`Body text (last 500 chars): ${bodyText.slice(-500)}`);
});

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

1
packages/drizzle-pkg/lib/schema/agent.ts

@ -65,6 +65,7 @@ export const agentSessions = sqliteTable(
modelId: integer("model_id"), modelId: integer("model_id"),
enableThinking: integer("enable_thinking"), enableThinking: integer("enable_thinking"),
enableTools: integer("enable_tools"), enableTools: integer("enable_tools"),
systemPrompt: text("system_prompt"),
lastActiveAt: integer("last_active_at", { mode: "timestamp_ms" }).defaultNow().notNull(), lastActiveAt: integer("last_active_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }), expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
deletedAt: integer("deleted_at", { mode: "timestamp_ms" }), deletedAt: integer("deleted_at", { mode: "timestamp_ms" }),

1
packages/drizzle-pkg/migrations/0001_gifted_yellow_claw.sql

@ -0,0 +1 @@
ALTER TABLE `agent_sessions` ADD `system_prompt` text;

2509
packages/drizzle-pkg/migrations/meta/0001_snapshot.json

File diff suppressed because it is too large

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -8,6 +8,13 @@
"when": 1786207777065, "when": 1786207777065,
"tag": "0000_natural_ken_ellis", "tag": "0000_natural_ken_ellis",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1786244884461,
"tag": "0001_gifted_yellow_claw",
"breakpoints": true
} }
] ]
} }
Loading…
Cancel
Save