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) {
@ -733,29 +732,6 @@ public class McpHelperResource {
} }
} }
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
public Map<String, Object> getIssue( public Map<String, Object> getIssue(
@ -766,30 +742,7 @@ public class McpHelperResource {
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,27 +143,34 @@ 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()); .collect(Collectors.toList()));
var future = executorService.submit(() -> { var toolSpecifications = tools.stream()
var latch = new CountDownLatch(1); .map(ChatTool::getSpecification)
var handler = new StreamingChatResponseHandler() { .collect(Collectors.toList());
var completableFuture = new CompletableFuture<String>();
var executableFuture = executorService.submit(new Runnable() {
private StreamingChatResponseHandler newResponseHandler(Subject subject) {
return 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);
if (completableFuture.isDone()) {
context.streamingHandle().cancel(); context.streamingHandle().cancel();
} else { } else {
var responding = getResponding(sessionId, chatId, requestId); var responding = getResponding(sessionId, chatId, requestId);
@ -139,66 +190,156 @@ public class DefaultChatService extends BaseEntityService<Chat> implements ChatS
@Override @Override
public void onPartialThinking(PartialThinking partialThinking, PartialThinkingContext context) { public void onPartialThinking(PartialThinking partialThinking, PartialThinkingContext context) {
if (latch.getCount() == 0) ThreadContext.bind(subject);
if (completableFuture.isDone())
context.streamingHandle().cancel(); context.streamingHandle().cancel();
} }
@Override @Override
public void onPartialToolCall(PartialToolCall partialToolCall, PartialToolCallContext context) { public void onPartialToolCall(PartialToolCall partialToolCall, PartialToolCallContext context) {
if (latch.getCount() == 0) ThreadContext.bind(subject);
if (completableFuture.isDone()) {
context.streamingHandle().cancel(); context.streamingHandle().cancel();
} }
}
@Override @Override
public void onCompleteResponse(ChatResponse completeResponse) { public void onCompleteResponse(ChatResponse completeResponse) {
ThreadContext.bind(subject);
sessionService.runAsync(() -> {
try { try {
createResponseIfNecessary(sessionId, chatId, requestId, completeResponse.aiMessage().text(), null); var aiMessage = completeResponse.aiMessage();
} finally { if (aiMessage.hasToolExecutionRequests()) {
latch.countDown(); 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 @Override
public void onError(Throwable error) { public void onError(Throwable error) {
try { ThreadContext.bind(subject);
createResponseIfNecessary(sessionId, chatId, requestId, "Error getting chat response, check server log for details", error); completableFuture.completeExceptionally(error);
} finally {
latch.countDown();
}
} }
}; };
try { }
modelSetting.getStreamingChatModel().chat(langchain4jMessages, handler);
private void createResponse(String content, @Nullable Throwable throwable) {
var responding = getResponding(session.getId(), chatId, requestId);
if (responding != null) {
transactionService.run(() -> { transactionService.run(() -> {
var chat = load(chatId); 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()); var requests = chat.getMessages().stream().filter(it->it.isRequest()).collect(Collectors.toList());
if (requests.size() == 1) { if (requests.size() == 1) {
var systemPrompt = String.format(""" var systemPrompt = String.format("""
Summarize provided message to get a compact title with below requirements: Summarize provided message to get a compact title with below requirements:
1. It should be within %d characters 1. Just summarize the message itself, no need to ask user for clarification or confirmation
2. Only title is returned, no other text or comments 2. It should be within %d characters
""", Chat.MAX_TITLE_LEN); 3. Only title is returned, no other text or comments
var title = modelSetting.getChatModel().chat(new SystemMessage(systemPrompt), new UserMessage(requests.get(0).getContent())).aiMessage().text(); 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); chat.setTitle(title);
session.getAnonymousChats().put(chatId, chat);
} else {
chat.setTitle(title);
dao.persist(chat);
}
webSocketService.notifyObservableChange(Chat.getChangeObservable(chatId), null); webSocketService.notifyObservableChange(Chat.getChangeObservable(chatId), null);
} }
}); });
latch.await(); var response = completableFuture.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (Exception e) { if (StringUtils.isBlank(response))
if (ExceptionUtils.find(e, InterruptedException.class) == null) { 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); logger.error("Error getting chat response", e);
String errorMessage = e.getMessage(); String errorMessage = e.getMessage();
if (errorMessage == null) if (errorMessage == null)
errorMessage = "Error getting chat response, check server log for details"; errorMessage = "Error getting chat response, check server log for details";
createResponseIfNecessary(sessionId, chatId, requestId, errorMessage, e); createResponse(errorMessage, e);
} else {
var responding = getResponding(sessionId, chatId, requestId);
if (responding != null && responding.getContent() != null)
createResponseIfNecessary(sessionId, chatId, requestId, responding.getContent(), null);
} }
} finally { } finally {
latch.countDown(); completableFuture.cancel(false);
var respondingsOfSession = respondings.get(sessionId); var respondingsOfSession = respondings.get(sessionId);
if (respondingsOfSession != null) { if (respondingsOfSession != null) {
var responding = respondingsOfSession.get(chatId); var responding = respondingsOfSession.get(chatId);
@ -213,9 +354,10 @@ public class DefaultChatService extends BaseEntityService<Chat> implements ChatS
} }
} }
} }
}
}); });
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
@ -81,6 +91,23 @@ public class ChatPanel extends Panel {
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
@ -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) {
if (isVisible())
showNewMessages(handler); 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"));
if (getUser() != null)
chatService.delete(getActiveChat()); 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);
} }
@ -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());
if (getUser() != null) {
chatService.createOrUpdate(chat); chatService.createOrUpdate(chat);
WebSession.get().setActiveChatId(chat.getId());
target.add(chatSelectorContainer);
}
var request = new ChatMessage();
request.setChat(chat); request.setChat(chat);
request.setRequest(true);
request.setContent(input);
chat.getMessages().add(request); chat.getMessages().add(request);
dao.persist(request); dao.persist(request);
chatService.sendRequest(getSession().getId(), 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());
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);
}
}
List<ChatTool> chatTools = new ArrayList<>();
if (getPage() instanceof ChatToolAware)
chatTools.addAll(((ChatToolAware) getPage()).getChatTools());
getPage().visitChildren(ChatToolAware.class, new IVisitor<Component, Void>() {
@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,11 +520,14 @@ 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);
if ((Long) lastMessage.getDefaultModelObject() > prevLastMessageId) {
handler.appendJavaScript(String.format(""" handler.appendJavaScript(String.format("""
$('#%s')[0].scrollIntoView({ block: "end" }); $('#%s')[0].scrollIntoView({ block: "end" });
""", lastMessage.getMarkupId())); """, lastMessage.getMarkupId()));
} }
}
private Component newMessageContainer(String containerId, ChatMessage message) { private Component newMessageContainer(String containerId, ChatMessage message) {
var messageContainer = new WebMarkupContainer(containerId, Model.of(message.getId())); var messageContainer = new WebMarkupContainer(containerId, Model.of(message.getId()));
@ -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();
}
public void show(AjaxRequestTarget target, @Nullable String prompt) {
WebSession.get().setChatVisible(true); WebSession.get().setChatVisible(true);
WebSession.get().setActiveChatId(null); WebSession.get().setActiveChatId(null);
target.add(this); 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

@ -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,12 +71,13 @@ 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";
@ -78,6 +85,21 @@ public abstract class IssueDetailPage extends ProjectIssuesPage implements Input
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,13 +384,59 @@ 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() {
issueModel.detach(); issueModel.detach();
@ -392,11 +460,7 @@ 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

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();
}