5 Commits
Author SHA1 Message Date
jiang bdf4436899 fix: preserve backdrop filter in production
Generic Container CI/CD / test-build-publish (push) Successful in 1m48s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m18s
2026-08-17 20:22:35 +08:00
jiang d29792a5c5 fix: isolate Next frontend deployment
Generic Container CI/CD / test-build-publish (push) Successful in 25s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 25s
2026-08-17 19:43:49 +08:00
jiang d1e69ad59b build: use mainland package mirrors
Generic Container CI/CD / test-build-publish (push) Successful in 25s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 25s
2026-08-17 19:25:38 +08:00
jiang 7216ffd60f feat: add container CI/CD workflow 2026-08-17 19:02:02 +08:00
jiang b1b68d2754 fix: complete agent question interactions 2026-07-28 18:08:02 +08:00
11 changed files with 440 additions and 23 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Frontend CI/CD
on:
push:
tags:
- "v*"
workflow_dispatch: {}
jobs:
build-test-publish-and-deploy:
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
with:
image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-frontend
dockerfile: Dockerfile
build_context: .
test_target: build
deploy_service: next-frontend
deploy_host: 192.168.1.114
secrets:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
+12 -4
View File
@@ -1,17 +1,23 @@
FROM node:24-alpine AS build
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS build
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
ENV npm_config_registry=${NPM_REGISTRY}
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
RUN pnpm install --frozen-lockfile --registry="${NPM_REGISTRY}"
COPY . .
RUN pnpm build
FROM caddy:2.10.2-alpine
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
RUN apk add --no-cache jq nodejs
ARG ALPINE_MIRROR=mirrors.aliyun.com
RUN sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
apk add --no-cache jq nodejs
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint
COPY --from=build /app/dist /srv
@@ -19,5 +25,7 @@ COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/tjwater-fr
RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --quiet --spider http://127.0.0.1/ || exit 1
ENTRYPOINT ["/usr/local/bin/tjwater-frontend-entrypoint"]
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
+11
View File
@@ -70,6 +70,17 @@ docker run --rm -p 8080:80 --env-file .env.local next-tjwater
启动后访问 `http://localhost:8080`。
## CI/CD
Gitea Actions 通过 `.gitea/workflows/package.yml` 复用
`OrgTJWater/ci-templates` 的容器发布流程。手动触发只构建镜像并在 Dev
服务器运行候选验证;从 `main` 创建并推送 `v*` tag 后,候选验证通过才会
提升为正式 `frontend` 服务。
仓库需要配置 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD` 和
`DEV_DEPLOY_SSH_KEY` 三个 Gitea Secrets。生产运行时配置由 Dev 服务器的
`env/frontend.env` 注入,不写入镜像或仓库 Secret。
## 项目结构
```text
+2 -1
View File
@@ -6,7 +6,8 @@
"packageManager": "pnpm@11.8.0",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && pnpm build:tts",
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
"build:verify-css": "node scripts/verify-production-css.mjs",
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
+35
View File
@@ -0,0 +1,35 @@
import { readdir, readFile } from "node:fs/promises";
import { stdout } from "node:process";
import { fileURLToPath, URL } from "node:url";
import path from "node:path";
const assetsDirectory = fileURLToPath(new URL("../dist/assets/", import.meta.url));
const assetNames = await readdir(assetsDirectory);
const stylesheetName = assetNames.find((name) => /^index-.*\.css$/.test(name));
if (!stylesheetName) {
throw new Error("Production CSS verification failed: index stylesheet was not emitted.");
}
const stylesheet = await readFile(path.join(assetsDirectory, stylesheetName), "utf8");
const selector = ".agent-panel-shell.acrylic-panel";
const selectorOffset = stylesheet.indexOf(selector);
const ruleStart = stylesheet.indexOf("{", selectorOffset);
const ruleEnd = stylesheet.indexOf("}", ruleStart);
if (selectorOffset < 0 || ruleStart < 0 || ruleEnd < 0) {
throw new Error(`Production CSS verification failed: ${selector} rule is missing.`);
}
const declarations = stylesheet
.slice(ruleStart + 1, ruleEnd)
.split(";")
.map((declaration) => declaration.trim());
if (!declarations.some((declaration) => declaration.startsWith("backdrop-filter:"))) {
throw new Error(
"Production CSS verification failed: the standard backdrop-filter declaration was removed."
);
}
stdout.write(`Verified standard backdrop-filter in ${stylesheetName}.\n`);
@@ -0,0 +1,90 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentMessageDetails } from "./agent-message-details";
import type { AgentQuestionInfo, AgentQuestionRequest } from "../types";
afterEach(cleanup);
describe("AgentMessageDetails question answers", () => {
it("submits a custom answer instead of the selected option for a single-choice question", async () => {
const user = userEvent.setup();
const onReplyQuestion = vi.fn();
renderQuestion(
{
header: "分析范围",
question: "你想分析哪一部分?",
options: [
{ label: "雨污混接分析", description: "检查混接问题" },
{ label: "水质异常分析", description: "检查水质指标" }
]
},
onReplyQuestion
);
await user.click(screen.getByRole("radio", { name: /雨污混接分析/ }));
await user.click(screen.getByRole("radio", { name: "自定义回答" }));
await user.type(screen.getByPlaceholderText("输入自定义回答"), "检查高峰时段压力");
await user.click(screen.getByRole("button", { name: "提交回答" }));
expect(onReplyQuestion).toHaveBeenCalledWith(
expect.objectContaining({ requestId: "question-1" }),
[["检查高峰时段压力"]]
);
});
it("combines selected options with a custom answer for a multiple-choice question", async () => {
const user = userEvent.setup();
const onReplyQuestion = vi.fn();
renderQuestion(
{
header: "分析内容",
question: "需要包含哪些内容?",
options: [
{ label: "压力", description: "分析节点压力" },
{ label: "流量", description: "分析管段流量" }
],
multiple: true,
custom: true
},
onReplyQuestion
);
await user.click(screen.getByRole("checkbox", { name: /压力/ }));
await user.click(screen.getByRole("checkbox", { name: "自定义回答" }));
await user.type(screen.getByPlaceholderText("输入自定义回答"), "补充水龄");
await user.click(screen.getByRole("button", { name: "提交回答" }));
expect(onReplyQuestion).toHaveBeenCalledWith(
expect.objectContaining({ requestId: "question-1" }),
[["压力", "补充水龄"]]
);
});
});
function renderQuestion(
question: AgentQuestionInfo,
onReplyQuestion: (request: AgentQuestionRequest, answers: string[][]) => void
) {
render(
<AgentMessageDetails
message={{
id: "assistant-1",
role: "assistant",
content: "需要补充信息",
questions: [
{
requestId: "question-1",
sessionId: "session-1",
questions: [question],
createdAt: 100,
status: "pending"
}
]
}}
onReplyQuestion={onReplyQuestion}
/>
);
}
@@ -574,7 +574,7 @@ function QuestionRequestCard({
key={`${request.requestId}-${questionIndex}`}
question={question}
questionIndex={questionIndex}
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
value={draftAnswers[questionIndex] ?? { selected: [], customSelected: false, custom: "" }}
disabled={!actionable || submitting}
onChange={(nextValue) =>
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
@@ -633,6 +633,7 @@ function QuestionRequestCard({
type QuestionDraftAnswer = {
selected: string[];
customSelected: boolean;
custom: string;
};
@@ -649,8 +650,11 @@ function QuestionInput({
disabled: boolean;
onChange: (value: QuestionDraftAnswer) => void;
}) {
const name = `agent-question-${questionIndex}-${question.question}`;
const showCustomInput = question.custom || question.options.length === 0;
const inputId = useId();
const name = `agent-question-${questionIndex}-${inputId}`;
const customEnabled = question.custom !== false;
const showCustomChoice = customEnabled && question.options.length > 0;
const showCustomInput = customEnabled && value.customSelected;
const toggleOption = (label: string, checked: boolean) => {
if (question.multiple) {
@@ -662,21 +666,36 @@ function QuestionInput({
}
onChange({
...value,
selected: checked ? [label] : []
selected: checked ? [label] : [],
customSelected: false
});
};
const toggleCustom = (checked: boolean) => {
onChange({
...value,
selected: question.multiple ? value.selected : [],
customSelected: checked
});
};
return (
<div className="space-y-1.5">
<p className="leading-5 text-slate-700">{question.question}</p>
{question.options.length ? (
{question.options.length || showCustomChoice ? (
<div className="space-y-1">
{question.options.map((option) => {
const checked = value.selected.includes(option.label);
return (
<label
key={option.label}
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
className={cn(
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
checked
? "border-blue-300 bg-blue-50/70 text-blue-700"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
disabled && "cursor-default opacity-60"
)}
>
<input
type={question.multiple ? "checkbox" : "radio"}
@@ -695,12 +714,34 @@ function QuestionInput({
</label>
);
})}
{showCustomChoice ? (
<label
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
value.customSelected
? "border-blue-300 bg-blue-50/70 text-blue-700"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
disabled && "cursor-default opacity-60"
)}
>
<input
type={question.multiple ? "checkbox" : "radio"}
name={name}
className="h-3.5 w-3.5 accent-blue-600"
checked={value.customSelected}
disabled={disabled}
onChange={(event) => toggleCustom(event.currentTarget.checked)}
/>
<span className="font-semibold text-slate-700">自定义回答</span>
</label>
) : null}
</div>
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
aria-label="自定义回答内容"
placeholder="输入自定义回答"
value={value.custom}
disabled={disabled}
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
@@ -711,16 +752,25 @@ function QuestionInput({
}
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
return request.questions.map((question, index) => ({
selected: request.answers?.[index] ?? [],
custom: ""
}));
return request.questions.map((question, index) => {
const optionLabels = new Set(question.options.map((option) => option.label));
const savedAnswers = request.answers?.[index] ?? [];
const selected = savedAnswers.filter((answer) => optionLabels.has(answer));
const customAnswers = savedAnswers.filter((answer) => !optionLabels.has(answer));
const customEnabled = question.custom !== false;
return {
selected,
customSelected: customEnabled && (question.options.length === 0 || customAnswers.length > 0),
custom: customAnswers.join(";")
};
});
}
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
return draftAnswers.map((answer) => {
const custom = answer.custom.trim();
return custom ? [...answer.selected, custom] : answer.selected;
return answer.customSelected && custom ? [...answer.selected, custom] : answer.selected;
});
}
+27
View File
@@ -94,6 +94,33 @@ describe("agent session state", () => {
});
});
it("preserves an actionable question id when its tool placeholder arrives later", () => {
const questions = upsertQuestion(undefined, {
session_id: "session-1",
request_id: "question-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 100
});
const withPlaceholder = upsertQuestion(questions, {
session_id: "session-1",
request_id: "call-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 120
});
const rejected = applyQuestionResponse(withPlaceholder, {
request_id: "question-1",
rejected: true
});
expect(withPlaceholder).toHaveLength(1);
expect(rejected?.[0]).toMatchObject({
requestId: "question-1",
status: "rejected"
});
});
it("finalizes open assistant state after abort", () => {
const message: AgentChatMessage = {
id: "assistant-1",
+16 -3
View File
@@ -183,10 +183,23 @@ export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, da
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
if (index >= 0) {
next[index] = {
...next[index],
const current = next[index];
const currentIsActionable = Boolean(current.tool?.callID && current.requestId !== current.tool.callID);
const nextIsToolPlaceholder = Boolean(nextItem.tool?.callID && nextItem.requestId === nextItem.tool.callID);
next[index] =
currentIsActionable && nextIsToolPlaceholder
? {
...current,
sessionId: nextItem.sessionId,
questions: nextItem.questions,
tool: nextItem.tool ?? current.tool,
createdAt: nextItem.createdAt
}
: {
...current,
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status
status: current.status === "submitting" ? "submitting" : nextItem.status
};
} else {
next.push(nextItem);
+160
View File
@@ -0,0 +1,160 @@
import { expect, test, type Page } from "@playwright/test";
test("submits a custom answer with the actionable id after a later tool placeholder", async ({
page
}) => {
const replies: Array<{ pathname: string; body: unknown }> = [];
await mockQuestionApi(page, replies);
await page.goto("/", { waitUntil: "domcontentloaded" });
const prompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first();
await prompt.fill("测试 question 自定义回答");
await page
.getByRole("button", { name: "发送 Agent 指令" })
.filter({ visible: true })
.first()
.click();
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
await panel.getByRole("radio", { name: "自定义回答" }).click();
await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力");
await panel.getByRole("button", { name: "提交回答" }).click();
await expect.poll(() => replies).toEqual([
{
pathname: "/api/v1/agent/chat/question/question-1/reply",
body: {
session_id: "question-session",
answers: [["分析高峰时段压力"]]
}
}
]);
await expect(panel.getByText("已回答", { exact: true })).toBeVisible();
await expect(panel.getByRole("button", { name: "提交回答" })).toHaveCount(0);
});
test("keeps the custom answer control within a 375px Agent sheet", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await mockQuestionApi(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
await page.getByRole("button", { name: /Agent 工作台抽屉高度/ }).click();
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
const prompt = panel.getByPlaceholder(/输入调度问题/);
await prompt.fill("测试窄屏 question");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await panel.getByRole("radio", { name: "自定义回答" }).click();
const textarea = panel.getByPlaceholder("输入自定义回答");
await expect(textarea).toBeVisible();
const box = await textarea.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(375);
});
async function mockQuestionApi(
page: Page,
replies: Array<{ pathname: string; body: unknown }>
) {
await page.route("**/api/v1/agent/chat/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname.endsWith("/stream")) {
await route.fulfill({
status: 200,
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
},
body: createQuestionStream()
});
return;
}
if (pathname.endsWith("/question/question-1/reply")) {
replies.push({
pathname,
body: request.postDataJSON()
});
await route.fulfill({ json: {} });
return;
}
if (pathname.endsWith("/models")) {
await route.fulfill({
json: {
default_model: "test/model",
models: [
{
id: "test/model",
label: "测试模型",
description: "Question 交互测试",
icon: "bolt"
}
]
}
});
return;
}
if (pathname.endsWith("/sessions")) {
await route.fulfill({ json: { sessions: [] } });
return;
}
await route.fulfill({ json: {} });
});
}
function createQuestionStream() {
const question = {
session_id: "question-session",
questions: [
{
header: "分析范围",
question: "你想分析哪一部分?",
options: [
{
label: "雨污混接分析",
description: "检查混接问题"
}
]
}
],
tool: {
messageID: "assistant-question",
callID: "call-1"
}
};
const parts = [
{ type: "start", messageId: "assistant-question" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: "请选择分析范围。" },
{
type: "data-question_request",
id: "question-1",
data: {
...question,
request_id: "question-1",
created_at: 100
}
},
{
type: "data-question_request",
id: "call-1",
data: {
...question,
request_id: "call-1",
created_at: 120
}
},
{ type: "text-end", id: "answer" },
{ type: "finish", finishReason: "stop" }
];
return parts.map((part) => `data: ${JSON.stringify(part)}\n\n`).join("");
}
+1 -1
View File
@@ -46,7 +46,7 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
plugins: [runtimeConfigPlugin(env), react(), tailwindcss()],
plugins: [runtimeConfigPlugin(env), react(), tailwindcss({ optimize: false })],
server: {
allowedHosts: ["next.demo.waternetwork.cn"]
},