feat(web): unify web entry, preset config, SSE streaming and dual-pane live logs

This commit is contained in:
liumangmang
2026-04-03 15:40:31 +08:00
parent 2d6c64ecff
commit 2150dfe24e
42 changed files with 1917 additions and 1533 deletions

View File

@@ -7,9 +7,15 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
@@ -42,6 +48,19 @@ import okhttp3.Response;
public class AiWorkflowService {
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
private static final String DEFAULT_TEAM = "系统部";
private static final String DEFAULT_CONTACT = "杨志强\n(系统平台组)";
private static final String DEFAULT_DEVELOPER = "刘靖";
private static final String[] FIXED_PROJECTS = {
"PRS-7050场站智慧管控",
"PRS-7950在线巡视",
"PRS-7950在线巡视电科院测试版"
};
private static final String FIXED_PROJECT_VALUE =
"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控";
private static final Pattern NUMBERED_ITEM_PATTERN = Pattern.compile("^\\s*(\\d+)[\\.、\\)]\\s*(.+)$");
private static final Pattern BULLET_ITEM_PATTERN = Pattern.compile("^\\s*[-*•]\\s*(.+)$");
private static final Pattern REVISION_ITEM_PATTERN = Pattern.compile("^\\s*(?:\\*\\s*)?r\\d+\\s*[-:]+\\s*(.+)$");
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
@@ -63,8 +82,14 @@ public class AiWorkflowService {
}
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
context.setProgress(10, "正在读取 Markdown 文件");
final Path outputRoot = outputFileService.getOutputRoot();
final List<String> requestedPaths = request.getFilePaths() == null
? java.util.Collections.<String>emptyList()
: request.getFilePaths();
context.setProgress(10, "正在读取 Markdown 文件,输出目录: " + outputRoot);
context.setProgress(12, "待处理文件: " + joinPaths(requestedPaths));
final List<Path> markdownFiles = resolveUserFiles(request.getFilePaths());
context.setProgress(18, "路径解析完成: " + joinResolvedPaths(markdownFiles));
aiInputValidator.validate(markdownFiles);
final String content = readMarkdownFiles(markdownFiles);
@@ -79,7 +104,7 @@ public class AiWorkflowService {
}
final String prompt = buildPrompt(content, period);
final String aiResponse = callDeepSeek(apiKey, prompt);
final String aiResponse = callDeepSeek(apiKey, prompt, context);
final JsonObject payload = extractJson(aiResponse);
context.setProgress(75, "正在生成 Excel 文件");
@@ -121,39 +146,101 @@ public class AiWorkflowService {
throw new IllegalArgumentException("文件路径不能为空");
}
final String normalizedInput = userPath.trim();
final Path outputRoot = outputFileService.getOutputRoot();
final Path rootPath = Paths.get("").toAbsolutePath().normalize();
final Path candidate = rootPath.resolve(userPath).normalize();
final Path docsRoot = rootPath.resolve("docs").normalize();
if (candidate.startsWith(outputRoot) || candidate.startsWith(rootPath.resolve("docs").normalize())) {
// 优先按输出目录相对路径解析(例如 md/*.md、excel/*.xlsx
final Path outputCandidate = outputFileService.resolveInOutput(normalizedInput);
if (Files.exists(outputCandidate) && Files.isRegularFile(outputCandidate)) {
return outputCandidate;
}
// 兼容绝对路径或历史路径输入,但仍限制在允许目录内
final Path raw = Paths.get(normalizedInput);
final Path candidate = raw.isAbsolute() ? raw.normalize() : rootPath.resolve(raw).normalize();
if (candidate.startsWith(outputRoot) || candidate.startsWith(docsRoot)) {
if (Files.exists(candidate) && Files.isRegularFile(candidate)) {
return candidate;
}
}
throw new IllegalArgumentException("文件不存在或不在允许目录:" + userPath);
final boolean outputCandidateExists = Files.exists(outputCandidate);
final boolean outputCandidateIsFile = outputCandidateExists && Files.isRegularFile(outputCandidate);
final boolean rootCandidateExists = Files.exists(candidate);
final boolean rootCandidateIsFile = rootCandidateExists && Files.isRegularFile(candidate);
throw new IllegalArgumentException(
"文件不存在或不在允许目录:" + normalizedInput
+ " | outputCandidate=" + outputCandidate
+ " (exists=" + outputCandidateExists + ", file=" + outputCandidateIsFile + ")"
+ " | rootCandidate=" + candidate
+ " (exists=" + rootCandidateExists + ", file=" + rootCandidateIsFile + ")"
+ " | outputRoot=" + outputRoot
+ " | docsRoot=" + docsRoot
);
}
private String joinPaths(List<String> paths) {
if (paths == null || paths.isEmpty()) {
return "(empty)";
}
final StringBuilder builder = new StringBuilder();
for (int i = 0; i < paths.size(); i++) {
if (i > 0) {
builder.append(", ");
}
builder.append(paths.get(i));
}
return builder.toString();
}
private String joinResolvedPaths(List<Path> paths) {
if (paths == null || paths.isEmpty()) {
return "(empty)";
}
final StringBuilder builder = new StringBuilder();
for (int i = 0; i < paths.size(); i++) {
if (i > 0) {
builder.append(", ");
}
builder.append(paths.get(i).toString());
}
return builder.toString();
}
private String buildPrompt(String markdownContent, String period) {
return "你是项目管理助手,请根据以下 SVN Markdown 日志生成工作量统计 JSON。\n"
+ "工作周期: " + period + "\n"
+ "要求:仅输出 JSON不要输出额外文字。\n"
+ "固定字段要求:\n"
+ "1. team 固定为 \"系统部\"\n"
+ "2. contact 固定为 \"杨志强\\n(系统平台组)\"\n"
+ "3. developer 固定为 \"刘靖\"\n"
+ "4. records 固定仅 1 条sequence 固定为 1\n"
+ "5. project 固定为 \"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控\"\n"
+ "6. content 必须按下面三段输出,且每段使用数字编号,不要包含 SVN 地址/仓库路径/版本范围/提交总数等元信息:\n"
+ "PRS-7050场站智慧管控\\n1. xxx\\n2. xxx\\n\\n"
+ "PRS-7950在线巡视\\n1. xxx\\n2. xxx\\n\\n"
+ "PRS-7950在线巡视电科院测试版\\n1. xxx\\n2. xxx\n"
+ "JSON结构:\n"
+ "{\n"
+ " \"team\": \"所属班组\",\n"
+ " \"contact\": \"技术对接人\",\n"
+ " \"developer\": \"开发人员\",\n"
+ " \"team\": \"系统部\",\n"
+ " \"contact\": \"杨志强\\n(系统平台组)\",\n"
+ " \"developer\": \"刘靖\",\n"
+ " \"period\": \"" + period + "\",\n"
+ " \"records\": [\n"
+ " {\"sequence\":1,\"project\":\"项目A/项目B\",\"content\":\"# 项目A\\n1.xxx\\n2.xxx\"}\n"
+ " {\"sequence\":1,\"project\":\"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控\",\"content\":\"PRS-7050场站智慧管控\\n1.xxx\\n2.xxx\\n\\nPRS-7950在线巡视\\n1.xxx\\n2.xxx\\n\\nPRS-7950在线巡视电科院测试版\\n1.xxx\"}\n"
+ " ]\n"
+ "}\n\n"
+ "日志内容:\n" + markdownContent;
}
private String callDeepSeek(String apiKey, String prompt) throws IOException {
private String callDeepSeek(String apiKey, String prompt, TaskContext context) throws IOException {
try {
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt), 3, 1000L);
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt, context), 3, 1000L);
} catch (IOException e) {
throw e;
} catch (Exception e) {
@@ -161,7 +248,7 @@ public class AiWorkflowService {
}
}
private String callDeepSeekOnce(String apiKey, String prompt) throws Exception {
private String callDeepSeekOnce(String apiKey, String prompt, TaskContext context) throws Exception {
final JsonObject message = new JsonObject();
message.addProperty("role", "user");
message.addProperty("content", prompt);
@@ -173,7 +260,13 @@ public class AiWorkflowService {
body.addProperty("model", "deepseek-reasoner");
body.add("messages", messages);
body.addProperty("max_tokens", 3500);
body.addProperty("stream", false);
body.addProperty("stream", true);
final JsonObject responseFormat = new JsonObject();
responseFormat.addProperty("type", "json_object");
body.add("response_format", responseFormat);
final JsonObject streamOptions = new JsonObject();
streamOptions.addProperty("include_usage", true);
body.add("stream_options", streamOptions);
final Request request = new Request.Builder()
.url(DEEPSEEK_API_URL)
@@ -198,19 +291,96 @@ public class AiWorkflowService {
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
}
final String raw = response.body().string();
final JsonObject data = JsonParser.parseString(raw).getAsJsonObject();
final JsonArray choices = data.getAsJsonArray("choices");
if (choices == null || choices.size() == 0) {
throw new IllegalStateException("DeepSeek API 未返回可用结果");
final StringBuilder answerBuilder = new StringBuilder();
final okhttp3.ResponseBody responseBody = response.body();
final okio.BufferedSource source = responseBody.source();
String finishReason = "";
context.emitEvent("phase", buildEventPayload("正在流式接收 DeepSeek 输出"));
while (!source.exhausted()) {
final String line = source.readUtf8Line();
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
continue;
}
final String dataLine = line.substring(5).trim();
if ("[DONE]".equals(dataLine)) {
break;
}
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
if (data.has("usage") && data.get("usage").isJsonObject()) {
final JsonObject usage = data.getAsJsonObject("usage");
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
usagePayload.put("promptTokens", optLong(usage, "prompt_tokens"));
usagePayload.put("completionTokens", optLong(usage, "completion_tokens"));
usagePayload.put("totalTokens", optLong(usage, "total_tokens"));
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
context.emitEvent("usage", usagePayload);
}
final JsonArray choices = data.getAsJsonArray("choices");
if (choices == null || choices.size() == 0) {
continue;
}
final JsonObject first = choices.get(0).getAsJsonObject();
if (first.has("delta") && first.get("delta").isJsonObject()) {
final JsonObject delta = first.getAsJsonObject("delta");
final String reasoning = optString(delta, "reasoning_content");
if (reasoning != null && !reasoning.isEmpty()) {
context.emitEvent("reasoning_delta", buildTextPayload(reasoning));
}
final String answer = optString(delta, "content");
if (answer != null && !answer.isEmpty()) {
answerBuilder.append(answer);
context.emitEvent("answer_delta", buildTextPayload(answer));
}
}
if (first.has("finish_reason") && !first.get("finish_reason").isJsonNull()) {
finishReason = first.get("finish_reason").getAsString();
}
}
final JsonObject first = choices.get(0).getAsJsonObject();
final JsonObject messageObj = first.getAsJsonObject("message");
if (messageObj == null || !messageObj.has("content")) {
throw new IllegalStateException("DeepSeek API 响应缺少 content 字段");
if ("length".equalsIgnoreCase(finishReason)) {
throw new IllegalStateException("DeepSeek 输出被截断finish_reason=length请增大 max_tokens 或缩短输入");
}
return messageObj.get("content").getAsString();
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
throw new RetrySupport.RetryableException("DeepSeek 资源不足,请稍后重试");
}
final String answer = answerBuilder.toString().trim();
if (answer.isEmpty()) {
throw new IllegalStateException("DeepSeek 未返回有效 content 内容");
}
return answer;
}
}
private Map<String, Object> buildTextPayload(String text) {
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("text", text);
return payload;
}
private Map<String, Object> buildEventPayload(String message) {
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("message", message);
return payload;
}
private Long optLong(JsonObject object, String key) {
if (object == null || key == null || !object.has(key) || object.get(key).isJsonNull()) {
return null;
}
try {
return Long.valueOf(object.get(key).getAsLong());
} catch (Exception ignored) {
return null;
}
}
@@ -239,10 +409,14 @@ public class AiWorkflowService {
}
private void writeExcel(Path outputFile, JsonObject payload, String defaultPeriod) throws IOException {
final String team = optString(payload, "team");
final String contact = optString(payload, "contact");
final String developer = optString(payload, "developer");
final String period = payload.has("period") ? optString(payload, "period") : defaultPeriod;
final String period = payload.has("period")
? firstNonBlank(optString(payload, "period"), defaultPeriod)
: defaultPeriod;
final String team = DEFAULT_TEAM;
final String contact = DEFAULT_CONTACT;
final String developer = DEFAULT_DEVELOPER;
final String project = FIXED_PROJECT_VALUE;
final String content = buildContentFromPayload(payload);
try (Workbook workbook = new XSSFWorkbook()) {
final Sheet sheet = workbook.createSheet("工作量统计");
@@ -259,19 +433,15 @@ public class AiWorkflowService {
cell.setCellStyle(headerStyle);
}
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
for (int i = 0; i < records.size(); i++) {
final JsonObject record = records.get(i).getAsJsonObject();
final Row row = sheet.createRow(i + 1);
createCell(row, 0, getAsInt(record.get("sequence"), i + 1), textStyle);
createCell(row, 1, team, textStyle);
createCell(row, 2, contact, textStyle);
createCell(row, 3, developer, textStyle);
createCell(row, 4, period, textStyle);
createCell(row, 5, optString(record, "project"), textStyle);
createCell(row, 6, optString(record, "content"), contentStyle);
}
final Row row = sheet.createRow(1);
row.setHeightInPoints(calculateRowHeight(content));
createCell(row, 0, 1, textStyle);
createCell(row, 1, team, textStyle);
createCell(row, 2, contact, textStyle);
createCell(row, 3, developer, textStyle);
createCell(row, 4, period, textStyle);
createCell(row, 5, project, textStyle);
createCell(row, 6, content, contentStyle);
sheet.setColumnWidth(0, 2200);
sheet.setColumnWidth(1, 4200);
@@ -360,6 +530,164 @@ public class AiWorkflowService {
return object.get(key).getAsString();
}
private String buildContentFromPayload(JsonObject payload) {
final Map<String, LinkedHashSet<String>> groupedItems = createGroupedItems();
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
for (JsonElement element : records) {
if (element == null || !element.isJsonObject()) {
continue;
}
final JsonObject record = element.getAsJsonObject();
final String recordProject = optString(record, "project");
final String recordContent = optString(record, "content");
collectItems(groupedItems, recordProject, recordContent);
}
final StringBuilder builder = new StringBuilder();
for (String project : FIXED_PROJECTS) {
final LinkedHashSet<String> items = groupedItems.get(project);
if (items == null || items.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append("\n\n");
}
builder.append(project).append('\n');
int index = 1;
for (String item : items) {
builder.append(index++).append(". ").append(item).append('\n');
}
}
return builder.toString().trim();
}
private Map<String, LinkedHashSet<String>> createGroupedItems() {
final Map<String, LinkedHashSet<String>> groupedItems =
new LinkedHashMap<String, LinkedHashSet<String>>();
for (String project : FIXED_PROJECTS) {
groupedItems.put(project, new LinkedHashSet<String>());
}
return groupedItems;
}
private void collectItems(Map<String, LinkedHashSet<String>> groupedItems, String projectText, String content) {
final String fallbackProject = normalizeProject(projectText);
String currentProject = fallbackProject;
final String[] lines = content == null ? new String[0] : content.split("\\r?\\n");
for (String rawLine : lines) {
final String line = rawLine == null ? "" : rawLine.trim();
if (line.isEmpty()) {
continue;
}
final String headingProject = parseHeadingProject(line);
if (headingProject != null) {
currentProject = headingProject;
continue;
}
final String extracted = parseWorkItem(line);
if (extracted == null || extracted.isEmpty()) {
continue;
}
final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject;
groupedItems.get(targetProject).add(extracted);
}
}
private String parseHeadingProject(String line) {
if (line.startsWith("#")) {
final String stripped = line.replaceFirst("^#+\\s*", "");
return normalizeProject(stripped);
}
return normalizeProject(line);
}
private String parseWorkItem(String line) {
if (isMetaLine(line)) {
return null;
}
Matcher matcher = NUMBERED_ITEM_PATTERN.matcher(line);
if (matcher.matches()) {
return cleanWorkItem(matcher.group(2));
}
matcher = BULLET_ITEM_PATTERN.matcher(line);
if (matcher.matches()) {
return cleanWorkItem(matcher.group(1));
}
matcher = REVISION_ITEM_PATTERN.matcher(line);
if (matcher.matches()) {
return cleanWorkItem(matcher.group(1));
}
if (line.length() > 6 && !line.startsWith("=") && !line.startsWith("```")) {
return cleanWorkItem(line);
}
return null;
}
private boolean isMetaLine(String line) {
return line.startsWith("SVN")
|| line.startsWith("仓库")
|| line.startsWith("分支")
|| line.startsWith("版本")
|| line.startsWith("提交总数")
|| line.startsWith("日志详情")
|| line.startsWith("作者")
|| line.startsWith("时间")
|| line.startsWith("消息")
|| line.startsWith("文件")
|| line.startsWith("=== 文件:");
}
private String cleanWorkItem(String item) {
String cleaned = item == null ? "" : item.trim();
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
cleaned = cleaned.replaceAll("\\s+", " ");
return cleaned.trim();
}
private String normalizeProject(String value) {
if (value == null) {
return null;
}
final String input = value.trim();
if (input.isEmpty()) {
return null;
}
if (input.contains("7050")) {
return "PRS-7050场站智慧管控";
}
if (input.contains("电科院")) {
return "PRS-7950在线巡视电科院测试版";
}
if (input.contains("7950")) {
return "PRS-7950在线巡视";
}
return null;
}
private float calculateRowHeight(String content) {
final String safeContent = content == null ? "" : content;
final String[] lines = safeContent.split("\\r?\\n");
final int visibleLines = Math.max(lines.length, 1);
final float lineHeight = 19.0f;
final float minHeight = 220.0f;
return Math.max(minHeight, visibleLines * lineHeight);
}
private String firstNonBlank(String preferred, String fallback) {
if (preferred != null && !preferred.trim().isEmpty()) {
return preferred.trim();
}
return fallback == null ? "" : fallback;
}
private String sanitize(String value) {
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_");
}