From 23b4c1d115c344464fc1342abebaf28a5617e04d Mon Sep 17 00:00:00 2001 From: chen <2501238913@qq.com> Date: Sat, 26 Jul 2025 16:57:19 +0800 Subject: [PATCH] ai submit first --- .gitignore | 3 +- CANDIDATE_README.md | 126 +++++++ backend/meeting-summary.iml | 131 +++++++ backend/pom.xml | 79 ++++ backend/src/example.py | 0 .../MeetingSummaryApplication.java | 19 + .../controller/MeetingSummaryController.java | 98 +++++ .../meetingsummary/dto/SummaryRequest.java | 23 ++ .../meetingsummary/dto/SummaryResponse.java | 104 ++++++ .../meetingsummary/model/MeetingSummary.java | 165 +++++++++ .../repository/MeetingSummaryRepository.java | 11 + .../meetingsummary/service/GeminiService.java | 208 +++++++++++ .../service/MeetingSummaryService.java | 55 +++ backend/src/main/resources/application.yml | 31 ++ frontend/package.json | 29 ++ frontend/src/App.vue | 33 ++ frontend/src/example.ts | 0 frontend/src/main.js | 16 + frontend/src/router/index.js | 24 ++ frontend/src/services/api.js | 96 +++++ frontend/src/views/Home.vue | 341 ++++++++++++++++++ frontend/src/views/Summary.vue | 173 +++++++++ frontend/vue.config.js | 13 + global.sh | 38 ++ 24 files changed, 1815 insertions(+), 1 deletion(-) create mode 100644 backend/meeting-summary.iml create mode 100644 backend/pom.xml delete mode 100644 backend/src/example.py create mode 100644 backend/src/main/java/com/work4u/meetingsummary/MeetingSummaryApplication.java create mode 100644 backend/src/main/java/com/work4u/meetingsummary/controller/MeetingSummaryController.java create mode 100644 backend/src/main/java/com/work4u/meetingsummary/dto/SummaryRequest.java create mode 100644 backend/src/main/java/com/work4u/meetingsummary/dto/SummaryResponse.java create mode 100644 backend/src/main/java/com/work4u/meetingsummary/model/MeetingSummary.java create mode 100644 backend/src/main/java/com/work4u/meetingsummary/repository/MeetingSummaryRepository.java create mode 100644 backend/src/main/java/com/work4u/meetingsummary/service/GeminiService.java create mode 100644 backend/src/main/java/com/work4u/meetingsummary/service/MeetingSummaryService.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue delete mode 100644 frontend/src/example.ts create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/services/api.js create mode 100644 frontend/src/views/Home.vue create mode 100644 frontend/src/views/Summary.vue create mode 100644 frontend/vue.config.js create mode 100644 global.sh diff --git a/.gitignore b/.gitignore index 3d5df2d..255b6ab 100644 --- a/.gitignore +++ b/.gitignore @@ -287,4 +287,5 @@ target/ # Package manager files package-lock.json yarn.lock -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +任务.txt diff --git a/CANDIDATE_README.md b/CANDIDATE_README.md index e69de29..4050e64 100644 --- a/CANDIDATE_README.md +++ b/CANDIDATE_README.md @@ -0,0 +1,126 @@ +# AI Meeting Digest - Candidate Submission + +## 1. Technology Choices + +* **Frontend:** Vue.js 3 with Element Plus UI library +* **Backend:** Java Spring Boot with Spring WebFlux for reactive programming +* **Database:** MongoDB +* **AI Service:** Google Gemini API + +### Why This Stack? + +I chose this modern full-stack combination for several reasons: + +- **Vue.js 3** provides excellent developer experience with its composition API and reactive system, paired with Element Plus for professional UI components +- **Spring Boot with WebFlux** enables both traditional REST APIs and reactive streaming capabilities needed for real-time AI response streaming +- **MongoDB** offers flexible document storage perfect for meeting summaries with varying structures +- **Google Gemini API** provides reliable AI text generation with streaming support and generous free tier + +## 2. How to Run the Project + +### Prerequisites +- Java 17 or higher +- Node.js 16+ and npm/yarn +- MongoDB instance (configured for 192.168.0.49:29001) + +### Backend Setup +```bash +cd backend +# Run the Spring Boot application +./mvnw spring-boot:run +# Or: mvn spring-boot:run +``` + +### Frontend Setup +```bash +cd frontend +npm install +npm run serve +``` + +### Database Setup +The application will automatically connect to the configured MongoDB instance at 192.168.0.49:29001 and create the `meeting_summary` database if it doesn't exist. + +### Access the Application +- Frontend: http://localhost:8081 +- Backend API: http://localhost:8080 + +## 3. Design Decisions & Trade-offs + +### Architecture Decisions + +1. **Reactive Programming with WebFlux**: Implemented streaming responses using Spring WebFlux's `Flux` to handle real-time AI generation. This provides better user experience but adds complexity. + +2. **Dual API Approach**: Created both traditional REST endpoints (`/api/summaries`) and streaming endpoints (`/api/summaries/stream`) to support both standard and real-time generation modes. + +3. **Unique Public IDs**: Each summary gets a UUID-based public ID for secure sharing without exposing internal database IDs. + +4. **Structured Summary Format**: Designed the AI prompt to return structured JSON with three specific sections: overview, key decisions, and action items. + +### Implemented Features + +#### Core Features  +- Clean, responsive UI with textarea for transcript input +- "Generate Digest" button with loading states +- Structured summary display (overview, decisions, action items) +- History view of all past summaries +- Database persistence of transcripts and summaries + +#### Bonus Features  +- **Shareable Digest Links**: Each summary has a unique public URL (`/summary/:publicId`) +- **Real-time Streaming Response**: Implemented Server-Sent Events for word-by-word AI response streaming + +### Trade-offs Made + +1. **Chinese UI**: Implemented the interface in Chinese to demonstrate localization capability, though English would be more universal. + +2. **Simple Error Handling**: Basic error handling with user-friendly messages, but could be more sophisticated for production. + +3. **MongoDB Schema**: Chose flexible document structure over strict relational schema for easier summary format evolution. + +4. **Streaming Parsing**: The streaming response parsing is somewhat fragile - relies on JSON extraction from streamed text. + +### What I'd Do With More Time + +- Add comprehensive unit and integration tests +- Implement user authentication and authorization +- Add summary editing and deletion capabilities +- Improve streaming response parsing robustness +- Add summary search and filtering +- Implement summary export to PDF/Word formats +- Add analytics and usage tracking +- Implement summary categorization and tagging + +## 4. AI Usage Log + +I extensively used AI programming assistants (Claude) throughout this project: + +### Backend Development +- **Spring Boot Setup**: Used AI to generate the initial project structure and Maven dependencies +- **Reactive Programming**: Got help implementing WebFlux streaming endpoints, particularly the `Flux` return types +- **Gemini Integration**: AI helped with HTTP client configuration and JSON request/response handling +- **Database Modeling**: Assistance with MongoDB repository setup and entity relationships + +### Frontend Development +- **Vue.js 3 Components**: AI helped with Vue composition API patterns and Element Plus component integration +- **Streaming Implementation**: Got significant help implementing the fetch-based streaming response handling +- **CSS Styling**: AI assisted with responsive layout and professional styling +- **State Management**: Help with reactive data binding and component state management + +### API Integration +- **CORS Configuration**: AI helped resolve cross-origin issues between frontend and backend +- **Error Handling**: Assistance with proper error propagation and user feedback +- **Stream Processing**: Significant help parsing Server-Sent Events format in the frontend + +### Prompt Engineering +- **Gemini Prompt Design**: AI helped craft the prompt to ensure consistent JSON output format from Gemini API +- **Response Parsing**: Assistance with robust JSON extraction from AI responses + +### Key AI Contributions +- Approximately 60% of boilerplate code generation +- 80% of complex reactive programming patterns +- 90% of streaming implementation logic +- 70% of error handling patterns +- 50% of UI component structure + +The AI assistance was crucial for implementing the streaming features, which I hadn't worked with extensively before. It helped me understand WebFlux reactive patterns and frontend streaming consumption patterns that would have taken much longer to learn independently. \ No newline at end of file diff --git a/backend/meeting-summary.iml b/backend/meeting-summary.iml new file mode 100644 index 0000000..2a7d2b7 --- /dev/null +++ b/backend/meeting-summary.iml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..23567bd --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.work4u + meeting-summary + 1.0.0 + meeting-summary + AI Meeting Summary Service + + + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/backend/src/example.py b/backend/src/example.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/main/java/com/work4u/meetingsummary/MeetingSummaryApplication.java b/backend/src/main/java/com/work4u/meetingsummary/MeetingSummaryApplication.java new file mode 100644 index 0000000..73cc0df --- /dev/null +++ b/backend/src/main/java/com/work4u/meetingsummary/MeetingSummaryApplication.java @@ -0,0 +1,19 @@ +package com.work4u.meetingsummary; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.web.reactive.function.client.WebClient; + +@SpringBootApplication +public class MeetingSummaryApplication { + + public static void main(String[] args) { + SpringApplication.run(MeetingSummaryApplication.class, args); + } + + @Bean + public WebClient.Builder webClientBuilder() { + return WebClient.builder(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/work4u/meetingsummary/controller/MeetingSummaryController.java b/backend/src/main/java/com/work4u/meetingsummary/controller/MeetingSummaryController.java new file mode 100644 index 0000000..b913536 --- /dev/null +++ b/backend/src/main/java/com/work4u/meetingsummary/controller/MeetingSummaryController.java @@ -0,0 +1,98 @@ +package com.work4u.meetingsummary.controller; + +import com.work4u.meetingsummary.dto.SummaryRequest; +import com.work4u.meetingsummary.dto.SummaryResponse; +import com.work4u.meetingsummary.model.MeetingSummary; +import com.work4u.meetingsummary.service.MeetingSummaryService; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; + +@RestController +@RequestMapping("/api/summaries") +@CrossOrigin(origins = "*") +public class MeetingSummaryController { + + private static final Logger logger = LoggerFactory.getLogger(MeetingSummaryController.class); + + @Autowired + private MeetingSummaryService summaryService; + + @PostMapping + public Mono> generateSummary(@Valid @RequestBody SummaryRequest request) { + logger.info("Generating summary for transcript of length: {}", request.getTranscript().length()); + + return summaryService.generateSummary(request.getTranscript()) + .map(summary -> { + logger.info("Successfully generated summary with publicId: {}", summary.getPublicId()); + return ResponseEntity.ok(new SummaryResponse(summary)); + }) + .onErrorResume(error -> { + logger.error("Error generating summary: ", error); + return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); + }); + } + + @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux generateSummaryStream(@Valid @RequestBody SummaryRequest request) { + logger.info("Starting streaming summary generation for transcript of length: {}", request.getTranscript().length()); + + return summaryService.generateSummaryStream(request.getTranscript()) + .map(chunk -> "data: " + chunk + "\n\n") + .onErrorReturn("data: [ERROR] 生成摘要时发生错误\n\n") + .doOnComplete(() -> logger.info("Streaming summary generation completed")) + .doOnError(error -> logger.error("Error in streaming summary generation: ", error)); + } + + @GetMapping + public ResponseEntity> getAllSummaries() { + try { + List summaries = summaryService.getAllSummaries(); + List responses = summaries.stream() + .map(SummaryResponse::new) + .toList(); + logger.info("Retrieved {} summaries", summaries.size()); + return ResponseEntity.ok(responses); + } catch (Exception e) { + logger.error("Error retrieving all summaries: ", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @GetMapping("/{id}") + public ResponseEntity getSummaryById(@PathVariable String id) { + logger.info("Retrieving summary by ID: {}", id); + return summaryService.getSummaryById(id) + .map(summary -> { + logger.info("Found summary with ID: {}", id); + return ResponseEntity.ok(new SummaryResponse(summary)); + }) + .orElseGet(() -> { + logger.warn("Summary not found with ID: {}", id); + return ResponseEntity.notFound().build(); + }); + } + + @GetMapping("/public/{publicId}") + public ResponseEntity getSummaryByPublicId(@PathVariable String publicId) { + logger.info("Retrieving summary by public ID: {}", publicId); + return summaryService.getSummaryByPublicId(publicId) + .map(summary -> { + logger.info("Found summary with public ID: {}", publicId); + return ResponseEntity.ok(new SummaryResponse(summary)); + }) + .orElseGet(() -> { + logger.warn("Summary not found with public ID: {}", publicId); + return ResponseEntity.notFound().build(); + }); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/work4u/meetingsummary/dto/SummaryRequest.java b/backend/src/main/java/com/work4u/meetingsummary/dto/SummaryRequest.java new file mode 100644 index 0000000..d3e76d6 --- /dev/null +++ b/backend/src/main/java/com/work4u/meetingsummary/dto/SummaryRequest.java @@ -0,0 +1,23 @@ +package com.work4u.meetingsummary.dto; + +import jakarta.validation.constraints.NotBlank; + +public class SummaryRequest { + + @NotBlank(message = "Transcript cannot be empty") + private String transcript; + + public SummaryRequest() {} + + public SummaryRequest(String transcript) { + this.transcript = transcript; + } + + public String getTranscript() { + return transcript; + } + + public void setTranscript(String transcript) { + this.transcript = transcript; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/work4u/meetingsummary/dto/SummaryResponse.java b/backend/src/main/java/com/work4u/meetingsummary/dto/SummaryResponse.java new file mode 100644 index 0000000..7aa9a47 --- /dev/null +++ b/backend/src/main/java/com/work4u/meetingsummary/dto/SummaryResponse.java @@ -0,0 +1,104 @@ +package com.work4u.meetingsummary.dto; + +import com.work4u.meetingsummary.model.MeetingSummary; +import java.time.LocalDateTime; +import java.util.List; + +public class SummaryResponse { + private String id; + private String publicId; + private String overview; + private List keyDecisions; + private List actionItems; + private LocalDateTime createdAt; + + public SummaryResponse() {} + + public SummaryResponse(MeetingSummary summary) { + this.id = summary.getId(); + this.publicId = summary.getPublicId(); + this.overview = summary.getOverview(); + this.keyDecisions = summary.getKeyDecisions(); + this.actionItems = summary.getActionItems().stream() + .map(item -> new ActionItemDto(item.getTask(), item.getAssignee())) + .toList(); + this.createdAt = summary.getCreatedAt(); + } + + // Getters and Setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPublicId() { + return publicId; + } + + public void setPublicId(String publicId) { + this.publicId = publicId; + } + + public String getOverview() { + return overview; + } + + public void setOverview(String overview) { + this.overview = overview; + } + + public List getKeyDecisions() { + return keyDecisions; + } + + public void setKeyDecisions(List keyDecisions) { + this.keyDecisions = keyDecisions; + } + + public List getActionItems() { + return actionItems; + } + + public void setActionItems(List actionItems) { + this.actionItems = actionItems; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public static class ActionItemDto { + private String task; + private String assignee; + + public ActionItemDto() {} + + public ActionItemDto(String task, String assignee) { + this.task = task; + this.assignee = assignee; + } + + public String getTask() { + return task; + } + + public void setTask(String task) { + this.task = task; + } + + public String getAssignee() { + return assignee; + } + + public void setAssignee(String assignee) { + this.assignee = assignee; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/work4u/meetingsummary/model/MeetingSummary.java b/backend/src/main/java/com/work4u/meetingsummary/model/MeetingSummary.java new file mode 100644 index 0000000..ef85a60 --- /dev/null +++ b/backend/src/main/java/com/work4u/meetingsummary/model/MeetingSummary.java @@ -0,0 +1,165 @@ +package com.work4u.meetingsummary.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import java.time.LocalDateTime; +import java.util.List; + +@Document(collection = "meeting_summaries") +public class MeetingSummary { + + @Id + private String id; + + private String publicId; // UUID for sharing + + private String originalTranscript; + + private String overview; + + private List keyDecisions; + + private List actionItems; + + private LocalDateTime createdAt; + + // Constructors + public MeetingSummary() { + this.createdAt = LocalDateTime.now(); + } + + public MeetingSummary(String publicId, String originalTranscript, String overview, + List keyDecisions, List actionItems) { + this(); + this.publicId = publicId; + this.originalTranscript = originalTranscript; + this.overview = overview; + this.keyDecisions = keyDecisions; + this.actionItems = actionItems; + } + + // Getters and Setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPublicId() { + return publicId; + } + + public void setPublicId(String publicId) { + this.publicId = publicId; + } + + public String getOriginalTranscript() { + return originalTranscript; + } + + public void setOriginalTranscript(String originalTranscript) { + this.originalTranscript = originalTranscript; + } + + public String getOverview() { + return overview; + } + + public void setOverview(String overview) { + this.overview = overview; + } + + public List getKeyDecisions() { + return keyDecisions; + } + + public void setKeyDecisions(List keyDecisions) { + this.keyDecisions = keyDecisions; + } + + public List getActionItems() { + return actionItems; + } + + public void setActionItems(List actionItems) { + this.actionItems = actionItems; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + // Inner class for Action Items + public static class ActionItem { + private String task; + private String assignee; + + public ActionItem() {} + + public ActionItem(String task, String assignee) { + this.task = task; + this.assignee = assignee; + } + + public String getTask() { + return task; + } + + public void setTask(String task) { + this.task = task; + } + + public String getAssignee() { + return assignee; + } + + public void setAssignee(String assignee) { + this.assignee = assignee; + } + } + + // Inner class for parsed summary from AI + public static class ParsedSummary { + private String overview; + private List keyDecisions; + private List actionItems; + + public ParsedSummary() {} + + public ParsedSummary(String overview, List keyDecisions, List actionItems) { + this.overview = overview; + this.keyDecisions = keyDecisions; + this.actionItems = actionItems; + } + + public String getOverview() { + return overview; + } + + public void setOverview(String overview) { + this.overview = overview; + } + + public List getKeyDecisions() { + return keyDecisions; + } + + public void setKeyDecisions(List keyDecisions) { + this.keyDecisions = keyDecisions; + } + + public List getActionItems() { + return actionItems; + } + + public void setActionItems(List actionItems) { + this.actionItems = actionItems; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/work4u/meetingsummary/repository/MeetingSummaryRepository.java b/backend/src/main/java/com/work4u/meetingsummary/repository/MeetingSummaryRepository.java new file mode 100644 index 0000000..2582f7c --- /dev/null +++ b/backend/src/main/java/com/work4u/meetingsummary/repository/MeetingSummaryRepository.java @@ -0,0 +1,11 @@ +package com.work4u.meetingsummary.repository; + +import com.work4u.meetingsummary.model.MeetingSummary; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface MeetingSummaryRepository extends MongoRepository { + Optional findByPublicId(String publicId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/work4u/meetingsummary/service/GeminiService.java b/backend/src/main/java/com/work4u/meetingsummary/service/GeminiService.java new file mode 100644 index 0000000..b97ce55 --- /dev/null +++ b/backend/src/main/java/com/work4u/meetingsummary/service/GeminiService.java @@ -0,0 +1,208 @@ +package com.work4u.meetingsummary.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.work4u.meetingsummary.model.MeetingSummary; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class GeminiService { + + private static final Logger logger = LoggerFactory.getLogger(GeminiService.class); + + private final WebClient webClient; + private final ObjectMapper objectMapper; + + @Value("${gemini.api.key}") + private String apiKey; + + @Value("${gemini.api.url:https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent}") + private String apiUrl; + + public GeminiService(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) { + // 配置HttpClient超时 + HttpClient httpClient = HttpClient.create() + .responseTimeout(Duration.ofSeconds(120)) // 120秒响应超时 + .followRedirect(true); + + this.webClient = webClientBuilder + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) // 10MB + .build(); + this.objectMapper = objectMapper; + } + + public Mono generateSummary(String transcript) { + logger.info("Generating summary for transcript of length: {}", transcript.length()); + String prompt = buildPrompt(transcript); + + Map request = Map.of( + "contents", List.of( + Map.of("parts", List.of( + Map.of("text", prompt) + )) + ), + "generationConfig", Map.of( + "temperature", 0.3, + "maxOutputTokens", 2048 + ) + ); + + return webClient.post() + .uri(apiUrl) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header("X-goog-api-key", apiKey) + .bodyValue(request) + .retrieve() + .bodyToMono(String.class) + .timeout(Duration.ofSeconds(120)) // 应用级超时 + .doOnNext(response -> logger.debug("Received Gemini response: {}", response)) + .map(this::parseSummaryResponse) + .doOnSuccess(parsed -> logger.info("Successfully parsed summary with {} key decisions and {} action items", + parsed.getKeyDecisions().size(), parsed.getActionItems().size())) + .doOnError(error -> logger.error("Error in Gemini API call: ", error)); + } + + public Flux generateSummaryStream(String transcript) { + logger.info("Starting streaming summary generation for transcript of length: {}", transcript.length()); + String prompt = buildPrompt(transcript); + + Map request = Map.of( + "contents", List.of( + Map.of("parts", List.of( + Map.of("text", prompt) + )) + ), + "generationConfig", Map.of( + "temperature", 0.3, + "maxOutputTokens", 2048 + ) + ); + + String streamUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:streamGenerateContent"; + + return webClient.post() + .uri(streamUrl) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header("X-goog-api-key", apiKey) + .bodyValue(request) + .retrieve() + .bodyToFlux(String.class) + .timeout(Duration.ofSeconds(120)) // 应用级超时 + .map(this::extractTextFromStreamResponse) + .filter(text -> !text.isEmpty()) + .doOnNext(chunk -> logger.debug("Streaming chunk: {}", chunk.substring(0, Math.min(50, chunk.length())))) + .doOnComplete(() -> logger.info("Streaming generation completed")) + .doOnError(error -> logger.error("Error in streaming generation: ", error)); + } + + private String buildPrompt(String transcript) { + return String.format(""" + 请分析以下会议记录并生成结构化摘要。请严格按照以下JSON格式返回结果: + + { + "overview": "会议的简短概述(1-2句话)", + "keyDecisions": ["关键决定1", "关键决定2", "..."], + "actionItems": [ + {"task": "任务描述", "assignee": "负责人"}, + {"task": "任务描述", "assignee": "负责人"} + ] + } + + 会议记录: + %s + + 请确保: + 1. 概述简洁明了 + 2. 关键决定明确具体 + 3. 行动项目包含具体任务和负责人 + 4. 响应必须是有效的JSON格式 + """, transcript); + } + + private MeetingSummary.ParsedSummary parseSummaryResponse(String response) { + try { + JsonNode root = objectMapper.readTree(response); + String content = root.path("candidates") + .path(0) + .path("content") + .path("parts") + .path(0) + .path("text") + .asText(); + + logger.debug("Extracting JSON from content: {}", content.substring(0, Math.min(200, content.length()))); + + // Extract JSON from the content + int jsonStart = content.indexOf("{"); + int jsonEnd = content.lastIndexOf("}") + 1; + + if (jsonStart >= 0 && jsonEnd > jsonStart) { + String jsonContent = content.substring(jsonStart, jsonEnd); + logger.debug("Extracted JSON: {}", jsonContent); + JsonNode summaryJson = objectMapper.readTree(jsonContent); + + String overview = summaryJson.path("overview").asText(); + if (overview.isEmpty()) { + throw new RuntimeException("摘要概述为空"); + } + + List keyDecisions = new ArrayList<>(); + summaryJson.path("keyDecisions").forEach(node -> + keyDecisions.add(node.asText())); + + List actionItems = new ArrayList<>(); + summaryJson.path("actionItems").forEach(node -> { + String task = node.path("task").asText(); + String assignee = node.path("assignee").asText(); + if (!task.isEmpty()) { + actionItems.add(new MeetingSummary.ActionItem(task, assignee)); + } + }); + + return new MeetingSummary.ParsedSummary(overview, keyDecisions, actionItems); + } + + throw new RuntimeException("无法从AI响应中提取JSON内容"); + + } catch (Exception e) { + logger.error("Failed to parse AI response: {}", response, e); + throw new RuntimeException("解析AI响应失败: " + e.getMessage(), e); + } + } + + private String extractTextFromStreamResponse(String chunk) { + try { + if (chunk.trim().isEmpty()) { + return ""; + } + + JsonNode root = objectMapper.readTree(chunk); + return root.path("candidates") + .path(0) + .path("content") + .path("parts") + .path(0) + .path("text") + .asText(); + } catch (Exception e) { + logger.warn("Failed to parse streaming chunk: {}", chunk, e); + return ""; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/work4u/meetingsummary/service/MeetingSummaryService.java b/backend/src/main/java/com/work4u/meetingsummary/service/MeetingSummaryService.java new file mode 100644 index 0000000..a37ec26 --- /dev/null +++ b/backend/src/main/java/com/work4u/meetingsummary/service/MeetingSummaryService.java @@ -0,0 +1,55 @@ +package com.work4u.meetingsummary.service; + +import com.work4u.meetingsummary.model.MeetingSummary; +import com.work4u.meetingsummary.repository.MeetingSummaryRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class MeetingSummaryService { + + @Autowired + private MeetingSummaryRepository repository; + + @Autowired + private GeminiService geminiService; + + public Mono generateSummary(String transcript) { + String publicId = UUID.randomUUID().toString(); + + return geminiService.generateSummary(transcript) + .map(parsedSummary -> { + MeetingSummary summary = new MeetingSummary( + publicId, + transcript, + parsedSummary.getOverview(), + parsedSummary.getKeyDecisions(), + parsedSummary.getActionItems() + ); + return summary; + }) + .map(summary -> repository.save(summary)); + } + + public Flux generateSummaryStream(String transcript) { + return geminiService.generateSummaryStream(transcript); + } + + public List getAllSummaries() { + return repository.findAll(); + } + + public Optional getSummaryByPublicId(String publicId) { + return repository.findByPublicId(publicId); + } + + public Optional getSummaryById(String id) { + return repository.findById(id); + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..0dfb99d --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,31 @@ +server: + port: 8080 + +spring: + data: + mongodb: + host: yourmongohost + port: 27001 + database: meeting_summary + username: username + password: password + authentication-database: admin + + application: + name: meeting-summary + + # 增加异步请求超时配置 + mvc: + async: + request-timeout: 120000 # 120秒超时 + +# Gemini API Configuration +gemini: + api: + key: your-key + url: https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent + +logging: + level: + com.work4u.meetingsummary: DEBUG + org.springframework.web: DEBUG \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fcd3d7b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "meeting-summary-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "serve": "vue-cli-service serve", + "build": "vue-cli-service build", + "lint": "vue-cli-service lint" + }, + "dependencies": { + "vue": "^3.3.4", + "vue-router": "^4.2.4", + "axios": "^1.5.0", + "element-plus": "^2.3.12", + "@element-plus/icons-vue": "^2.1.0" + }, + "devDependencies": { + "@vue/cli-plugin-router": "~5.0.0", + "@vue/cli-service": "~5.0.0", + "@vue/compiler-sfc": "^3.3.4", + "typescript": "~5.1.6" + }, + "browserslist": [ + "> 1%", + "last 2 versions", + "not dead", + "not ie 11" + ] +} \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..4c7c4f1 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,33 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/example.ts b/frontend/src/example.ts deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..0cbcc41 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,16 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +const app = createApp(App) + +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(ElementPlus) +app.use(router) +app.mount('#app') \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..e683d07 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,24 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Home from '../views/Home.vue' +import Summary from '../views/Summary.vue' + +const routes = [ + { + path: '/', + name: 'Home', + component: Home + }, + { + path: '/summary/:publicId', + name: 'Summary', + component: Summary, + props: true + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +export default router \ No newline at end of file diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..14564d3 --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,96 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + timeout: 30000 // 30 seconds timeout +}) + +// Add request interceptor for logging +api.interceptors.request.use( + config => { + console.log('API Request:', config.method?.toUpperCase(), config.url) + return config + }, + error => { + console.error('API Request Error:', error) + return Promise.reject(error) + } +) + +// Add response interceptor for error handling +api.interceptors.response.use( + response => { + console.log('API Response:', response.status, response.config.url) + return response + }, + error => { + console.error('API Response Error:', error.response?.status, error.response?.data) + return Promise.reject(error) + } +) + +export const summaryApi = { + generateSummary(transcript) { + return api.post('/summaries', { transcript }) + }, + + getAllSummaries() { + return api.get('/summaries') + }, + + getSummaryByPublicId(publicId) { + return api.get(`/summaries/public/${publicId}`) + }, + + generateSummaryStream(transcript, onData, onError) { + // Since EventSource doesn't support POST, we'll use fetch for streaming + return fetch('/api/summaries/stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }, + body: JSON.stringify({ transcript }) + }).then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + function read() { + return reader.read().then(({ done, value }) => { + if (done) { + console.log('Streaming completed') + return + } + + const chunk = decoder.decode(value, { stream: true }) + const lines = chunk.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6) + if (data.trim() && !data.includes('[ERROR]')) { + onData(data) + } else if (data.includes('[ERROR]')) { + onError(new Error('Streaming generation failed')) + return + } + } + } + + return read() + }) + } + + return read() + }).catch(error => { + console.error('Streaming error:', error) + onError(error) + }) + } +} + +export default api \ No newline at end of file diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 0000000..78b39f8 --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,341 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Summary.vue b/frontend/src/views/Summary.vue new file mode 100644 index 0000000..05e26be --- /dev/null +++ b/frontend/src/views/Summary.vue @@ -0,0 +1,173 @@ + + + + + \ No newline at end of file diff --git a/frontend/vue.config.js b/frontend/vue.config.js new file mode 100644 index 0000000..892961e --- /dev/null +++ b/frontend/vue.config.js @@ -0,0 +1,13 @@ +const { defineConfig } = require('@vue/cli-service') +module.exports = defineConfig({ + transpileDependencies: true, + devServer: { + port: 8081, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + } +}) \ No newline at end of file diff --git a/global.sh b/global.sh new file mode 100644 index 0000000..3a2217a --- /dev/null +++ b/global.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# 获取 PNPM_HOME +PNPM_HOME_DEFAULT="/c/Users/$(whoami)/AppData/Local/pnpm" + +echo "🔧 修复 pnpm 全局路径配置..." +echo "📌 设置 PNPM_HOME 为: $PNPM_HOME_DEFAULT" + +# 检查 ~/.bashrc 是否已包含 PNPM_HOME +if grep -q "PNPM_HOME" ~/.bashrc; then + echo "✅ ~/.bashrc 已包含 PNPM_HOME,无需重复添加。" +else + echo -e "\n# PNPM 设置" >> ~/.bashrc + echo "export PNPM_HOME=\"$PNPM_HOME_DEFAULT\"" >> ~/.bashrc + echo "export PATH=\"\$PNPM_HOME:\$PATH\"" >> ~/.bashrc + echo "✅ 已将 PNPM_HOME 添加到 ~/.bashrc" +fi + +# 设置 pnpm 的 global-bin-dir +pnpm config set global-bin-dir "$PNPM_HOME_DEFAULT" + +# 立即导入环境变量(无需重启) +export PNPM_HOME="$PNPM_HOME_DEFAULT" +export PATH="$PNPM_HOME:$PATH" + +# 验证结果 +echo "📂 当前 PNPM_HOME: $PNPM_HOME" +echo "🔎 当前 PATH 包含 PNPM_HOME 吗?" +echo "$PATH" | grep "$PNPM_HOME" && echo "✅ 包含" || echo "❌ 不包含" + +# 检查 pnpm 命令是否正常 +if command -v pnpm &> /dev/null; then + echo "🚀 pnpm 命令可用,版本为:$(pnpm -v)" +else + echo "❌ pnpm 命令仍不可用,请检查环境变量配置。" +fi + +echo "🎉 完成!可以重新运行:pnpm install -g @anthropic-ai/claude-code"