wip: Add page tools to AI chatter

This commit is contained in:
Robin Shen 2025-12-01 10:15:44 +08:00
parent b3d207a835
commit 8554de9e13
23 changed files with 934 additions and 416 deletions

View File

@ -629,7 +629,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>dev.langchain4j</groupId> <groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-core</artifactId> <artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version> <version>${langchain4j.version}</version>
</dependency> </dependency>
<dependency> <dependency>

View File

@ -385,7 +385,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>dev.langchain4j</groupId> <groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-core</artifactId> <artifactId>langchain4j</artifactId>
</dependency> </dependency>
</dependencies> </dependencies>
<properties> <properties>

View File

@ -0,0 +1,90 @@
package io.onedev.server.ai;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.onedev.server.OneDev;
import io.onedev.server.model.Issue;
import io.onedev.server.model.IssueComment;
import io.onedev.server.model.Project;
import io.onedev.server.web.UrlService;
public class IssueHelper {
private static ObjectMapper getObjectMapper() {
return OneDev.getInstance(ObjectMapper.class);
}
private static UrlService getUrlService() {
return OneDev.getInstance(UrlService.class);
}
public static Map<String, Object> getSummary(Project currentProject, Issue issue) {
var typeReference = new TypeReference<LinkedHashMap<String, Object>>() {};
var summary = getObjectMapper().convertValue(issue, typeReference);
summary.remove("id");
summary.remove("stateOrdinal");
summary.remove("uuid");
summary.remove("messageId");
summary.remove("pinDate");
summary.remove("boardPosition");
summary.remove("numberScopeId");
summary.put("reference", issue.getReference().toString(currentProject));
summary.remove("submitterId");
summary.put("submitter", issue.getSubmitter().getName());
summary.put("Project", issue.getProject().getPath());
summary.remove("lastActivity");
for (var it = summary.entrySet().iterator(); it.hasNext();) {
var entry = it.next();
if (entry.getKey().endsWith("Count"))
it.remove();
}
return summary;
}
public static List<Map<String, Object>> getComments(Issue issue) {
var comments = new ArrayList<Map<String, Object>>();
issue.getComments().stream().sorted(Comparator.comparing(IssueComment::getId)).forEach(comment -> {
var commentMap = new HashMap<String, Object>();
commentMap.put("user", comment.getUser().getName());
commentMap.put("date", comment.getDate());
commentMap.put("content", comment.getContent());
comments.add(commentMap);
});
return comments;
}
public static Map<String, Object> getDetail(Project currentProject, Issue issue) {
var detail = getSummary(currentProject, issue);
for (var entry : issue.getFieldInputs().entrySet()) {
detail.put(entry.getKey(), entry.getValue().getValues());
}
Map<String, Collection<String>> linkedIssues = new HashMap<>();
for (var link: issue.getTargetLinks()) {
linkedIssues.computeIfAbsent(link.getSpec().getName(), k -> new ArrayList<>())
.add(link.getTarget().getReference().toString(currentProject));
}
for (var link : issue.getSourceLinks()) {
if (link.getSpec().getOpposite() != null) {
linkedIssues.computeIfAbsent(link.getSpec().getOpposite().getName(), k -> new ArrayList<>())
.add(link.getSource().getReference().toString(currentProject));
} else {
linkedIssues.computeIfAbsent(link.getSpec().getName(), k -> new ArrayList<>())
.add(link.getSource().getReference().toString(currentProject));
}
}
detail.putAll(linkedIssues);
detail.put("link", getUrlService().urlFor(issue, true));
return detail;
}
}

View File

@ -10,7 +10,6 @@ import java.nio.charset.StandardCharsets;
import java.text.MessageFormat; import java.text.MessageFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@ -709,16 +708,16 @@ public class McpHelperResource {
parsedQuery = new IssueQuery(); parsedQuery = new IssueQuery();
} }
var issues = new ArrayList<Map<String, Object>>(); var summaries = new ArrayList<Map<String, Object>>();
for (var issue : issueService.query(subject, new ProjectScope(projectInfo.project, true, false), parsedQuery, true, offset, count)) { for (var issue : issueService.query(subject, new ProjectScope(projectInfo.project, true, false), parsedQuery, true, offset, count)) {
var issueMap = getIssueMap(projectInfo.currentProject, issue); var summary = IssueHelper.getSummary(projectInfo.currentProject, issue);
for (var entry: issue.getFieldInputs().entrySet()) { for (var entry: issue.getFieldInputs().entrySet()) {
issueMap.put(entry.getKey(), entry.getValue().getValues()); summary.put(entry.getKey(), entry.getValue().getValues());
} }
issueMap.put("link", urlService.urlFor(issue, true)); summary.put("link", urlService.urlFor(issue, true));
issues.add(issueMap); summaries.add(summary);
} }
return issues; return summaries;
} }
private Issue getIssue(Project currentProject, String referenceString) { private Issue getIssue(Project currentProject, String referenceString) {
@ -732,29 +731,6 @@ public class McpHelperResource {
throw new NotFoundException("Issue not found: " + referenceString); throw new NotFoundException("Issue not found: " + referenceString);
} }
} }
private Map<String, Object> getIssueMap(Project currentProject, Issue issue) {
var typeReference = new TypeReference<LinkedHashMap<String, Object>>() {};
var issueMap = objectMapper.convertValue(issue, typeReference);
issueMap.remove("id");
issueMap.remove("stateOrdinal");
issueMap.remove("uuid");
issueMap.remove("messageId");
issueMap.remove("pinDate");
issueMap.remove("boardPosition");
issueMap.remove("numberScopeId");
issueMap.put("reference", issue.getReference().toString(currentProject));
issueMap.remove("submitterId");
issueMap.put("submitter", issue.getSubmitter().getName());
issueMap.put("Project", issue.getProject().getPath());
issueMap.remove("lastActivity");
for (var it = issueMap.entrySet().iterator(); it.hasNext();) {
var entry = it.next();
if (entry.getKey().endsWith("Count"))
it.remove();
}
return issueMap;
}
@Path("/get-issue") @Path("/get-issue")
@GET @GET
@ -765,31 +741,8 @@ public class McpHelperResource {
throw new UnauthenticatedException(); throw new UnauthenticatedException();
var currentProject = getProject(currentProjectPath); var currentProject = getProject(currentProjectPath);
var issue = getIssue(currentProject, issueReference); var issue = getIssue(currentProject, issueReference);
return IssueHelper.getDetail(currentProject, issue);
var issueMap = getIssueMap(currentProject, issue);
for (var entry : issue.getFieldInputs().entrySet()) {
issueMap.put(entry.getKey(), entry.getValue().getValues());
}
Map<String, Collection<String>> linkedIssues = new HashMap<>();
for (var link: issue.getTargetLinks()) {
linkedIssues.computeIfAbsent(link.getSpec().getName(), k -> new ArrayList<>())
.add(link.getTarget().getReference().toString(currentProject));
}
for (var link : issue.getSourceLinks()) {
if (link.getSpec().getOpposite() != null) {
linkedIssues.computeIfAbsent(link.getSpec().getOpposite().getName(), k -> new ArrayList<>())
.add(link.getSource().getReference().toString(currentProject));
} else {
linkedIssues.computeIfAbsent(link.getSpec().getName(), k -> new ArrayList<>())
.add(link.getSource().getReference().toString(currentProject));
}
}
issueMap.putAll(linkedIssues);
issueMap.put("link", urlService.urlFor(issue, true));
return issueMap;
} }
@Path("/get-issue-comments") @Path("/get-issue-comments")
@ -799,20 +752,9 @@ public class McpHelperResource {
@QueryParam("reference") @NotNull String issueReference) { @QueryParam("reference") @NotNull String issueReference) {
if (SecurityUtils.getUser() == null) if (SecurityUtils.getUser() == null)
throw new UnauthenticatedException(); throw new UnauthenticatedException();
var currentProject = getProject(currentProjectPath); var currentProject = getProject(currentProjectPath);
var issue = getIssue(currentProject, issueReference); var issue = getIssue(currentProject, issueReference);
return IssueHelper.getComments(issue);
var comments = new ArrayList<Map<String, Object>>();
for (var comment : issue.getComments()) {
var commentMap = new HashMap<String, Object>();
commentMap.put("user", comment.getUser().getName());
commentMap.put("date", comment.getDate());
commentMap.put("content", comment.getContent());
comments.add(commentMap);
}
return comments;
} }
@Path("/add-issue-comment") @Path("/add-issue-comment")
@ -1093,40 +1035,6 @@ public class McpHelperResource {
} }
} }
private Map<String, Object> getPullRequestMap(Project currentProject,
PullRequest pullRequest, boolean checkMergeConditionIfOpen) {
var typeReference = new TypeReference<LinkedHashMap<String, Object>>() {};
var pullRequestMap = objectMapper.convertValue(pullRequest, typeReference);
pullRequestMap.remove("id");
if (pullRequest.isOpen() && checkMergeConditionIfOpen) {
var errorMessage = pullRequest.checkMergeCondition();
if (errorMessage != null)
pullRequestMap.put("status", PullRequest.Status.OPEN.name() + " (" + errorMessage + ")");
else
pullRequestMap.put("status", PullRequest.Status.OPEN.name() + " (ready to merge)");
}
pullRequestMap.remove("uuid");
pullRequestMap.remove("buildCommitHash");
pullRequestMap.remove("submitTimeGroups");
pullRequestMap.remove("closeTimeGroups");
pullRequestMap.remove("checkError");
pullRequestMap.remove("numberScopeId");
pullRequestMap.put("reference", pullRequest.getReference().toString(currentProject));
pullRequestMap.remove("submitterId");
pullRequestMap.put("submitter", pullRequest.getSubmitter().getName());
pullRequestMap.put("targetProject", pullRequest.getTarget().getProject().getPath());
if (pullRequest.getSourceProject() != null)
pullRequestMap.put("sourceProject", pullRequest.getSourceProject().getPath());
pullRequestMap.remove("codeCommentsUpdateDate");
pullRequestMap.remove("lastActivity");
for (var it = pullRequestMap.entrySet().iterator(); it.hasNext();) {
var entry = it.next();
if (entry.getKey().endsWith("Count"))
it.remove();
}
return pullRequestMap;
}
@Path("/query-pull-requests") @Path("/query-pull-requests")
@GET @GET
public List<Map<String, Object>> queryPullRequests( public List<Map<String, Object>> queryPullRequests(
@ -1154,13 +1062,13 @@ public class McpHelperResource {
parsedQuery = new PullRequestQuery(); parsedQuery = new PullRequestQuery();
} }
var pullRequests = new ArrayList<Map<String, Object>>(); var summaries = new ArrayList<Map<String, Object>>();
for (var pullRequest : pullRequestService.query(subject, projectInfo.project, parsedQuery, false, offset, count)) { for (var pullRequest : pullRequestService.query(subject, projectInfo.project, parsedQuery, false, offset, count)) {
var pullRequestMap = getPullRequestMap(projectInfo.currentProject, pullRequest, false); var summary = PullRequestHelper.getSummary(projectInfo.currentProject, pullRequest, false);
pullRequestMap.put("link", urlService.urlFor(pullRequest, true)); summary.put("link", urlService.urlFor(pullRequest, true));
pullRequests.add(pullRequestMap); summaries.add(summary);
} }
return pullRequests; return summaries;
} }
@Path("/query-builds") @Path("/query-builds")
@ -1249,31 +1157,8 @@ public class McpHelperResource {
throw new UnauthenticatedException(); throw new UnauthenticatedException();
var currentProject = getProject(currentProjectPath); var currentProject = getProject(currentProjectPath);
var pullRequest = getPullRequest(currentProject, pullRequestReference); var pullRequest = getPullRequest(currentProject, pullRequestReference);
return PullRequestHelper.getDetail(currentProject, pullRequest);
var pullRequestMap = getPullRequestMap(currentProject, pullRequest, true);
pullRequestMap.put("headCommitHash", pullRequest.getLatestUpdate().getHeadCommitHash());
pullRequestMap.put("assignees", pullRequest.getAssignees().stream().map(it->it.getName()).collect(Collectors.toList()));
var reviews = new ArrayList<Map<String, Object>>();
for (var review : pullRequest.getReviews()) {
if (review.getStatus() == PullRequestReview.Status.EXCLUDED)
continue;
var reviewMap = new HashMap<String, Object>();
reviewMap.put("reviewer", review.getUser().getName());
reviewMap.put("status", review.getStatus());
reviews.add(reviewMap);
}
pullRequestMap.put("reviews", reviews);
var builds = new ArrayList<String>();
for (var build : pullRequest.getBuilds()) {
builds.add(build.getReference().toString(currentProject) + " (job: " + build.getJobName() + ", status: " + build.getStatus() + ")");
}
pullRequestMap.put("builds", builds);
pullRequestMap.put("labels", pullRequest.getLabels().stream().map(it->it.getSpec().getName()).collect(Collectors.toList()));
pullRequestMap.put("link", urlService.urlFor(pullRequest, true));
return pullRequestMap;
} }
@Path("/get-pull-request-comments") @Path("/get-pull-request-comments")
@ -1287,15 +1172,7 @@ public class McpHelperResource {
var currentProject = getProject(currentProjectPath); var currentProject = getProject(currentProjectPath);
var pullRequest = getPullRequest(currentProject, pullRequestReference); var pullRequest = getPullRequest(currentProject, pullRequestReference);
var comments = new ArrayList<Map<String, Object>>(); return PullRequestHelper.getComments(pullRequest);
for (var comment : pullRequest.getComments()) {
var commentMap = new HashMap<String, Object>();
commentMap.put("user", comment.getUser().getName());
commentMap.put("date", comment.getDate());
commentMap.put("content", comment.getContent());
comments.add(commentMap);
}
return comments;
} }
@Path("/get-pull-request-code-comments") @Path("/get-pull-request-code-comments")

View File

@ -0,0 +1,102 @@
package io.onedev.server.ai;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.onedev.server.OneDev;
import io.onedev.server.model.Project;
import io.onedev.server.model.PullRequest;
import io.onedev.server.model.PullRequestComment;
import io.onedev.server.model.PullRequestReview;
import io.onedev.server.web.UrlService;
public class PullRequestHelper {
private static ObjectMapper getObjectMapper() {
return OneDev.getInstance(ObjectMapper.class);
}
private static UrlService getUrlService() {
return OneDev.getInstance(UrlService.class);
}
public static Map<String, Object> getSummary(Project currentProject,
PullRequest pullRequest, boolean checkMergeConditionIfOpen) {
var typeReference = new TypeReference<LinkedHashMap<String, Object>>() {};
var summary = getObjectMapper().convertValue(pullRequest, typeReference);
summary.remove("id");
if (pullRequest.isOpen() && checkMergeConditionIfOpen) {
var errorMessage = pullRequest.checkMergeCondition();
if (errorMessage != null)
summary.put("status", PullRequest.Status.OPEN.name() + " (" + errorMessage + ")");
else
summary.put("status", PullRequest.Status.OPEN.name() + " (ready to merge)");
}
summary.remove("uuid");
summary.remove("buildCommitHash");
summary.remove("submitTimeGroups");
summary.remove("closeTimeGroups");
summary.remove("checkError");
summary.remove("numberScopeId");
summary.put("reference", pullRequest.getReference().toString(currentProject));
summary.remove("submitterId");
summary.put("submitter", pullRequest.getSubmitter().getName());
summary.put("targetProject", pullRequest.getTarget().getProject().getPath());
if (pullRequest.getSourceProject() != null)
summary.put("sourceProject", pullRequest.getSourceProject().getPath());
summary.remove("codeCommentsUpdateDate");
summary.remove("lastActivity");
for (var it = summary.entrySet().iterator(); it.hasNext();) {
var entry = it.next();
if (entry.getKey().endsWith("Count"))
it.remove();
}
return summary;
}
public static Map<String, Object> getDetail(Project currentProject, PullRequest pullRequest) {
var detail = getSummary(currentProject, pullRequest, true);
detail.put("headCommitHash", pullRequest.getLatestUpdate().getHeadCommitHash());
detail.put("assignees", pullRequest.getAssignees().stream().map(it->it.getName()).collect(Collectors.toList()));
var reviews = new ArrayList<Map<String, Object>>();
for (var review : pullRequest.getReviews()) {
if (review.getStatus() == PullRequestReview.Status.EXCLUDED)
continue;
var reviewMap = new HashMap<String, Object>();
reviewMap.put("reviewer", review.getUser().getName());
reviewMap.put("status", review.getStatus());
reviews.add(reviewMap);
}
detail.put("reviews", reviews);
var builds = new ArrayList<String>();
for (var build : pullRequest.getBuilds()) {
builds.add(build.getReference().toString(currentProject) + " (job: " + build.getJobName() + ", status: " + build.getStatus() + ")");
}
detail.put("builds", builds);
detail.put("labels", pullRequest.getLabels().stream().map(it->it.getSpec().getName()).collect(Collectors.toList()));
detail.put("link", getUrlService().urlFor(pullRequest, true));
return detail;
}
public static List<Map<String, Object>> getComments(PullRequest pullRequest) {
var comments = new ArrayList<Map<String, Object>>();
pullRequest.getComments().stream().sorted(Comparator.comparing(PullRequestComment::getId)).forEach(comment -> {
var commentMap = new HashMap<String, Object>();
commentMap.put("user", comment.getUser().getName());
commentMap.put("date", comment.getDate());
commentMap.put("content", comment.getContent());
comments.add(commentMap);
});
return comments;
}
}

View File

@ -102,15 +102,15 @@ public class Chat extends AbstractEntity {
} }
public static String getChangeObservable(Long chatId) { public static String getChangeObservable(Long chatId) {
return "chat:" + chatId; return Chat.class.getName() + ":" + chatId;
} }
public static String getPartialResponseObservable(Long chatId) { public static String getPartialResponseObservable(Long chatId) {
return "chat:" + chatId + ":partialResponse"; return Chat.class.getName() + ":partialResponse:" + chatId;
} }
public static String getNewMessagesObservable(Long chatId) { public static String getNewMessagesObservable(Long chatId) {
return "chat:" + chatId + ":newMessages"; return Chat.class.getName() + ":newMessages:" + chatId;
} }
} }

View File

@ -8,6 +8,8 @@ import io.onedev.server.model.Chat;
import io.onedev.server.model.ChatMessage; import io.onedev.server.model.ChatMessage;
import io.onedev.server.model.User; import io.onedev.server.model.User;
import io.onedev.server.service.support.ChatResponding; import io.onedev.server.service.support.ChatResponding;
import io.onedev.server.service.support.ChatTool;
import io.onedev.server.web.WebSession;
public interface ChatService extends EntityService<Chat> { public interface ChatService extends EntityService<Chat> {
@ -15,9 +17,13 @@ public interface ChatService extends EntityService<Chat> {
void createOrUpdate(Chat chat); void createOrUpdate(Chat chat);
void sendRequest(String sessionId, ChatMessage request); void sendRequest(WebSession session, ChatMessage request, List<ChatTool> tools, int timeoutSeconds);
@Nullable @Nullable
ChatResponding getResponding(String sessionId, Chat chat); ChatResponding getResponding(WebSession session, Chat chat);
long nextAnonymousChatId();
long nextAnonymousChatMessageId();
} }

View File

@ -1,25 +1,38 @@
package io.onedev.server.service.impl; package io.onedev.server.service.impl;
import static com.google.common.base.Preconditions.checkNotNull;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Singleton; import javax.inject.Singleton;
import org.apache.commons.lang3.SerializationUtils;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.hibernate.criterion.Order; import org.hibernate.criterion.Order;
import org.hibernate.criterion.Restrictions; import org.hibernate.criterion.Restrictions;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hazelcast.cp.IAtomicLong;
import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.chat.response.PartialResponse; import dev.langchain4j.model.chat.response.PartialResponse;
import dev.langchain4j.model.chat.response.PartialResponseContext; import dev.langchain4j.model.chat.response.PartialResponseContext;
@ -28,18 +41,26 @@ import dev.langchain4j.model.chat.response.PartialThinkingContext;
import dev.langchain4j.model.chat.response.PartialToolCall; import dev.langchain4j.model.chat.response.PartialToolCall;
import dev.langchain4j.model.chat.response.PartialToolCallContext; import dev.langchain4j.model.chat.response.PartialToolCallContext;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.commons.utils.StringUtils; import io.onedev.commons.utils.StringUtils;
import io.onedev.server.cluster.ClusterService;
import io.onedev.server.event.Listen;
import io.onedev.server.event.system.SystemStarting;
import io.onedev.server.exception.ExceptionUtils; import io.onedev.server.exception.ExceptionUtils;
import io.onedev.server.model.Chat; import io.onedev.server.model.Chat;
import io.onedev.server.model.ChatMessage; import io.onedev.server.model.ChatMessage;
import io.onedev.server.model.User; import io.onedev.server.model.User;
import io.onedev.server.persistence.SessionService;
import io.onedev.server.persistence.TransactionService; import io.onedev.server.persistence.TransactionService;
import io.onedev.server.persistence.annotation.Sessional; import io.onedev.server.persistence.annotation.Sessional;
import io.onedev.server.persistence.annotation.Transactional; import io.onedev.server.persistence.annotation.Transactional;
import io.onedev.server.persistence.dao.EntityCriteria; import io.onedev.server.persistence.dao.EntityCriteria;
import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.ChatService; import io.onedev.server.service.ChatService;
import io.onedev.server.service.support.ChatResponding; import io.onedev.server.service.support.ChatResponding;
import io.onedev.server.service.support.ChatTool;
import io.onedev.server.web.SessionListener; import io.onedev.server.web.SessionListener;
import io.onedev.server.web.WebSession;
import io.onedev.server.web.websocket.WebSocketService; import io.onedev.server.web.websocket.WebSocketService;
@Singleton @Singleton
@ -62,6 +83,23 @@ public class DefaultChatService extends BaseEntityService<Chat> implements ChatS
@Inject @Inject
private WebSocketService webSocketService; private WebSocketService webSocketService;
@Inject
private ObjectMapper objectMapper;
@Inject
private SessionService sessionService;
@Inject
private ClusterService clusterService;
/*
* Use cluster wide id for anonymous chats and messages as we use id to route
* websocket observable changes to correct connections
*/
private volatile IAtomicLong nextAnonymousChatId;
private volatile IAtomicLong nextAnonymousChatMessageId;
private final Map<String, Map<Long, ChatRespondingImpl>> respondings = new ConcurrentHashMap<>(); private final Map<String, Map<Long, ChatRespondingImpl>> respondings = new ConcurrentHashMap<>();
@Override @Override
@ -70,7 +108,7 @@ public class DefaultChatService extends BaseEntityService<Chat> implements ChatS
criteria.add(Restrictions.eq(Chat.PROP_USER, user)); criteria.add(Restrictions.eq(Chat.PROP_USER, user));
criteria.add(Restrictions.eq(Chat.PROP_AI, ai)); criteria.add(Restrictions.eq(Chat.PROP_AI, ai));
criteria.add(Restrictions.ilike(Chat.PROP_TITLE, "%" + term + "%")); criteria.add(Restrictions.ilike(Chat.PROP_TITLE, "%" + term + "%"));
criteria.addOrder(Order.desc(Chat.PROP_DATE)); criteria.addOrder(Order.desc(Chat.PROP_ID));
return query(criteria); return query(criteria);
} }
@ -81,16 +119,22 @@ public class DefaultChatService extends BaseEntityService<Chat> implements ChatS
@Sessional @Sessional
@Override @Override
public ChatResponding getResponding(String sessionId, Chat chat) { public ChatResponding getResponding(WebSession session, Chat chat) {
return getResponding(sessionId, chat.getId()); return getResponding(session.getId(), chat.getId());
} }
@Transactional @Transactional
@Override @Override
public void sendRequest(String sessionId, ChatMessage request) { public void sendRequest(WebSession session, ChatMessage request, List<ChatTool> tools, int timeoutSeconds) {
var sessionId = session.getId();
var requestId = request.getId(); var requestId = request.getId();
var chatId = request.getChat().getId(); var chatId = request.getChat().getId();
var anonymous = request.getChat().getUser() == null;
var modelSetting = request.getChat().getAi().getAiSetting().getModelSetting(); var modelSetting = request.getChat().getAi().getAiSetting().getModelSetting();
var streamingChatModel = modelSetting.getStreamingChatModel();
var chatModel = modelSetting.getChatModel();
var messages = request.getChat().getSortedMessages() var messages = request.getChat().getSortedMessages()
.stream() .stream()
.filter(it->!it.isError()) .filter(it->!it.isError())
@ -99,123 +143,221 @@ public class DefaultChatService extends BaseEntityService<Chat> implements ChatS
if (messages.size() > MAX_HISTORY_MESSAGES) if (messages.size() > MAX_HISTORY_MESSAGES)
messages = messages.subList(messages.size()-MAX_HISTORY_MESSAGES, messages.size()); messages = messages.subList(messages.size()-MAX_HISTORY_MESSAGES, messages.size());
var langchain4jMessages = messages.stream() var langchain4jMessages = new ArrayList<dev.langchain4j.data.message.ChatMessage>();
langchain4jMessages.addAll(messages.stream()
.map(it -> { .map(it -> {
var content = it.getContent(); var content = it.getContent();
if (!it.equals(request)) if (!it.equals(request))
content = StringUtils.abbreviate(content, MAX_HISTORY_MESSAGE_LEN); content = StringUtils.abbreviate(content, MAX_HISTORY_MESSAGE_LEN);
if (it.isRequest()) if (it.isRequest())
return new UserMessage(content); return (dev.langchain4j.data.message.ChatMessage) new UserMessage(content);
else else
return new AiMessage(content); return (dev.langchain4j.data.message.ChatMessage) new AiMessage(content);
}) })
.collect(Collectors.toList()));
var toolSpecifications = tools.stream()
.map(ChatTool::getSpecification)
.collect(Collectors.toList()); .collect(Collectors.toList());
var completableFuture = new CompletableFuture<String>();
var executableFuture = executorService.submit(new Runnable() {
var future = executorService.submit(() -> { private StreamingChatResponseHandler newResponseHandler(Subject subject) {
var latch = new CountDownLatch(1); return new StreamingChatResponseHandler() {
var handler = new StreamingChatResponseHandler() {
private long lastPartialResponseNotificationTime = System.currentTimeMillis(); private long lastPartialResponseNotificationTime = System.currentTimeMillis();
@Override @Override
public void onPartialResponse(PartialResponse partialResponse, PartialResponseContext context) { public void onPartialResponse(PartialResponse partialResponse, PartialResponseContext context) {
if (latch.getCount() == 0) { ThreadContext.bind(subject);
context.streamingHandle().cancel(); if (completableFuture.isDone()) {
} else { context.streamingHandle().cancel();
var responding = getResponding(sessionId, chatId, requestId); } else {
if (responding != null) { var responding = getResponding(sessionId, chatId, requestId);
var content = responding.getContent(); if (responding != null) {
if (content == null) var content = responding.getContent();
content = ""; if (content == null)
content += partialResponse.text(); content = "";
responding.content = content; content += partialResponse.text();
if (System.currentTimeMillis() - lastPartialResponseNotificationTime > PARTIAL_RESPONSE_NOTIFICATION_INTERVAL) { responding.content = content;
lastPartialResponseNotificationTime = System.currentTimeMillis(); if (System.currentTimeMillis() - lastPartialResponseNotificationTime > PARTIAL_RESPONSE_NOTIFICATION_INTERVAL) {
webSocketService.notifyObservableChange(Chat.getPartialResponseObservable(chatId), null); lastPartialResponseNotificationTime = System.currentTimeMillis();
webSocketService.notifyObservableChange(Chat.getPartialResponseObservable(chatId), null);
}
}
}
}
@Override
public void onPartialThinking(PartialThinking partialThinking, PartialThinkingContext context) {
ThreadContext.bind(subject);
if (completableFuture.isDone())
context.streamingHandle().cancel();
}
@Override
public void onPartialToolCall(PartialToolCall partialToolCall, PartialToolCallContext context) {
ThreadContext.bind(subject);
if (completableFuture.isDone()) {
context.streamingHandle().cancel();
}
}
@Override
public void onCompleteResponse(ChatResponse completeResponse) {
ThreadContext.bind(subject);
sessionService.runAsync(() -> {
try {
var aiMessage = completeResponse.aiMessage();
if (aiMessage.hasToolExecutionRequests()) {
langchain4jMessages.add(aiMessage);
var toolRequests = aiMessage.toolExecutionRequests();
for (int i = 0; i < toolRequests.size(); i++) {
var toolRequest = toolRequests.get(i);
String toolName = toolRequest.name();
String toolArgs = toolRequest.arguments();
var toolResult = tools.stream()
.filter(it->it.getSpecification().name().equals(toolName))
.findFirst()
.orElseThrow(() -> new ExplicitException("Unknown tool: " + toolName))
.execute(objectMapper.readTree(toolArgs));
ToolExecutionResultMessage toolResultMessage = ToolExecutionResultMessage.from(
toolRequest.id(), toolName, toolResult);
langchain4jMessages.add(toolResultMessage);
}
var handler = newResponseHandler(SecurityUtils.getSubject());
var langchain4jChatRequest = ChatRequest.builder()
.messages(new ArrayList<>(langchain4jMessages))
.toolSpecifications(toolSpecifications)
.build();
streamingChatModel.chat(langchain4jChatRequest, handler);
} else {
completableFuture.complete(aiMessage.text());
}
} catch (Throwable t) {
completableFuture.completeExceptionally(t);
} }
}
}
}
@Override
public void onPartialThinking(PartialThinking partialThinking, PartialThinkingContext context) {
if (latch.getCount() == 0)
context.streamingHandle().cancel();
}
@Override
public void onPartialToolCall(PartialToolCall partialToolCall, PartialToolCallContext context) {
if (latch.getCount() == 0)
context.streamingHandle().cancel();
}
@Override
public void onCompleteResponse(ChatResponse completeResponse) {
try {
createResponseIfNecessary(sessionId, chatId, requestId, completeResponse.aiMessage().text(), null);
} finally {
latch.countDown();
}
}
@Override
public void onError(Throwable error) {
try {
createResponseIfNecessary(sessionId, chatId, requestId, "Error getting chat response, check server log for details", error);
} finally {
latch.countDown();
}
}
};
try {
modelSetting.getStreamingChatModel().chat(langchain4jMessages, handler);
transactionService.run(() -> {
var chat = load(chatId);
var requests = chat.getMessages().stream().filter(it->it.isRequest()).collect(Collectors.toList());
if (requests.size() == 1) {
var systemPrompt = String.format("""
Summarize provided message to get a compact title with below requirements:
1. It should be within %d characters
2. Only title is returned, no other text or comments
""", Chat.MAX_TITLE_LEN);
var title = modelSetting.getChatModel().chat(new SystemMessage(systemPrompt), new UserMessage(requests.get(0).getContent())).aiMessage().text();
chat.setTitle(title);
webSocketService.notifyObservableChange(Chat.getChangeObservable(chatId), null);
}
});
latch.await();
} catch (Exception e) {
if (ExceptionUtils.find(e, InterruptedException.class) == null) {
logger.error("Error getting chat response", e);
String errorMessage = e.getMessage();
if (errorMessage == null)
errorMessage = "Error getting chat response, check server log for details";
createResponseIfNecessary(sessionId, chatId, requestId, errorMessage, e);
} else {
var responding = getResponding(sessionId, chatId, requestId);
if (responding != null && responding.getContent() != null)
createResponseIfNecessary(sessionId, chatId, requestId, responding.getContent(), null);
}
} finally {
latch.countDown();
var respondingsOfSession = respondings.get(sessionId);
if (respondingsOfSession != null) {
var responding = respondingsOfSession.get(chatId);
if (responding != null) {
respondingsOfSession.computeIfPresent(chatId, (k, v)-> {
if (v.requestId.equals(requestId))
return null;
else
return v;
}); });
webSocketService.notifyObservableChange(Chat.getPartialResponseObservable(chatId), null); }
@Override
public void onError(Throwable error) {
ThreadContext.bind(subject);
completableFuture.completeExceptionally(error);
}
};
}
private void createResponse(String content, @Nullable Throwable throwable) {
var responding = getResponding(session.getId(), chatId, requestId);
if (responding != null) {
transactionService.run(() -> {
if (throwable != null)
logger.error("Error getting chat response", throwable);
var response = new ChatMessage();
response.setError(throwable != null);
response.setContent(content);
if (anonymous) {
var chat = SerializationUtils.clone(checkNotNull(session.getAnonymousChats().get(chatId)));
response.setChat(chat);
response.setId(nextAnonymousChatMessageId());
chat.getMessages().add(response);
session.getAnonymousChats().put(chatId, chat);
} else {
response.setChat(load(chatId));
dao.persist(response);
}
webSocketService.notifyObservableChange(Chat.getNewMessagesObservable(chatId), null);
});
};
}
private void createIncompleteResponse(String reason) {
var responding = getResponding(sessionId, chatId, requestId);
if (responding != null) {
var content = responding.getContent();
if (content == null)
content = "";
else
content += "\n\n";
content += "<b class='text text-danger'>" + reason + "</b>";
createResponse(content, null);
}
}
@Override
public void run() {
var handler = newResponseHandler(SecurityUtils.getSubject());
try {
var langchain4jChatRequest = ChatRequest.builder()
.messages(langchain4jMessages.stream()
.map(msg -> (dev.langchain4j.data.message.ChatMessage) msg)
.collect(Collectors.toList()))
.toolSpecifications(toolSpecifications)
.build();
streamingChatModel.chat(langchain4jChatRequest, handler);
transactionService.run(() -> {
var chat = anonymous ? checkNotNull(session.getAnonymousChats().get(chatId)) : load(chatId);
var requests = chat.getMessages().stream().filter(it->it.isRequest()).collect(Collectors.toList());
if (requests.size() == 1) {
var systemPrompt = String.format("""
Summarize provided message to get a compact title with below requirements:
1. Just summarize the message itself, no need to ask user for clarification or confirmation
2. It should be within %d characters
3. Only title is returned, no other text or comments
4. Display in %s
""", Chat.MAX_TITLE_LEN, session.getLocale().getDisplayLanguage());
var title = chatModel.chat(new SystemMessage(systemPrompt), new UserMessage(requests.get(0).getContent())).aiMessage().text();
if (anonymous) {
chat = SerializationUtils.clone(chat);
chat.setTitle(title);
session.getAnonymousChats().put(chatId, chat);
} else {
chat.setTitle(title);
dao.persist(chat);
}
webSocketService.notifyObservableChange(Chat.getChangeObservable(chatId), null);
}
});
var response = completableFuture.get(timeoutSeconds, TimeUnit.SECONDS);
if (StringUtils.isBlank(response))
throw new ExplicitException("Received empty response");
createResponse(response, null);
} catch (Throwable e) {
if (ExceptionUtils.find(e, InterruptedException.class) != null) {
createIncompleteResponse("Conversation cancelled");
} else if (ExceptionUtils.find(e, TimeoutException.class) != null) {
createIncompleteResponse("Conversation timed out");
} else {
logger.error("Error getting chat response", e);
String errorMessage = e.getMessage();
if (errorMessage == null)
errorMessage = "Error getting chat response, check server log for details";
createResponse(errorMessage, e);
}
} finally {
completableFuture.cancel(false);
var respondingsOfSession = respondings.get(sessionId);
if (respondingsOfSession != null) {
var responding = respondingsOfSession.get(chatId);
if (responding != null) {
respondingsOfSession.computeIfPresent(chatId, (k, v)-> {
if (v.requestId.equals(requestId))
return null;
else
return v;
});
webSocketService.notifyObservableChange(Chat.getPartialResponseObservable(chatId), null);
}
} }
} }
} }
}); });
var previousResponding = respondings.computeIfAbsent(sessionId, it->new ConcurrentHashMap<>()).put(chatId, new ChatRespondingImpl(requestId, future)); var previousResponding = respondings.computeIfAbsent(sessionId, it->new ConcurrentHashMap<>()).put(chatId, new ChatRespondingImpl(requestId, executableFuture));
if (previousResponding != null) if (previousResponding != null)
previousResponding.cancel(); previousResponding.cancel();
} }
@ -236,21 +378,24 @@ public class DefaultChatService extends BaseEntityService<Chat> implements ChatS
return null; return null;
} }
private void createResponseIfNecessary(String sessionId, Long chatId, Long requestId, String content, @Nullable Throwable throwable) { @Listen
var responding = getResponding(sessionId, chatId, requestId); public void on(SystemStarting event) {
if (responding != null) { nextAnonymousChatId = clusterService.getHazelcastInstance().getCPSubsystem().getAtomicLong("nextAnonymousChatId");
transactionService.run(() -> { nextAnonymousChatMessageId = clusterService.getHazelcastInstance().getCPSubsystem().getAtomicLong("nextAnonymousChatMessageId");
var chat = load(chatId); if (clusterService.isLeaderServer()) {
if (throwable != null) nextAnonymousChatId.set(1L);
logger.error("Error getting chat response", throwable); nextAnonymousChatMessageId.set(1L);
var response = new ChatMessage(); }
response.setChat(chat); }
response.setError(throwable != null);
response.setContent(content); @Override
dao.persist(response); public long nextAnonymousChatId() {
webSocketService.notifyObservableChange(Chat.getNewMessagesObservable(chatId), null); return nextAnonymousChatId.getAndIncrement();
}); }
};
@Override
public long nextAnonymousChatMessageId() {
return nextAnonymousChatMessageId.getAndIncrement();
} }
@Override @Override

View File

@ -0,0 +1,21 @@
package io.onedev.server.service.support;
import com.fasterxml.jackson.databind.JsonNode;
import dev.langchain4j.agent.tool.ToolSpecification;
public interface ChatTool {
ToolSpecification getSpecification();
/**
* This method should not rely on any Wicket facilities, such as page, component, model etc.
* If you need to access some data from these facilities, make sure to prepare them while
* creating the tool
*
* @param arguments
* @return
*/
String execute(JsonNode arguments);
}

View File

@ -5,7 +5,6 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.jspecify.annotations.Nullable;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
@ -14,8 +13,10 @@ import org.apache.shiro.subject.Subject;
import org.apache.wicket.protocol.http.WicketServlet; import org.apache.wicket.protocol.http.WicketServlet;
import org.apache.wicket.request.Request; import org.apache.wicket.request.Request;
import org.apache.wicket.util.collections.ConcurrentHashSet; import org.apache.wicket.util.collections.ConcurrentHashSet;
import org.jspecify.annotations.Nullable;
import io.onedev.server.OneDev; import io.onedev.server.OneDev;
import io.onedev.server.model.Chat;
import io.onedev.server.web.util.Cursor; import io.onedev.server.web.util.Cursor;
public class WebSession extends org.apache.wicket.protocol.http.WebSession { public class WebSession extends org.apache.wicket.protocol.http.WebSession {
@ -36,11 +37,13 @@ public class WebSession extends org.apache.wicket.protocol.http.WebSession {
private Set<Long> expandedProjectIds = new ConcurrentHashSet<>(); private Set<Long> expandedProjectIds = new ConcurrentHashSet<>();
private boolean chatVisible; private volatile boolean chatVisible;
private Long activeChatId; private volatile Long activeChatId;
private String chatInput; private volatile Map<Long, Chat> anonymousChats = new ConcurrentHashMap<>();
private volatile String chatInput;
public WebSession(Request request) { public WebSession(Request request) {
super(request); super(request);
@ -141,6 +144,10 @@ public class WebSession extends org.apache.wicket.protocol.http.WebSession {
this.activeChatId = activeChatId; this.activeChatId = activeChatId;
} }
public Map<Long, Chat> getAnonymousChats() {
return anonymousChats;
}
@Nullable @Nullable
public String getChatInput() { public String getChatInput() {
return chatInput; return chatInput;

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1764891371306" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9180" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M789.333333 512V234.666667q0-42.666667-42.666666-42.666667H234.666667q-42.666667 0-42.666667 42.666667v554.666666q0 42.666667 42.666667 42.666667h298.666666a42.666667 42.666667 0 1 1 0 85.333333H234.666667q-53.013333 0-90.517334-37.482666Q106.666667 842.346667 106.666667 789.333333V234.666667q0-53.013333 37.482666-90.517334Q181.653333 106.666667 234.666667 106.666667h512q53.013333 0 90.517333 37.482666Q874.666667 181.653333 874.666667 234.666667v277.333333a42.666667 42.666667 0 1 1-85.333334 0zM490.666667 277.333333h-170.666667a42.666667 42.666667 0 1 0 0 85.333334h170.666667a42.666667 42.666667 0 1 0 0-85.333334z m213.333333 213.333334a42.666667 42.666667 0 0 0-42.666667-42.666667H320a42.666667 42.666667 0 1 0 0 85.333333h341.333333a42.666667 42.666667 0 0 0 42.666667-42.666666zM320 704h85.333333a42.666667 42.666667 0 1 0 0-85.333333h-85.333333a42.666667 42.666667 0 1 0 0 85.333333z m310.442667 79.36c-15.701333-4.202667-15.701333-26.517333 0-30.72l7.722666-2.090667a159.274667 159.274667 0 0 0 112.384-112.384l2.069334-7.722666c4.224-15.701333 26.538667-15.701333 30.762666 0l2.069334 7.722666a159.274667 159.274667 0 0 0 112.384 112.384l7.701333 2.069334c15.722667 4.224 15.722667 26.538667 0 30.762666l-7.68 2.069334a159.274667 159.274667 0 0 0-112.426667 112.384l-2.048 7.701333c-4.224 15.722667-26.538667 15.722667-30.762666 0l-2.069334-7.68a159.274667 159.274667 0 0 0-112.384-112.405333l-7.722666-2.069334z" p-id="9181"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,8 +1,8 @@
<wicket:panel> <wicket:panel>
<div class="ui-resizable-handle ui-resizable-w"></div> <div class="ui-resizable-handle ui-resizable-w"></div>
<div class="head d-flex align-items-center justify-content-between px-4 py-3 border-bottom"> <div class="head d-flex align-items-center px-4 py-3 border-bottom">
<h5 class="card-title mb-0"><wicket:t>Chat with</wicket:t> <a wicket:id="aiSelector" class="ml-2"><img wicket:id="avatar"></img> <span wicket:id="name"></span> <wicket:svg href="arrow" class="icon rotate-90"></wicket:svg></a></h5> <h5 class="card-title mb-0 mr-3"><wicket:t>Chat with</wicket:t> <a wicket:id="aiSelector" class="ml-2"><img wicket:id="avatar"></img> <span wicket:id="name"></span> <wicket:svg href="arrow" class="icon rotate-90"></wicket:svg></a></h5>
<a wicket:id="close" t:data-tippy-content="Close" class="btn btn-xs btn-icon btn-light btn-hover-primary"><wicket:svg href="times" class="icon"/></a> <a wicket:id="close" t:data-tippy-content="Close" class="ml-auto btn btn-xs btn-icon btn-light btn-hover-primary"><wicket:svg href="times" class="icon"/></a>
</div> </div>
<div class="body d-flex flex-column flex-grow-1 p-4 position-relative" style="overflow: hidden;"> <div class="body d-flex flex-column flex-grow-1 p-4 position-relative" style="overflow: hidden;">
<div wicket:id="chatSelectorContainer" class="chat-selector d-flex flex-shrink-0 mb-3"> <div wicket:id="chatSelectorContainer" class="chat-selector d-flex flex-shrink-0 mb-3">

View File

@ -6,6 +6,7 @@ import static io.onedev.server.web.translation.Translation._T;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -13,6 +14,7 @@ import java.util.stream.Collectors;
import javax.inject.Inject; import javax.inject.Inject;
import javax.servlet.http.Cookie; import javax.servlet.http.Cookie;
import org.apache.commons.lang3.SerializationUtils;
import org.apache.wicket.Component; import org.apache.wicket.Component;
import org.apache.wicket.ajax.AjaxRequestTarget; import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.form.AjaxFormComponentUpdatingBehavior; import org.apache.wicket.ajax.form.AjaxFormComponentUpdatingBehavior;
@ -31,10 +33,13 @@ import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.markup.repeater.RepeatingView; import org.apache.wicket.markup.repeater.RepeatingView;
import org.apache.wicket.model.AbstractReadOnlyModel; import org.apache.wicket.model.AbstractReadOnlyModel;
import org.apache.wicket.model.IModel; import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LoadableDetachableModel;
import org.apache.wicket.model.Model; import org.apache.wicket.model.Model;
import org.apache.wicket.request.cycle.RequestCycle; import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.http.WebRequest; import org.apache.wicket.request.http.WebRequest;
import org.apache.wicket.request.http.WebResponse; import org.apache.wicket.request.http.WebResponse;
import org.apache.wicket.util.visit.IVisit;
import org.apache.wicket.util.visit.IVisitor;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONWriter; import org.json.JSONWriter;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
@ -46,6 +51,8 @@ import io.onedev.server.persistence.dao.Dao;
import io.onedev.server.service.ChatService; import io.onedev.server.service.ChatService;
import io.onedev.server.service.UserService; import io.onedev.server.service.UserService;
import io.onedev.server.service.support.ChatResponding; import io.onedev.server.service.support.ChatResponding;
import io.onedev.server.service.support.ChatTool;
import io.onedev.server.util.facade.UserFacade;
import io.onedev.server.web.WebConstants; import io.onedev.server.web.WebConstants;
import io.onedev.server.web.WebSession; import io.onedev.server.web.WebSession;
import io.onedev.server.web.behavior.ChangeObserver; import io.onedev.server.web.behavior.ChangeObserver;
@ -59,11 +66,14 @@ import io.onedev.server.web.component.select2.ChoiceProvider;
import io.onedev.server.web.component.select2.ResponseFiller; import io.onedev.server.web.component.select2.ResponseFiller;
import io.onedev.server.web.component.select2.Select2Choice; import io.onedev.server.web.component.select2.Select2Choice;
import io.onedev.server.web.component.user.UserAvatar; import io.onedev.server.web.component.user.UserAvatar;
import io.onedev.server.web.util.ChatToolAware;
public class ChatPanel extends Panel { public class ChatPanel extends Panel {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private static final int TIMEOUT_SECONDS = 120;
private static final String COOKIE_ACTIVE_AI = "active-ai"; private static final String COOKIE_ACTIVE_AI = "active-ai";
@Inject @Inject
@ -80,6 +90,23 @@ public class ChatPanel extends Panel {
private RepeatingView messagesView; private RepeatingView messagesView;
private WebMarkupContainer respondingContainer; private WebMarkupContainer respondingContainer;
private final IModel<List<User>> entitledAisModel = new LoadableDetachableModel<List<User>>() {
@Override
protected List<User> load() {
if (getUser() != null) {
return getUser().getEntitledAis();
} else {
return userService.cloneCache().values().stream()
.filter(it -> it.getType() == User.Type.AI && !it.isDisabled() && it.isEntitleToAll())
.sorted(Comparator.comparing(UserFacade::getDisplayName))
.map(it->userService.load(it.getId()))
.collect(Collectors.toList());
}
}
};
public ChatPanel(String componentId) { public ChatPanel(String componentId) {
super(componentId); super(componentId);
@ -100,7 +127,7 @@ public class ChatPanel extends Panel {
protected List<MenuItem> getMenuItems(FloatingPanel dropdown) { protected List<MenuItem> getMenuItems(FloatingPanel dropdown) {
var activeAI = getActiveAI(); var activeAI = getActiveAI();
var menuItems = new ArrayList<MenuItem>(); var menuItems = new ArrayList<MenuItem>();
for (var ai : getUser().getEntitledAis()) { for (var ai : getEntitledAis()) {
menuItems.add(new MenuItem() { menuItems.add(new MenuItem() {
@Override @Override
@ -150,7 +177,7 @@ public class ChatPanel extends Panel {
}); });
var chatSelectorContainer = new WebMarkupContainer("chatSelectorContainer"); var chatSelectorContainer = new WebMarkupContainer("chatSelectorContainer");
chatSelectorContainer.setOutputMarkupId(true); chatSelectorContainer.setOutputMarkupId(true);
chatSelectorContainer.add(new Select2Choice<Chat>("chatSelector", new IModel<Chat>() { chatSelectorContainer.add(new Select2Choice<Chat>("chatSelector", new IModel<Chat>() {
@Override @Override
@ -172,7 +199,16 @@ public class ChatPanel extends Panel {
@Override @Override
public void query(String term, int page, io.onedev.server.web.component.select2.Response<Chat> response) { public void query(String term, int page, io.onedev.server.web.component.select2.Response<Chat> response) {
var count = (page+1) * WebConstants.PAGE_SIZE + 1; var count = (page+1) * WebConstants.PAGE_SIZE + 1;
var chats = chatService.query(getUser(), getActiveAI(), term, count); List<Chat> chats;
if (getUser() != null) {
chats = chatService.query(getUser(), getActiveAI(), term, count);
} else {
chats = WebSession.get().getAnonymousChats().values().stream()
.filter(it -> it.getAi().equals(getActiveAI()) && it.getTitle().toLowerCase().contains(term.toLowerCase()))
.sorted(Comparator.comparing(Chat::getId).reversed())
.limit(count)
.collect(Collectors.toList());
}
new ResponseFiller<>(response).fill(chats, page, WebConstants.PAGE_SIZE); new ResponseFiller<>(response).fill(chats, page, WebConstants.PAGE_SIZE);
} }
@ -205,10 +241,6 @@ public class ChatPanel extends Panel {
return Collections.emptySet(); return Collections.emptySet();
} }
public void onObservableChanged(IPartialPageRequestHandler handler, Collection<String> changedObservables) {
handler.add(chatSelectorContainer);
}
}); });
chatSelectorContainer.add(new ChangeObserver() { chatSelectorContainer.add(new ChangeObserver() {
@ -223,10 +255,11 @@ public class ChatPanel extends Panel {
@Override @Override
public void onObservableChanged(IPartialPageRequestHandler handler, Collection<String> changedObservables) { public void onObservableChanged(IPartialPageRequestHandler handler, Collection<String> changedObservables) {
showNewMessages(handler); if (isVisible())
showNewMessages(handler);
} }
});
});
chatSelectorContainer.add(new AjaxLink<Void>("newChat") { chatSelectorContainer.add(new AjaxLink<Void>("newChat") {
@ -242,7 +275,10 @@ public class ChatPanel extends Panel {
@Override @Override
public void onClick(AjaxRequestTarget target) { public void onClick(AjaxRequestTarget target) {
getSession().success(_T("Chat deleted")); getSession().success(_T("Chat deleted"));
chatService.delete(getActiveChat()); if (getUser() != null)
chatService.delete(getActiveChat());
else
WebSession.get().getAnonymousChats().remove(getActiveChat().getId());
WebSession.get().setActiveChatId(null); WebSession.get().setActiveChatId(null);
target.add(ChatPanel.this); target.add(ChatPanel.this);
} }
@ -299,7 +335,7 @@ public class ChatPanel extends Panel {
else else
return Collections.emptySet(); return Collections.emptySet();
} }
}); });
respondingContainer.setOutputMarkupPlaceholderTag(true); respondingContainer.setOutputMarkupPlaceholderTag(true);
@ -337,23 +373,55 @@ public class ChatPanel extends Panel {
protected void onSubmit(AjaxRequestTarget target, Form<?> form) { protected void onSubmit(AjaxRequestTarget target, Form<?> form) {
var input = WebSession.get().getChatInput().trim(); var input = WebSession.get().getChatInput().trim();
var chat = getActiveChat(); var chat = getActiveChat();
ChatMessage request = new ChatMessage();
request.setRequest(true);
request.setContent(input);
if (chat == null) { if (chat == null) {
chat = new Chat(); chat = new Chat();
chat.setUser(getUser()); chat.setUser(getUser());
chat.setAi(getActiveAI()); chat.setAi(getActiveAI());
chat.setTitle(_T("New chat")); chat.setTitle(_T("New chat"));
chat.setDate(new Date()); chat.setDate(new Date());
chatService.createOrUpdate(chat); if (getUser() != null) {
chatService.createOrUpdate(chat);
request.setChat(chat);
chat.getMessages().add(request);
dao.persist(request);
} else {
chat.setId(chatService.nextAnonymousChatId());
request.setId(chatService.nextAnonymousChatMessageId());
request.setChat(chat);
chat.getMessages().add(request);
WebSession.get().getAnonymousChats().put(chat.getId(), chat);
}
WebSession.get().setActiveChatId(chat.getId()); WebSession.get().setActiveChatId(chat.getId());
target.add(chatSelectorContainer); target.add(chatSelectorContainer);
} else {
if (getUser() != null) {
request.setChat(chat);
chat.getMessages().add(request);
dao.persist(request);
} else {
request.setId(chatService.nextAnonymousChatMessageId());
chat = SerializationUtils.clone(chat);
request.setChat(chat);
chat.getMessages().add(request);
WebSession.get().getAnonymousChats().put(chat.getId(), chat);
}
} }
var request = new ChatMessage();
request.setChat(chat); List<ChatTool> chatTools = new ArrayList<>();
request.setRequest(true); if (getPage() instanceof ChatToolAware)
request.setContent(input); chatTools.addAll(((ChatToolAware) getPage()).getChatTools());
chat.getMessages().add(request); getPage().visitChildren(ChatToolAware.class, new IVisitor<Component, Void>() {
dao.persist(request);
chatService.sendRequest(getSession().getId(), request); @Override
public void component(Component component, IVisit<Void> visit) {
chatTools.addAll(((ChatToolAware) component).getChatTools());
}
});
chatService.sendRequest(WebSession.get(), request, chatTools, TIMEOUT_SECONDS);
showNewMessages(target); showNewMessages(target);
target.add(respondingContainer); target.add(respondingContainer);
@ -388,10 +456,10 @@ public class ChatPanel extends Panel {
private User getActiveAI() { private User getActiveAI() {
if (activeAiId != null) { if (activeAiId != null) {
var ai = userService.get(activeAiId); var ai = userService.get(activeAiId);
if (ai != null && getUser().getEntitledAis().contains(ai)) if (ai != null && getEntitledAis().contains(ai))
return ai; return ai;
} }
return getUser().getEntitledAis().get(0); return getEntitledAis().get(0);
} }
private void setActiveAI(User ai) { private void setActiveAI(User ai) {
@ -408,7 +476,11 @@ public class ChatPanel extends Panel {
private Chat getActiveChat() { private Chat getActiveChat() {
var activeChatId = WebSession.get().getActiveChatId(); var activeChatId = WebSession.get().getActiveChatId();
if (activeChatId != null) { if (activeChatId != null) {
var chat = chatService.get(activeChatId); Chat chat;
if (getUser() != null)
chat = chatService.get(activeChatId);
else
chat = WebSession.get().getAnonymousChats().get(activeChatId);
if (chat != null && chat.getAi().equals(getActiveAI())) if (chat != null && chat.getAi().equals(getActiveAI()))
return chat; return chat;
} }
@ -419,7 +491,7 @@ public class ChatPanel extends Panel {
private ChatResponding getResponding() { private ChatResponding getResponding() {
var chat = getActiveChat(); var chat = getActiveChat();
if (chat != null) if (chat != null)
return chatService.getResponding(getSession().getId(), chat); return chatService.getResponding(WebSession.get(), chat);
else else
return null; return null;
} }
@ -434,12 +506,13 @@ public class ChatPanel extends Panel {
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
private void showNewMessages(IPartialPageRequestHandler handler) { private void showNewMessages(IPartialPageRequestHandler handler) {
long lastMessageId; long prevLastMessageId;
if (messagesView.size() != 0) if (messagesView.size() != 0)
lastMessageId = (Long) messagesView.get(messagesView.size() - 1).getDefaultModelObject(); prevLastMessageId = (Long) messagesView.get(messagesView.size() - 1).getDefaultModelObject();
else else
lastMessageId = 0; prevLastMessageId = 0;
getMessages().stream().filter(it -> it.getId() > lastMessageId).forEach(it -> {
getMessages().stream().filter(it -> it.getId() > prevLastMessageId).forEach(it -> {
var messageContainer = newMessageContainer(messagesView.newChildId(), it); var messageContainer = newMessageContainer(messagesView.newChildId(), it);
messagesView.add(messageContainer); messagesView.add(messageContainer);
handler.prependJavaScript(String.format(""" handler.prependJavaScript(String.format("""
@ -447,10 +520,13 @@ public class ChatPanel extends Panel {
""", respondingContainer.getMarkupId(), messageContainer.getMarkupId())); """, respondingContainer.getMarkupId(), messageContainer.getMarkupId()));
handler.add(messageContainer); handler.add(messageContainer);
}); });
var lastMessage = messagesView.get(messagesView.size() - 1); var lastMessage = messagesView.get(messagesView.size() - 1);
handler.appendJavaScript(String.format(""" if ((Long) lastMessage.getDefaultModelObject() > prevLastMessageId) {
$('#%s')[0].scrollIntoView({ block: "end" }); handler.appendJavaScript(String.format("""
""", lastMessage.getMarkupId())); $('#%s')[0].scrollIntoView({ block: "end" });
""", lastMessage.getMarkupId()));
}
} }
private Component newMessageContainer(String containerId, ChatMessage message) { private Component newMessageContainer(String containerId, ChatMessage message) {
@ -493,7 +569,7 @@ public class ChatPanel extends Panel {
@Override @Override
protected void onConfigure() { protected void onConfigure() {
super.onConfigure(); super.onConfigure();
setVisible(WebSession.get().isChatVisible()); setVisible(WebSession.get().isChatVisible() && !getEntitledAis().isEmpty());
} }
@Override @Override
@ -504,16 +580,25 @@ public class ChatPanel extends Panel {
} }
@Override @Override
public boolean isVisible() { protected void onDetach() {
return WebSession.get().isChatVisible(); super.onDetach();
entitledAisModel.detach();
} }
public void show(AjaxRequestTarget target) { public List<User> getEntitledAis() {
if (!isVisible()) { return entitledAisModel.getObject();
WebSession.get().setChatVisible(true); }
WebSession.get().setActiveChatId(null);
target.add(this); public void show(AjaxRequestTarget target, @Nullable String prompt) {
} WebSession.get().setChatVisible(true);
WebSession.get().setActiveChatId(null);
if (prompt != null) {
WebSession.get().setChatInput(prompt);
target.appendJavaScript("""
$(".chat>.body>.send .submit").click();
""");
}
target.add(this);
} }
public void hide(AjaxRequestTarget target) { public void hide(AjaxRequestTarget target) {

View File

@ -1,25 +1,20 @@
.chat { .chat {
position: fixed; position: fixed;
top: calc(var(--topbar-height) + 1rem); top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 1000; z-index: 2000;
border-radius: 0.42rem 0 0 0;
background: white; background: white;
box-shadow: 0 0 12px rgba(0,0,0,0.2), 0 -2px 8px rgba(0,0,0,0.1); box-shadow: 0 0 12px rgba(0,0,0,0.2);
}
.chat>.head {
border-radius: 0.42rem 0 0 0;
} }
.dark-mode .chat { .dark-mode .chat {
background-color: var(--dark-mode-dark); background-color: var(--dark-mode-dark);
box-shadow: 0 0 20px rgb(0 0 0 / 50%), 0 -2px 15px rgb(0 0 0 / 30%); box-shadow: 0 0 20px rgb(0 0 0 / 50%);
} }
.chat .ui-resizable-handle { .chat .ui-resizable-handle {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0.42rem;
bottom: 0; bottom: 0;
width: 3px; width: 3px;
cursor: col-resize; cursor: col-resize;

View File

@ -18,10 +18,11 @@
</form> </form>
</wicket:fragment> </wicket:fragment>
<wicket:fragment wicket:id="optionsFrag"> <wicket:fragment wicket:id="optionsFrag">
<div class="issue-activity-options"> <a wicket:id="aiSummarize" t:data-tippy-content="Summarize comments with AI" class="btn btn-xs btn-outline-secondary btn-hover-primary btn-icon"><wicket:svg href="ai-summarize" class="icon"></wicket:svg></a>
<a wicket:id="showComments" t:data-tippy-content="Toggle comments" class="btn btn-xs text-muted btn-active-secondary btn-icon"><wicket:svg href="comments" class="icon"></wicket:svg></a> <div class="btn-group">
<a wicket:id="showChangeHistory" t:data-tippy-content="Toggle change history" class="btn btn-xs text-muted btn-active-secondary btn-icon"><wicket:svg href="history" class="icon"></wicket:svg></a> <a wicket:id="showComments" t:data-tippy-content="Toggle comments" class="btn btn-xs text-muted btn-outline-secondary btn-active-secondary btn-icon"><wicket:svg href="comments" class="icon"></wicket:svg></a>
<a wicket:id="showWorkLog" t:data-tippy-content="Toggle work log" class="btn btn-xs text-muted btn-active-secondary btn-icon"><wicket:svg href="info" class="icon"></wicket:svg></a> <a wicket:id="showChangeHistory" t:data-tippy-content="Toggle change history" class="btn btn-xs text-muted btn-outline-secondary btn-active-secondary btn-icon"><wicket:svg href="history" class="icon"></wicket:svg></a>
<a wicket:id="showWorkLog" t:data-tippy-content="Toggle work log" class="btn btn-xs text-muted btn-outline-secondary btn-active-secondary btn-icon"><wicket:svg href="info" class="icon"></wicket:svg></a>
</div> </div>
</wicket:fragment> </wicket:fragment>
</wicket:panel> </wicket:panel>

View File

@ -39,7 +39,6 @@ import com.google.common.collect.Lists;
import io.onedev.server.OneDev; import io.onedev.server.OneDev;
import io.onedev.server.attachment.AttachmentSupport; import io.onedev.server.attachment.AttachmentSupport;
import io.onedev.server.attachment.ProjectAttachmentSupport; import io.onedev.server.attachment.ProjectAttachmentSupport;
import io.onedev.server.service.IssueCommentService;
import io.onedev.server.entityreference.ReferencedFromAware; import io.onedev.server.entityreference.ReferencedFromAware;
import io.onedev.server.model.Issue; import io.onedev.server.model.Issue;
import io.onedev.server.model.IssueChange; import io.onedev.server.model.IssueChange;
@ -53,6 +52,7 @@ import io.onedev.server.model.support.issue.changedata.IssueOwnSpentTimeChangeDa
import io.onedev.server.model.support.issue.changedata.IssueTotalEstimatedTimeChangeData; import io.onedev.server.model.support.issue.changedata.IssueTotalEstimatedTimeChangeData;
import io.onedev.server.model.support.issue.changedata.IssueTotalSpentTimeChangeData; import io.onedev.server.model.support.issue.changedata.IssueTotalSpentTimeChangeData;
import io.onedev.server.security.SecurityUtils; import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.IssueCommentService;
import io.onedev.server.web.ajaxlistener.ConfirmLeaveListener; import io.onedev.server.web.ajaxlistener.ConfirmLeaveListener;
import io.onedev.server.web.behavior.ChangeObserver; import io.onedev.server.web.behavior.ChangeObserver;
import io.onedev.server.web.component.comment.CommentInput; import io.onedev.server.web.component.comment.CommentInput;
@ -61,6 +61,7 @@ import io.onedev.server.web.component.issue.activities.activity.IssueChangeActiv
import io.onedev.server.web.component.issue.activities.activity.IssueCommentActivity; import io.onedev.server.web.component.issue.activities.activity.IssueCommentActivity;
import io.onedev.server.web.component.issue.activities.activity.IssueWorkActivity; import io.onedev.server.web.component.issue.activities.activity.IssueWorkActivity;
import io.onedev.server.web.page.base.BasePage; import io.onedev.server.web.page.base.BasePage;
import io.onedev.server.web.page.layout.LayoutPage;
import io.onedev.server.web.page.security.LoginPage; import io.onedev.server.web.page.security.LoginPage;
import io.onedev.server.web.util.WicketUtils; import io.onedev.server.web.util.WicketUtils;
@ -327,6 +328,23 @@ public abstract class IssueActivitiesPanel extends Panel {
public Component renderOptions(String componentId) { public Component renderOptions(String componentId) {
Fragment fragment = new Fragment(componentId, "optionsFrag", this); Fragment fragment = new Fragment(componentId, "optionsFrag", this);
fragment.add(new AjaxLink<Void>("aiSummarize") {
@Override
public void onClick(AjaxRequestTarget target) {
var page = (LayoutPage)getPage();
page.getChatter().show(target, "Summarize comments of current issue. Display summary in " + getSession().getLocale().getDisplayLanguage());
}
@Override
protected void onConfigure() {
super.onConfigure();
var page = (LayoutPage)getPage();
setVisible(!page.getChatter().getEntitledAis().isEmpty());
}
});
fragment.add(showCommentsLink = new AjaxLink<Void>("showComments") { fragment.add(showCommentsLink = new AjaxLink<Void>("showComments") {
@Override @Override

View File

@ -474,5 +474,5 @@ public abstract class BasePage extends WebPage {
removeAutosaveKeys = new HashSet<>(); removeAutosaveKeys = new HashSet<>();
return removeAutosaveKeys; return removeAutosaveKeys;
} }
} }

View File

@ -176,6 +176,8 @@ public abstract class LayoutPage extends BasePage {
private AbstractDefaultAjaxBehavior newVersionStatusBehavior; private AbstractDefaultAjaxBehavior newVersionStatusBehavior;
private ChatPanel chatter;
public LayoutPage(PageParameters params) { public LayoutPage(PageParameters params) {
super(params); super(params);
} }
@ -811,20 +813,20 @@ public abstract class LayoutPage extends BasePage {
}); });
var chat = new ChatPanel("chat"); chatter = new ChatPanel("chat");
add(chat); add(chatter);
topbar.add(new AjaxLink<Void>("showChat") { topbar.add(new AjaxLink<Void>("showChat") {
@Override @Override
public void onClick(AjaxRequestTarget target) { public void onClick(AjaxRequestTarget target) {
chat.show(target); chatter.show(target, null);
} }
@Override @Override
protected void onConfigure() { protected void onConfigure() {
super.onConfigure(); super.onConfigure();
setVisible(getLoginUser() != null && !getLoginUser().getEntitledAis().isEmpty()); setVisible(!chatter.getEntitledAis().isEmpty());
} }
}); });
@ -1287,6 +1289,10 @@ public abstract class LayoutPage extends BasePage {
response.render(OnLoadHeaderItem.forScript("onedev.server.layout.onLoad();")); response.render(OnLoadHeaderItem.forScript("onedev.server.layout.onLoad();"));
} }
public ChatPanel getChatter() {
return chatter;
}
protected List<SidebarMenu> getSidebarMenus() { protected List<SidebarMenu> getSidebarMenus() {
return Lists.newArrayList(); return Lists.newArrayList();
} }

View File

@ -7,6 +7,7 @@ import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import javax.inject.Inject;
import javax.persistence.EntityNotFoundException; import javax.persistence.EntityNotFoundException;
import javax.validation.ValidationException; import javax.validation.ValidationException;
@ -31,14 +32,15 @@ import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.flow.RedirectToUrlException; import org.apache.wicket.request.flow.RedirectToUrlException;
import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.mapper.parameter.PageParameters;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import io.onedev.server.OneDev; import dev.langchain4j.agent.tool.ToolSpecification;
import io.onedev.server.ai.IssueHelper;
import io.onedev.server.buildspecmodel.inputspec.InputContext; import io.onedev.server.buildspecmodel.inputspec.InputContext;
import io.onedev.server.data.migration.VersionedXmlDoc; import io.onedev.server.data.migration.VersionedXmlDoc;
import io.onedev.server.service.IssueLinkService;
import io.onedev.server.service.IssueService;
import io.onedev.server.service.SettingService;
import io.onedev.server.model.Issue; import io.onedev.server.model.Issue;
import io.onedev.server.model.Project; import io.onedev.server.model.Project;
import io.onedev.server.model.support.issue.field.spec.FieldSpec; import io.onedev.server.model.support.issue.field.spec.FieldSpec;
@ -46,6 +48,10 @@ import io.onedev.server.search.entity.EntityQuery;
import io.onedev.server.search.entity.issue.IssueQuery; import io.onedev.server.search.entity.issue.IssueQuery;
import io.onedev.server.search.entity.issue.IssueQueryParseOption; import io.onedev.server.search.entity.issue.IssueQueryParseOption;
import io.onedev.server.security.SecurityUtils; import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.IssueLinkService;
import io.onedev.server.service.IssueService;
import io.onedev.server.service.SettingService;
import io.onedev.server.service.support.ChatTool;
import io.onedev.server.util.ProjectScope; import io.onedev.server.util.ProjectScope;
import io.onedev.server.web.WebSession; import io.onedev.server.web.WebSession;
import io.onedev.server.web.behavior.ChangeObserver; import io.onedev.server.web.behavior.ChangeObserver;
@ -65,18 +71,34 @@ import io.onedev.server.web.page.project.ProjectPage;
import io.onedev.server.web.page.project.dashboard.ProjectDashboardPage; import io.onedev.server.web.page.project.dashboard.ProjectDashboardPage;
import io.onedev.server.web.page.project.issues.ProjectIssuesPage; import io.onedev.server.web.page.project.issues.ProjectIssuesPage;
import io.onedev.server.web.page.project.issues.list.ProjectIssueListPage; import io.onedev.server.web.page.project.issues.list.ProjectIssueListPage;
import io.onedev.server.web.util.ChatToolAware;
import io.onedev.server.web.util.ConfirmClickModifier; import io.onedev.server.web.util.ConfirmClickModifier;
import io.onedev.server.web.util.Cursor; import io.onedev.server.web.util.Cursor;
import io.onedev.server.web.util.CursorSupport; import io.onedev.server.web.util.CursorSupport;
import io.onedev.server.xodus.VisitInfoService; import io.onedev.server.xodus.VisitInfoService;
public abstract class IssueDetailPage extends ProjectIssuesPage implements InputContext { public abstract class IssueDetailPage extends ProjectIssuesPage implements InputContext, ChatToolAware {
public static final String PARAM_ISSUE = "issue"; public static final String PARAM_ISSUE = "issue";
private static final String KEY_SCROLL_TOP = "onedev.issue.scrollTop"; private static final String KEY_SCROLL_TOP = "onedev.issue.scrollTop";
protected final IModel<Issue> issueModel; protected final IModel<Issue> issueModel;
@Inject
private ObjectMapper objectMapper;
@Inject
private IssueService issueService;
@Inject
private IssueLinkService issueLinkService;
@Inject
private VisitInfoService visitInfoService;
@Inject
private SettingService settingService;
public IssueDetailPage(PageParameters params) { public IssueDetailPage(PageParameters params) {
super(params); super(params);
@ -98,11 +120,11 @@ public abstract class IssueDetailPage extends ProjectIssuesPage implements Input
throw new ValidationException(MessageFormat.format(_T("Invalid issue number: {0}"), issueNumberString)); throw new ValidationException(MessageFormat.format(_T("Invalid issue number: {0}"), issueNumberString));
} }
Issue issue = getIssueService().find(getProject(), issueNumber); Issue issue = issueService.find(getProject(), issueNumber);
if (issue == null) { if (issue == null) {
throw new EntityNotFoundException(MessageFormat.format(_T("Unable to find issue #{0} in project {1}"), issueNumber, getProject())); throw new EntityNotFoundException(MessageFormat.format(_T("Unable to find issue #{0} in project {1}"), issueNumber, getProject()));
} else { } else {
OneDev.getInstance(IssueLinkService.class).loadDeepLinks(issue); issueLinkService.loadDeepLinks(issue);
if (!issue.getProject().equals(getProject())) if (!issue.getProject().equals(getProject()))
throw new RestartResponseException(getPageClass(), paramsOf(issue)); throw new RestartResponseException(getPageClass(), paramsOf(issue));
return issue; return issue;
@ -293,7 +315,7 @@ public abstract class IssueDetailPage extends ProjectIssuesPage implements Input
@Override @Override
public void onClick() { public void onClick() {
getIssueService().delete(getIssue()); issueService.delete(getIssue());
var oldAuditContent = VersionedXmlDoc.fromBean(getIssue()).toXML(); var oldAuditContent = VersionedXmlDoc.fromBean(getIssue()).toXML();
auditService.audit(getIssue().getProject(), "deleted issue \"" + getIssue().getReference().toString(getIssue().getProject()) + "\"", oldAuditContent, null); auditService.audit(getIssue().getProject(), "deleted issue \"" + getIssue().getReference().toString(getIssue().getProject()) + "\"", oldAuditContent, null);
@ -331,7 +353,7 @@ public abstract class IssueDetailPage extends ProjectIssuesPage implements Input
@Override @Override
protected List<Issue> query(EntityQuery<Issue> query, int offset, int count, ProjectScope projectScope) { protected List<Issue> query(EntityQuery<Issue> query, int offset, int count, ProjectScope projectScope) {
return getIssueService().query(SecurityUtils.getSubject(), projectScope, query, false, offset, count); return issueService.query(SecurityUtils.getSubject(), projectScope, query, false, offset, count);
} }
@Override @Override
@ -362,12 +384,58 @@ public abstract class IssueDetailPage extends ProjectIssuesPage implements Input
@Override @Override
public void onEndRequest(RequestCycle cycle) { public void onEndRequest(RequestCycle cycle) {
if (SecurityUtils.getAuthUser() != null) if (SecurityUtils.getAuthUser() != null)
OneDev.getInstance(VisitInfoService.class).visitIssue(SecurityUtils.getAuthUser(), getIssue()); visitInfoService.visitIssue(SecurityUtils.getAuthUser(), getIssue());
} }
}); });
} }
@Override
public List<ChatTool> getChatTools() {
var issueId = getIssue().getId();
return List.of(new ChatTool() {
@Override
public ToolSpecification getSpecification() {
return ToolSpecification.builder()
.name("getCurrentIssue")
.description("Get info of current issue in json format")
.build();
}
@Override
public String execute(JsonNode arguments) {
var issue = issueService.load(issueId);
try {
return objectMapper.writeValueAsString(IssueHelper.getDetail(issue.getProject(), issue));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}, new ChatTool() {
@Override
public ToolSpecification getSpecification() {
return ToolSpecification.builder()
.name("getCurrentIssueComments")
.description("Get comments of current issue in json format")
.build();
}
@Override
public String execute(JsonNode arguments) {
var issue = issueService.load(issueId);
try {
return objectMapper.writeValueAsString(IssueHelper.getComments(issue));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
});
}
@Override @Override
protected void onDetach() { protected void onDetach() {
@ -392,13 +460,9 @@ public abstract class IssueDetailPage extends ProjectIssuesPage implements Input
@Override @Override
public FieldSpec getInputSpec(String inputName) { public FieldSpec getInputSpec(String inputName) {
return OneDev.getInstance(SettingService.class).getIssueSetting().getFieldSpec(inputName); return settingService.getIssueSetting().getFieldSpec(inputName);
} }
private IssueService getIssueService() {
return OneDev.getInstance(IssueService.class);
}
@Override @Override
protected Component newProjectTitle(String componentId) { protected Component newProjectTitle(String componentId) {
Fragment fragment = new Fragment(componentId, "projectTitleFrag", this); Fragment fragment = new Fragment(componentId, "projectTitleFrag", this);

View File

@ -17,9 +17,11 @@ import java.util.Comparator;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.inject.Inject;
import javax.persistence.EntityNotFoundException; import javax.persistence.EntityNotFoundException;
import javax.validation.ValidationException; import javax.validation.ValidationException;
@ -64,21 +66,16 @@ import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectId;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Sets; import com.google.common.collect.Sets;
import io.onedev.server.OneDev; import dev.langchain4j.agent.tool.ToolSpecification;
import io.onedev.server.ai.PullRequestHelper;
import io.onedev.server.attachment.AttachmentSupport; import io.onedev.server.attachment.AttachmentSupport;
import io.onedev.server.attachment.ProjectAttachmentSupport; import io.onedev.server.attachment.ProjectAttachmentSupport;
import io.onedev.server.data.migration.VersionedXmlDoc; import io.onedev.server.data.migration.VersionedXmlDoc;
import io.onedev.server.service.PullRequestAssignmentService;
import io.onedev.server.service.PullRequestChangeService;
import io.onedev.server.service.PullRequestLabelService;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.service.PullRequestReactionService;
import io.onedev.server.service.PullRequestReviewService;
import io.onedev.server.service.PullRequestWatchService;
import io.onedev.server.service.SettingService;
import io.onedev.server.service.UserService;
import io.onedev.server.entityreference.EntityReference; import io.onedev.server.entityreference.EntityReference;
import io.onedev.server.entityreference.LinkTransformer; import io.onedev.server.entityreference.LinkTransformer;
import io.onedev.server.git.GitUtils; import io.onedev.server.git.GitUtils;
@ -106,6 +103,16 @@ import io.onedev.server.search.entity.pullrequest.PullRequestQuery;
import io.onedev.server.security.SecurityUtils; import io.onedev.server.security.SecurityUtils;
import io.onedev.server.security.permission.ProjectPermission; import io.onedev.server.security.permission.ProjectPermission;
import io.onedev.server.security.permission.ReadCode; import io.onedev.server.security.permission.ReadCode;
import io.onedev.server.service.PullRequestAssignmentService;
import io.onedev.server.service.PullRequestChangeService;
import io.onedev.server.service.PullRequestLabelService;
import io.onedev.server.service.PullRequestReactionService;
import io.onedev.server.service.PullRequestReviewService;
import io.onedev.server.service.PullRequestService;
import io.onedev.server.service.PullRequestWatchService;
import io.onedev.server.service.SettingService;
import io.onedev.server.service.UserService;
import io.onedev.server.service.support.ChatTool;
import io.onedev.server.util.DateUtils; import io.onedev.server.util.DateUtils;
import io.onedev.server.util.ProjectScope; import io.onedev.server.util.ProjectScope;
import io.onedev.server.web.WebSession; import io.onedev.server.web.WebSession;
@ -159,6 +166,7 @@ import io.onedev.server.web.page.project.pullrequests.detail.changes.PullRequest
import io.onedev.server.web.page.project.pullrequests.detail.codecomments.PullRequestCodeCommentsPage; import io.onedev.server.web.page.project.pullrequests.detail.codecomments.PullRequestCodeCommentsPage;
import io.onedev.server.web.page.project.pullrequests.detail.operationdlg.MergePullRequestOptionPanel; import io.onedev.server.web.page.project.pullrequests.detail.operationdlg.MergePullRequestOptionPanel;
import io.onedev.server.web.page.project.pullrequests.detail.operationdlg.OperationCommentPanel; import io.onedev.server.web.page.project.pullrequests.detail.operationdlg.OperationCommentPanel;
import io.onedev.server.web.util.ChatToolAware;
import io.onedev.server.web.util.ConfirmClickModifier; import io.onedev.server.web.util.ConfirmClickModifier;
import io.onedev.server.web.util.Cursor; import io.onedev.server.web.util.Cursor;
import io.onedev.server.web.util.CursorSupport; import io.onedev.server.web.util.CursorSupport;
@ -169,7 +177,7 @@ import io.onedev.server.web.util.editbean.CommitMessageBean;
import io.onedev.server.web.util.editbean.LabelsBean; import io.onedev.server.web.util.editbean.LabelsBean;
import io.onedev.server.xodus.VisitInfoService; import io.onedev.server.xodus.VisitInfoService;
public abstract class PullRequestDetailPage extends ProjectPage implements PullRequestAware { public abstract class PullRequestDetailPage extends ProjectPage implements PullRequestAware, ChatToolAware {
public static final String PARAM_REQUEST = "request"; public static final String PARAM_REQUEST = "request";
@ -185,6 +193,45 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
private MergeStrategy mergeStrategy; private MergeStrategy mergeStrategy;
@Inject
private ObjectMapper objectMapper;
@Inject
private PullRequestService pullRequestService;
@Inject
private GitService gitService;
@Inject
private PullRequestReviewService pullRequestReviewService;
@Inject
private PullRequestChangeService pullRequestChangeService;
@Inject
private PullRequestAssignmentService pullRequestAssignmentService;
@Inject
private PullRequestLabelService pullRequestLabelService;
@Inject
private PullRequestWatchService pullRequestWatchService;
@Inject
private PullRequestReactionService pullRequestReactionService;
@Inject
private UserService userService;
@Inject
private SettingService settingService;
@Inject
private VisitInfoService visitInfoService;
@Inject
private Set<PullRequestSummaryContribution> summaryContributions;
public PullRequestDetailPage(PageParameters params) { public PullRequestDetailPage(PageParameters params) {
super(params); super(params);
@ -205,7 +252,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
throw new ValidationException(MessageFormat.format(_T("Invalid pull request number: {0}"), requestNumberString)); throw new ValidationException(MessageFormat.format(_T("Invalid pull request number: {0}"), requestNumberString));
} }
PullRequest request = getPullRequestService().find(getProject(), requestNumber); PullRequest request = pullRequestService.find(getProject(), requestNumber);
if (request == null) { if (request == null) {
throw new EntityNotFoundException(MessageFormat.format(_T("Unable to find pull request #{0} in project {1}"), requestNumber, getProject().getPath())); throw new EntityNotFoundException(MessageFormat.format(_T("Unable to find pull request #{0} in project {1}"), requestNumber, getProject().getPath()));
} else if (!request.getTargetProject().equals(getProject())) { } else if (!request.getTargetProject().equals(getProject())) {
@ -225,22 +272,6 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
latestUpdateId = requestModel.getObject().getLatestUpdate().getId(); latestUpdateId = requestModel.getObject().getLatestUpdate().getId();
} }
private GitService getGitService() {
return OneDev.getInstance(GitService.class);
}
private PullRequestService getPullRequestService() {
return OneDev.getInstance(PullRequestService.class);
}
private PullRequestReviewService getPullRequestReviewService() {
return OneDev.getInstance(PullRequestReviewService.class);
}
private PullRequestChangeService getPullRequestChangeService() {
return OneDev.getInstance(PullRequestChangeService.class);
}
private WebMarkupContainer newRequestHead() { private WebMarkupContainer newRequestHead() {
WebMarkupContainer requestHead = new WebMarkupContainer("requestHeader"); WebMarkupContainer requestHead = new WebMarkupContainer("requestHeader");
requestHead.setOutputMarkupId(true); requestHead.setOutputMarkupId(true);
@ -340,7 +371,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
super.onSubmit(target, form); super.onSubmit(target, form);
var user = SecurityUtils.getUser(); var user = SecurityUtils.getUser();
OneDev.getInstance(PullRequestChangeService.class).changeTitle(user, getPullRequest(), title); pullRequestChangeService.changeTitle(user, getPullRequest(), title);
notifyPullRequestChange(target); notifyPullRequestChange(target);
isEditingTitle = false; isEditingTitle = false;
@ -919,7 +950,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
public void onEndRequest(RequestCycle cycle) { public void onEndRequest(RequestCycle cycle) {
if (SecurityUtils.getAuthUser() != null) if (SecurityUtils.getAuthUser() != null)
OneDev.getInstance(VisitInfoService.class).visitPullRequest(SecurityUtils.getAuthUser(), getPullRequest()); visitInfoService.visitPullRequest(SecurityUtils.getAuthUser(), getPullRequest());
} }
}); });
@ -980,7 +1011,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
public void onClick(AjaxRequestTarget target) { public void onClick(AjaxRequestTarget target) {
var user = SecurityUtils.getUser(); var user = SecurityUtils.getUser();
OneDev.getInstance(PullRequestChangeService.class).changeTargetBranch(user, getPullRequest(), branch); pullRequestChangeService.changeTargetBranch(user, getPullRequest(), branch);
notifyPullRequestChange(target); notifyPullRequestChange(target);
dropdown.close(); dropdown.close();
} }
@ -1087,7 +1118,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
var assignment = new PullRequestAssignment(); var assignment = new PullRequestAssignment();
assignment.setRequest(getPullRequest()); assignment.setRequest(getPullRequest());
assignment.setUser(SecurityUtils.getUser()); assignment.setUser(SecurityUtils.getUser());
OneDev.getInstance(PullRequestAssignmentService.class).create(assignment); pullRequestAssignmentService.create(assignment);
((BasePage)getPage()).notifyObservableChange(target, ((BasePage)getPage()).notifyObservableChange(target,
PullRequest.getChangeObservable(getPullRequest().getId())); PullRequest.getChangeObservable(getPullRequest().getId()));
} }
@ -1137,7 +1168,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected void onUpdated(IPartialPageRequestHandler handler, Serializable bean, String propertyName) { protected void onUpdated(IPartialPageRequestHandler handler, Serializable bean, String propertyName) {
LabelsBean labelsBean = (LabelsBean) bean; LabelsBean labelsBean = (LabelsBean) bean;
OneDev.getInstance(PullRequestLabelService.class).sync(getPullRequest(), labelsBean.getLabels()); pullRequestLabelService.sync(getPullRequest(), labelsBean.getLabels());
handler.add(labelsContainer); handler.add(labelsContainer);
} }
@ -1184,12 +1215,12 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected void onSaveWatch(EntityWatch watch) { protected void onSaveWatch(EntityWatch watch) {
OneDev.getInstance(PullRequestWatchService.class).createOrUpdate((PullRequestWatch) watch); pullRequestWatchService.createOrUpdate((PullRequestWatch) watch);
} }
@Override @Override
protected void onDeleteWatch(EntityWatch watch) { protected void onDeleteWatch(EntityWatch watch) {
OneDev.getInstance(PullRequestWatchService.class).delete((PullRequestWatch) watch); pullRequestWatchService.delete((PullRequestWatch) watch);
} }
@Override @Override
@ -1217,7 +1248,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
public void onClick(AjaxRequestTarget target) { public void onClick(AjaxRequestTarget target) {
getPullRequestService().checkAsync(getPullRequest(), false, true); pullRequestService.checkAsync(getPullRequest(), false, true);
Session.get().success(_T("Pull request synchronization submitted")); Session.get().success(_T("Pull request synchronization submitted"));
} }
@ -1227,7 +1258,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
public void onClick() { public void onClick() {
PullRequest request = getPullRequest(); PullRequest request = getPullRequest();
getPullRequestService().delete(request); pullRequestService.delete(request);
var oldAuditContent = VersionedXmlDoc.fromBean(request).toXML(); var oldAuditContent = VersionedXmlDoc.fromBean(request).toXML();
auditService.audit(request.getProject(), "deleted pull request \"" + request.getReference().toString(request.getProject()) + "\"", oldAuditContent, null); auditService.audit(request.getProject(), "deleted pull request \"" + request.getReference().toString(request.getProject()) + "\"", oldAuditContent, null);
@ -1278,7 +1309,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected List<PullRequest> query(EntityQuery<PullRequest> query, protected List<PullRequest> query(EntityQuery<PullRequest> query,
int offset, int count, ProjectScope projectScope) { int offset, int count, ProjectScope projectScope) {
return getPullRequestService().query(SecurityUtils.getSubject(), projectScope!=null?projectScope.getProject():null, query, false, offset, count); return pullRequestService.query(SecurityUtils.getSubject(), projectScope!=null?projectScope.getProject():null, query, false, offset, count);
} }
@Override @Override
@ -1347,7 +1378,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected void onUpdate(AjaxRequestTarget target) { protected void onUpdate(AjaxRequestTarget target) {
var user = SecurityUtils.getUser(); var user = SecurityUtils.getUser();
OneDev.getInstance(PullRequestChangeService.class).changeMergeStrategy(user, getPullRequest(), mergeStrategy); pullRequestChangeService.changeMergeStrategy(user, getPullRequest(), mergeStrategy);
notifyPullRequestChange(target); notifyPullRequestChange(target);
} }
@ -1420,7 +1451,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected String onSave(AjaxRequestTarget target, CommitMessageBean bean) { protected String onSave(AjaxRequestTarget target, CommitMessageBean bean) {
var request = getPullRequest(); var request = getPullRequest();
var system = OneDev.getInstance(UserService.class).getSystem(); var system = userService.getSystem();
var branchProtection = getProject().getBranchProtection(request.getTargetBranch(), system); var branchProtection = getProject().getBranchProtection(request.getTargetBranch(), system);
var errorMessage = branchProtection.checkCommitMessage(bean.getCommitMessage(), var errorMessage = branchProtection.checkCommitMessage(bean.getCommitMessage(),
request.getMergeStrategy() != SQUASH_SOURCE_BRANCH_COMMITS); request.getMergeStrategy() != SQUASH_SOURCE_BRANCH_COMMITS);
@ -1429,10 +1460,10 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
} else { } else {
autoMerge.setCommitMessage(bean.getCommitMessage()); autoMerge.setCommitMessage(bean.getCommitMessage());
var user = SecurityUtils.getUser(); var user = SecurityUtils.getUser();
getPullRequestChangeService().changeAutoMerge(user, request, autoMerge); pullRequestChangeService.changeAutoMerge(user, request, autoMerge);
Session.get().success(_T("Preset commit message updated")); Session.get().success(_T("Preset commit message updated"));
close(); close();
getPullRequestService().checkAutoMerge(request); pullRequestService.checkAutoMerge(request);
return null; return null;
} }
} }
@ -1530,7 +1561,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
var autoMerge = new AutoMerge(); var autoMerge = new AutoMerge();
autoMerge.setEnabled(true); autoMerge.setEnabled(true);
autoMerge.setCommitMessage(bean.getCommitMessage()); autoMerge.setCommitMessage(bean.getCommitMessage());
getPullRequestChangeService().changeAutoMerge(user, getPullRequest(), autoMerge); pullRequestChangeService.changeAutoMerge(user, getPullRequest(), autoMerge);
target.add(autoMergeContainer); target.add(autoMergeContainer);
close(); close();
return null; return null;
@ -1552,13 +1583,13 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
var autoMerge = new AutoMerge(); var autoMerge = new AutoMerge();
autoMerge.setEnabled(true); autoMerge.setEnabled(true);
autoMerge.setCommitMessage(request.getAutoMerge().getCommitMessage()); autoMerge.setCommitMessage(request.getAutoMerge().getCommitMessage());
getPullRequestChangeService().changeAutoMerge(user, request, autoMerge); pullRequestChangeService.changeAutoMerge(user, request, autoMerge);
target.add(autoMergeContainer); target.add(autoMergeContainer);
} }
} else { } else {
var autoMerge = new AutoMerge(); var autoMerge = new AutoMerge();
autoMerge.setCommitMessage(request.getAutoMerge().getCommitMessage()); autoMerge.setCommitMessage(request.getAutoMerge().getCommitMessage());
getPullRequestChangeService().changeAutoMerge(user, request, autoMerge); pullRequestChangeService.changeAutoMerge(user, request, autoMerge);
target.add(autoMergeContainer); target.add(autoMergeContainer);
} }
} }
@ -1683,7 +1714,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected void onSaveComment(AjaxRequestTarget target, String comment) { protected void onSaveComment(AjaxRequestTarget target, String comment) {
var user = SecurityUtils.getUser(); var user = SecurityUtils.getUser();
OneDev.getInstance(PullRequestChangeService.class).changeDescription(user, getPullRequest(), comment); pullRequestChangeService.changeDescription(user, getPullRequest(), comment);
((BasePage)getPage()).notifyObservableChange(target, ((BasePage)getPage()).notifyObservableChange(target,
PullRequest.getChangeObservable(getPullRequest().getId())); PullRequest.getChangeObservable(getPullRequest().getId()));
} }
@ -1753,10 +1784,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
public void onToggleEmoji(AjaxRequestTarget target, String emoji) { public void onToggleEmoji(AjaxRequestTarget target, String emoji) {
OneDev.getInstance(PullRequestReactionService.class).toggleEmoji( pullRequestReactionService.toggleEmoji(SecurityUtils.getUser(), getPullRequest(), emoji);
SecurityUtils.getUser(),
getPullRequest(),
emoji);
} }
}; };
} }
@ -1799,8 +1827,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected List<PullRequestSummaryPart> load() { protected List<PullRequestSummaryPart> load() {
List<PullRequestSummaryContribution> contributions = List<PullRequestSummaryContribution> contributions = new ArrayList<>(summaryContributions);
new ArrayList<>(OneDev.getExtensions(PullRequestSummaryContribution.class));
contributions.sort(Comparator.comparing(PullRequestSummaryContribution::getOrder)); contributions.sort(Comparator.comparing(PullRequestSummaryContribution::getOrder));
List<PullRequestSummaryPart> parts = new ArrayList<>(); List<PullRequestSummaryPart> parts = new ArrayList<>();
@ -1889,7 +1916,6 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
return errorMessage; return errorMessage;
if (targetHead.equals(request.getTarget().getObjectId()) if (targetHead.equals(request.getTarget().getObjectId())
&& sourceHead.equals(request.getSourceHead())) { && sourceHead.equals(request.getSourceHead())) {
var gitService = OneDev.getInstance(GitService.class);
var amendedCommitId = gitService.amendCommit(request.getProject(), mergeCommit.copy(), var amendedCommitId = gitService.amendCommit(request.getProject(), mergeCommit.copy(),
mergeCommit.getAuthorIdent(), mergeCommit.getCommitterIdent(), mergeCommit.getAuthorIdent(), mergeCommit.getCommitterIdent(),
bean.getCommitMessage()); bean.getCommitMessage());
@ -2001,7 +2027,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected String operate(AjaxRequestTarget target) { protected String operate(AjaxRequestTarget target) {
if (canOperate()) { if (canOperate()) {
getPullRequestReviewService().review(SecurityUtils.getUser(), getPullRequest(), true, getComment()); pullRequestReviewService.review(SecurityUtils.getUser(), getPullRequest(), true, getComment());
notifyPullRequestChange(target); notifyPullRequestChange(target);
Session.get().success(_T("Approved")); Session.get().success(_T("Approved"));
return null; return null;
@ -2044,7 +2070,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected String operate(AjaxRequestTarget target) { protected String operate(AjaxRequestTarget target) {
if (canOperate()) { if (canOperate()) {
getPullRequestReviewService().review(SecurityUtils.getUser(), getPullRequest(), false, getComment()); pullRequestReviewService.review(SecurityUtils.getUser(), getPullRequest(), false, getComment());
notifyPullRequestChange(target); notifyPullRequestChange(target);
Session.get().success(_T("Requested For changes")); Session.get().success(_T("Requested For changes"));
return null; return null;
@ -2091,7 +2117,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
if (errorMessage != null) if (errorMessage != null)
return errorMessage; return errorMessage;
} }
getPullRequestService().merge(SecurityUtils.getUser(), getPullRequest(), commitMessage); pullRequestService.merge(SecurityUtils.getUser(), getPullRequest(), commitMessage);
notifyPullRequestChange(target); notifyPullRequestChange(target);
return null; return null;
} else { } else {
@ -2123,7 +2149,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected String operate(AjaxRequestTarget target) { protected String operate(AjaxRequestTarget target) {
if (canOperate()) { if (canOperate()) {
getPullRequestService().discard(SecurityUtils.getUser(), getPullRequest(), getComment()); pullRequestService.discard(SecurityUtils.getUser(), getPullRequest(), getComment());
notifyPullRequestChange(target); notifyPullRequestChange(target);
return null; return null;
} else { } else {
@ -2160,7 +2186,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected String operate(AjaxRequestTarget target) { protected String operate(AjaxRequestTarget target) {
if (canOperate()) { if (canOperate()) {
getPullRequestService().reopen(SecurityUtils.getUser(), getPullRequest(), getComment()); pullRequestService.reopen(SecurityUtils.getUser(), getPullRequest(), getComment());
notifyPullRequestChange(target); notifyPullRequestChange(target);
return null; return null;
} else { } else {
@ -2199,7 +2225,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected String operate(AjaxRequestTarget target) { protected String operate(AjaxRequestTarget target) {
if (canOperate()) { if (canOperate()) {
getPullRequestService().deleteSourceBranch(SecurityUtils.getUser(), getPullRequest(), getComment()); pullRequestService.deleteSourceBranch(SecurityUtils.getUser(), getPullRequest(), getComment());
notifyPullRequestChange(target); notifyPullRequestChange(target);
Session.get().success(_T("Deleted source branch")); Session.get().success(_T("Deleted source branch"));
return null; return null;
@ -2239,7 +2265,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
@Override @Override
protected String operate(AjaxRequestTarget target) { protected String operate(AjaxRequestTarget target) {
if (canOperate()) { if (canOperate()) {
getPullRequestService().restoreSourceBranch(SecurityUtils.getUser(), getPullRequest(), getComment()); pullRequestService.restoreSourceBranch(SecurityUtils.getUser(), getPullRequest(), getComment());
notifyPullRequestChange(target); notifyPullRequestChange(target);
Session.get().success(_T("Restored source branch")); Session.get().success(_T("Restored source branch"));
return null; return null;
@ -2274,7 +2300,6 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
ObjectId targetHead = request.getTarget().getObjectId(); ObjectId targetHead = request.getTarget().getObjectId();
ObjectId mergeCommitId; ObjectId mergeCommitId;
var gitService = getGitService();
if (updateByMerge) { if (updateByMerge) {
String commitMessage; String commitMessage;
if (!request.getSourceProject().equals(project)) { if (!request.getSourceProject().equals(project)) {
@ -2300,8 +2325,7 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
&& !project.hasValidCommitSignature(project.getRevCommit(targetHead, true))) { && !project.hasValidCommitSignature(project.getRevCommit(targetHead, true))) {
return _T("No valid signature for head commit of target branch"); return _T("No valid signature for head commit of target branch");
} }
if (protection.isCommitSignatureRequired() if (protection.isCommitSignatureRequired() && settingService.getGpgSetting().getSigningKey() == null) {
&& OneDev.getInstance(SettingService.class).getGpgSetting().getSigningKey() == null) {
return _T("Commit signature required but no GPG signing key specified"); return _T("Commit signature required but no GPG signing key specified");
} }
var error = gitService.checkCommitMessages(protection, project, sourceHead, mergeCommitId, new HashMap<>()); var error = gitService.checkCommitMessages(protection, project, sourceHead, mergeCommitId, new HashMap<>());
@ -2313,11 +2337,10 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
private void updateSourceBranch(ObjectId commitId) { private void updateSourceBranch(ObjectId commitId) {
var request = getPullRequest(); var request = getPullRequest();
var gitService = getGitService();
if (!request.getSourceProject().equals(request.getTargetProject())) if (!request.getSourceProject().equals(request.getTargetProject()))
gitService.fetch(request.getSourceProject(), request.getTargetProject(), commitId.name()); gitService.fetch(request.getSourceProject(), request.getTargetProject(), commitId.name());
var oldCommitId = request.getSourceHead(); var oldCommitId = request.getSourceHead();
getGitService().updateRef(request.getSourceProject(), request.getSourceRef(), commitId, oldCommitId); gitService.updateRef(request.getSourceProject(), request.getSourceRef(), commitId, oldCommitId);
} }
@Override @Override
@ -2326,6 +2349,52 @@ public abstract class PullRequestDetailPage extends ProjectPage implements PullR
super.onDetach(); super.onDetach();
} }
@Override
public List<ChatTool> getChatTools() {
var pullRequestId = getPullRequest().getId();
return List.of(new ChatTool() {
@Override
public ToolSpecification getSpecification() {
return ToolSpecification.builder()
.name("getCurrentPullRequest")
.description("Get info of current pull request in json format")
.build();
}
@Override
public String execute(JsonNode arguments) {
var pullRequest = pullRequestService.load(pullRequestId);
try {
return objectMapper.writeValueAsString(PullRequestHelper.getDetail(pullRequest.getProject(), pullRequest));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}, new ChatTool() {
@Override
public ToolSpecification getSpecification() {
return ToolSpecification.builder()
.name("getCurrentPullRequestComments")
.description("Get comments of current pull request in json format")
.build();
}
@Override
public String execute(JsonNode arguments) {
var pullRequest = pullRequestService.load(pullRequestId);
try {
return objectMapper.writeValueAsString(PullRequestHelper.getComments(pullRequest));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
});
}
public static PageParameters paramsOf(PullRequest pullRequest) { public static PageParameters paramsOf(PullRequest pullRequest) {
return paramsOf(pullRequest.getProject(), pullRequest.getNumber()); return paramsOf(pullRequest.getProject(), pullRequest.getNumber());
} }

View File

@ -22,10 +22,11 @@
</div> </div>
</wicket:fragment> </wicket:fragment>
<wicket:fragment wicket:id="optionsFrag"> <wicket:fragment wicket:id="optionsFrag">
<div class="pull-request-activity-options"> <a wicket:id="aiSummarize" t:data-tippy-content="Summarize comments with AI" class="btn btn-xs btn-outline-secondary btn-hover-primary btn-icon"><wicket:svg href="ai-summarize" class="icon"></wicket:svg></a>
<a wicket:id="showComments" t:data-tippy-content="Toggle comments" class="btn btn-xs text-muted btn-active-secondary btn-icon"><wicket:svg href="comments" class="icon"></wicket:svg></a> <div class="btn-group">
<a wicket:id="showCommits" t:data-tippy-content="Toggle commits" class="btn btn-xs text-muted btn-active-secondary btn-icon"><wicket:svg href="commit" class="icon"></wicket:svg></a> <a wicket:id="showComments" t:data-tippy-content="Toggle comments" class="btn btn-xs text-muted btn-outline-secondary btn-active-secondary btn-icon"><wicket:svg href="comments" class="icon"></wicket:svg></a>
<a wicket:id="showChangeHistory" t:data-tippy-content="Toggle change history" class="btn btn-xs text-muted btn-active-secondary btn-icon"><wicket:svg href="history" class="icon"></wicket:svg></a> <a wicket:id="showCommits" t:data-tippy-content="Toggle commits" class="btn btn-xs text-muted btn-outline-secondary btn-active-secondary btn-icon"><wicket:svg href="commit" class="icon"></wicket:svg></a>
<a wicket:id="showChangeHistory" t:data-tippy-content="Toggle change history" class="btn btn-xs text-muted btn-outline-secondary btn-active-secondary btn-icon"><wicket:svg href="history" class="icon"></wicket:svg></a>
</div> </div>
</wicket:fragment> </wicket:fragment>
</wicket:extend> </wicket:extend>

View File

@ -61,6 +61,7 @@ import io.onedev.server.web.ajaxlistener.ConfirmLeaveListener;
import io.onedev.server.web.behavior.ChangeObserver; import io.onedev.server.web.behavior.ChangeObserver;
import io.onedev.server.web.component.comment.CommentInput; import io.onedev.server.web.component.comment.CommentInput;
import io.onedev.server.web.page.base.BasePage; import io.onedev.server.web.page.base.BasePage;
import io.onedev.server.web.page.layout.LayoutPage;
import io.onedev.server.web.page.project.pullrequests.detail.PullRequestDetailPage; import io.onedev.server.web.page.project.pullrequests.detail.PullRequestDetailPage;
import io.onedev.server.web.page.project.pullrequests.detail.activities.activity.PullRequestChangeActivity; import io.onedev.server.web.page.project.pullrequests.detail.activities.activity.PullRequestChangeActivity;
import io.onedev.server.web.page.project.pullrequests.detail.activities.activity.PullRequestCommentActivity; import io.onedev.server.web.page.project.pullrequests.detail.activities.activity.PullRequestCommentActivity;
@ -409,6 +410,24 @@ public class PullRequestActivitiesPage extends PullRequestDetailPage {
public Component renderOptions(String componentId) { public Component renderOptions(String componentId) {
Fragment fragment = new Fragment(componentId, "optionsFrag", this); Fragment fragment = new Fragment(componentId, "optionsFrag", this);
fragment.add(new AjaxLink<Void>("aiSummarize") {
@Override
public void onClick(AjaxRequestTarget target) {
var page = (LayoutPage)getPage();
page.getChatter().show(target, "Summarize comments of current pull request. Display summary in " + getSession().getLocale().getDisplayLanguage());
}
@Override
protected void onConfigure() {
super.onConfigure();
var page = (LayoutPage)getPage();
setVisible(!page.getChatter().getEntitledAis().isEmpty());
}
});
fragment.add(showCommentsLink = new AjaxLink<Void>("showComments") { fragment.add(showCommentsLink = new AjaxLink<Void>("showComments") {
@Override @Override

View File

@ -0,0 +1,11 @@
package io.onedev.server.web.util;
import java.util.List;
import io.onedev.server.service.support.ChatTool;
public interface ChatToolAware {
List<ChatTool> getChatTools();
}