feat: Accurate symbol navigation with help of AI (OD-2601)

This commit is contained in:
Robin Shen 2025-11-12 15:18:09 +08:00
parent b465a1fd78
commit 1eb8bda97e
20 changed files with 492 additions and 175 deletions

View File

@ -651,8 +651,8 @@
</repository>
</repositories>
<properties>
<commons.version>3.1.0</commons.version>
<agent.version>2.3.1</agent.version>
<commons.version>3.1.1</commons.version>
<agent.version>2.3.2</agent.version>
<slf4j.version>2.0.9</slf4j.version>
<logback.version>1.4.14</logback.version>
<antlr.version>4.7.2</antlr.version>

View File

@ -61,7 +61,7 @@ public class AIModelSetting implements Serializable {
this.apiKey = apiKey;
}
@Editable(order=400)
@Editable(order=400, name="Name")
@ChoiceProvider("getModels")
@NotEmpty
public String getName() {

View File

@ -12,30 +12,24 @@ import io.onedev.server.model.support.AIModelSetting;
public class AISetting implements Serializable {
private static final long serialVersionUID = 1L;
public static final String PROP_LITE_MODEL_SETTING = "liteModelSetting";
private AIModelSetting naturalLanguageQueryModelSetting;
private AIModelSetting liteModelSetting;
@Editable(order=100, name="Natural Language Query Model", description=
"""
If specified, one will be able to query issues, pull requests and builds via natural language. Suggested models in terms of performance and cost for this task:
<ul>
<li>Google/gemini-2.5-flash</li>
<li>OpenAI/gpt-4.1-mini</li>
<li>Qwen/Qwen-2.5-72B-instruct</li>
</ul>
""")
@Editable(order=100)
@Nullable
public AIModelSetting getNaturalLanguageQueryModelSetting() {
return naturalLanguageQueryModelSetting;
public AIModelSetting getLiteModelSetting() {
return liteModelSetting;
}
public void setNaturalLanguageQueryModelSetting(AIModelSetting naturalLanguageQueryModelSetting) {
this.naturalLanguageQueryModelSetting = naturalLanguageQueryModelSetting;
public void setLiteModelSetting(AIModelSetting liteModelSetting) {
this.liteModelSetting = liteModelSetting;
}
@Nullable
public ChatModel getNaturalLanguageQueryModel() {
return naturalLanguageQueryModelSetting != null ? naturalLanguageQueryModelSetting.getChatModel() : null;
public ChatModel getLiteModel() {
return liteModelSetting != null ? liteModelSetting.getChatModel() : null;
}
}

View File

@ -2,10 +2,9 @@ package io.onedev.server.search.code.hit;
import java.io.Serializable;
import org.jspecify.annotations.Nullable;
import org.apache.wicket.Component;
import org.apache.wicket.markup.html.image.Image;
import org.jspecify.annotations.Nullable;
import io.onedev.commons.utils.PlanarRange;

View File

@ -258,16 +258,16 @@ public class BuildQueryBehavior extends ANTLRAssistBehavior {
}
}
}
if (getSettingService().getAISetting().getNaturalLanguageQueryModelSetting() == null)
hints.add(_T("Set up AI to use natural language query"));
if (getSettingService().getAISetting().getLiteModelSetting() == null)
hints.add(_T("<a href='/~administration/settings/lite-ai-model' target='_blank'>Set up AI</a> to use natural language query"));
return hints;
}
@Override
protected NaturalLanguageTranslator getNaturalLanguageTranslator() {
var naturalLanguageQueryModel = getSettingService().getAISetting().getNaturalLanguageQueryModel();
if (naturalLanguageQueryModel != null) {
return new NaturalLanguageTranslator(naturalLanguageQueryModel) {
var liteModel = getSettingService().getAISetting().getLiteModel();
if (liteModel != null) {
return new NaturalLanguageTranslator(liteModel) {
@Override
public String getQueryDescription() {

View File

@ -432,8 +432,8 @@ public class IssueQueryBehavior extends ANTLRAssistBehavior {
}
}
}
if (getSettingService().getAISetting().getNaturalLanguageQueryModelSetting() == null)
hints.add(_T("Set up AI to use natural language query"));
if (getSettingService().getAISetting().getLiteModelSetting() == null)
hints.add(_T("<a href='/~administration/settings/lite-ai-model' target='_blank'>Set up AI</a> to use natural language query</a>"));
return hints;
}
@ -444,9 +444,9 @@ public class IssueQueryBehavior extends ANTLRAssistBehavior {
@Override
protected NaturalLanguageTranslator getNaturalLanguageTranslator() {
var naturalLanguageQueryModel = getSettingService().getAISetting().getNaturalLanguageQueryModel();
if (naturalLanguageQueryModel != null) {
return new NaturalLanguageTranslator(naturalLanguageQueryModel) {
var liteModel = getSettingService().getAISetting().getLiteModel();
if (liteModel != null) {
return new NaturalLanguageTranslator(liteModel) {
@Override
public String getQueryDescription() {

View File

@ -263,8 +263,8 @@ public class PullRequestQueryBehavior extends ANTLRAssistBehavior {
}
}
}
if (getSettingService().getAISetting().getNaturalLanguageQueryModelSetting() == null)
hints.add(_T("Set up AI to use natural language query"));
if (getSettingService().getAISetting().getLiteModelSetting() == null)
hints.add(_T("<a href='/~administration/settings/lite-ai-model' target='_blank'>Set up AI</a> to use natural language query</a>"));
return hints;
}
@ -276,9 +276,9 @@ public class PullRequestQueryBehavior extends ANTLRAssistBehavior {
@Override
protected NaturalLanguageTranslator getNaturalLanguageTranslator() {
var naturalLanguageQueryModel = getSettingService().getAISetting().getNaturalLanguageQueryModel();
if (naturalLanguageQueryModel != null) {
return new NaturalLanguageTranslator(naturalLanguageQueryModel) {
var liteModel = getSettingService().getAISetting().getLiteModel();
if (liteModel != null) {
return new NaturalLanguageTranslator(liteModel) {
@Override
public String getQueryDescription() {

View File

@ -127,7 +127,7 @@ public abstract class InputAssistBehavior extends AbstractPostAjaxBehavior {
String toTranslate = params.getParameterValue("input").toString();
String translated;
try {
translated = Preconditions.checkNotNull(getNaturalLanguageTranslator()).translate(toTranslate);
translated = getNaturalLanguageTranslator().translate(toTranslate);
} catch (Exception e) {
translated = toTranslate;
var explicitException = ExceptionUtils.find(e, ExplicitException.class);

View File

@ -14,8 +14,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.jspecify.annotations.Nullable;
import org.apache.wicket.Component;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.behavior.AttributeAppender;
@ -32,6 +30,7 @@ import org.apache.wicket.request.IRequestParameters;
import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.jspecify.annotations.Nullable;
import org.unbescape.html.HtmlEscape;
import com.fasterxml.jackson.core.JsonProcessingException;
@ -43,7 +42,6 @@ import io.onedev.commons.utils.LinearRange;
import io.onedev.commons.utils.StringUtils;
import io.onedev.server.OneDev;
import io.onedev.server.codequality.CodeProblem;
import io.onedev.server.service.CodeCommentService;
import io.onedev.server.git.BlameBlock;
import io.onedev.server.git.BlameCommit;
import io.onedev.server.git.BlobChange;
@ -54,6 +52,7 @@ import io.onedev.server.model.Project;
import io.onedev.server.model.PullRequest;
import io.onedev.server.search.code.hit.QueryHit;
import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.CodeCommentService;
import io.onedev.server.util.DateUtils;
import io.onedev.server.util.Pair;
import io.onedev.server.util.diff.DiffBlock;
@ -66,6 +65,7 @@ import io.onedev.server.web.behavior.blamemessage.BlameMessageBehavior;
import io.onedev.server.web.component.diff.blob.BlobAnnotationSupport;
import io.onedev.server.web.component.diff.revision.DiffViewMode;
import io.onedev.server.web.component.svg.SpriteImage;
import io.onedev.server.web.component.symboltooltip.SymbolContext;
import io.onedev.server.web.component.symboltooltip.SymbolTooltipPanel;
import io.onedev.server.web.page.base.BasePage;
import io.onedev.server.web.page.project.blob.ProjectBlobPage;
@ -352,7 +352,62 @@ public class BlobTextDiffPanel extends Panel {
protected Project getProject() {
return change.getProject();
}
@Override
protected String getSymbolPositionCalcFunction() {
return
"""
function(symbolEl) {
var $td = $(symbolEl).closest('td.content');
if ($td.hasClass('old'))
return 'old:' + $td.data('old');
else
return 'new:' + $td.data('new');
}""";
}
@Override
protected SymbolContext getSymbolContext(String symbolPosition, int beforeContextSize,
int afterContextSize, int atStartContextSize) {
String[] parts = symbolPosition.split(":");
boolean isNew = parts[0].equals("new");
int lineNo = Integer.parseInt(parts[1]);
List<String> lines;
String path;
if (isNew) {
lines = change.getNewText().getLines();
path = change.getNewBlobIdent().path;
} else {
lines = change.getOldText().getLines();
path = change.getOldBlobIdent().path;
}
List<String> linesBefore = new ArrayList<>();
List<String> linesAfter = new ArrayList<>();
String symbolLine = lines.get(lineNo);
for (int i = Math.max(0, lineNo - beforeContextSize); i < lineNo; i++) {
linesBefore.add(lines.get(i));
}
for (int i = lineNo + 1; i <= Math.min(lineNo + afterContextSize, lines.size() - 1); i++) {
linesAfter.add(lines.get(i));
}
List<String> linesAtStart = new ArrayList<>();
for (int i = 0; i < Math.min(atStartContextSize, lines.size()); i++) {
linesAtStart.add(lines.get(i));
}
return new SymbolContext(
path,
symbolLine,
linesBefore,
linesAfter,
linesAtStart
);
}
};
add(symbolTooltip);

View File

@ -0,0 +1,49 @@
package io.onedev.server.web.component.symboltooltip;
import java.io.Serializable;
import java.util.List;
public class SymbolContext implements Serializable {
private static final long serialVersionUID = 1L;
private final String blobPath;
private final String symbolLine;
private final List<String> linesBeforeSymbolLine;
private final List<String> linesAfterSymbolLine;
private final List<String> linesAtStart;
public SymbolContext(String fileName, String symbolLine,
List<String> linesBeforeSymbolLine, List<String> linesAfterSymbolLine, List<String> linesAtStart) {
this.blobPath = fileName;
this.symbolLine = symbolLine;
this.linesBeforeSymbolLine = linesBeforeSymbolLine;
this.linesAfterSymbolLine = linesAfterSymbolLine;
this.linesAtStart = linesAtStart;
}
public String getBlobPath() {
return blobPath;
}
public String getSymbolLine() {
return symbolLine;
}
public List<String> getLinesBeforeSymbolLine() {
return linesBeforeSymbolLine;
}
public List<String> getLinesAfterSymbolLine() {
return linesAfterSymbolLine;
}
public List<String> getLinesAtStart() {
return linesAtStart;
}
}

View File

@ -1,9 +1,13 @@
<wicket:panel>
<div wicket:id="content">
<wicket:enclosure child="declarations">
<div class="mb-2"><b>Possible declarations</b></div>
<ul class="declarations list-unstyled">
<li wicket:id="declarations">
<wicket:enclosure child="definitions">
<div class="mb-2"><b><wicket:t>Possible definitions</wicket:t></b></div>
<div wicket:id="definitionInferHint" class="mb-2 hint">
<wicket:svg href="bulb" class="icon icon-sm"/><wicket:t><a href="/~administration/settings/lite-ai-model" target="_blank">Set up AI</a> to mark the most probable</wicket:t>
</div>
<ul class="definitions list-unstyled">
<li wicket:id="definitions">
<span class="most-probable-indicator"><wicket:svg href="tick" class="icon icon-sm"/></span>
<img wicket:id="icon">
<a wicket:id="delegateLink"></a>
<a wicket:id="link">
@ -18,7 +22,7 @@
</ul>
</wicket:enclosure>
<div>
<a wicket:id="findOccurrences" class="find-occurrences"><b>All occurrences</b></a>
<a wicket:id="findOccurrences" class="find-occurrences"><b><wicket:t>All occurrences</wicket:t></b></a>
</div>
</div>
</wicket:panel>

View File

@ -1,26 +1,15 @@
package io.onedev.server.web.component.symboltooltip;
import io.onedev.commons.jsymbol.Symbol;
import io.onedev.server.OneDev;
import io.onedev.server.service.SettingService;
import io.onedev.server.git.Blob;
import io.onedev.server.git.BlobIdent;
import io.onedev.server.model.Project;
import io.onedev.server.search.code.CodeSearchService;
import io.onedev.server.search.code.IndexConstants;
import io.onedev.server.search.code.hit.QueryHit;
import io.onedev.server.search.code.hit.SymbolHit;
import io.onedev.server.search.code.query.BlobQuery;
import io.onedev.server.search.code.query.SymbolQuery;
import io.onedev.server.search.code.query.TextQuery;
import io.onedev.server.web.behavior.AbstractPostAjaxBehavior;
import io.onedev.server.web.behavior.CtrlClickBehavior;
import io.onedev.server.web.behavior.RunTaskBehavior;
import io.onedev.server.web.component.link.ViewStateAwareAjaxLink;
import io.onedev.server.web.page.project.blob.ProjectBlobPage;
import io.onedev.server.web.page.project.blob.render.BlobRenderer;
import static org.apache.wicket.ajax.attributes.CallbackParameter.explicit;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import org.apache.commons.lang.StringUtils;
import org.apache.wicket.Component;
import org.apache.wicket.Session;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.behavior.AttributeAppender;
@ -39,22 +28,64 @@ import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreType;
import com.fasterxml.jackson.databind.ObjectMapper;
import static org.apache.wicket.ajax.attributes.CallbackParameter.explicit;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import io.onedev.commons.jsymbol.Symbol;
import io.onedev.commons.utils.LinearRange;
import io.onedev.commons.utils.PlanarRange;
import io.onedev.server.git.Blob;
import io.onedev.server.git.BlobIdent;
import io.onedev.server.model.Project;
import io.onedev.server.search.code.CodeSearchService;
import io.onedev.server.search.code.IndexConstants;
import io.onedev.server.search.code.hit.QueryHit;
import io.onedev.server.search.code.hit.SymbolHit;
import io.onedev.server.search.code.query.BlobQuery;
import io.onedev.server.search.code.query.SymbolQuery;
import io.onedev.server.search.code.query.TextQuery;
import io.onedev.server.service.SettingService;
import io.onedev.server.web.behavior.AbstractPostAjaxBehavior;
import io.onedev.server.web.behavior.CtrlClickBehavior;
import io.onedev.server.web.behavior.RunTaskBehavior;
import io.onedev.server.web.component.link.ViewStateAwareAjaxLink;
import io.onedev.server.web.page.project.blob.ProjectBlobPage;
import io.onedev.server.web.page.project.blob.render.BlobRenderer;
public abstract class SymbolTooltipPanel extends Panel {
private static final int QUERY_ENTRIES = 20;
private static final int BEFORE_CONTEXT_SIZE = 5;
private static final int AFTER_CONTEXT_SIZE = 5;
private static final int AT_START_CONTEXT_SIZE = 200;
private static final Logger logger = LoggerFactory.getLogger(SymbolTooltipPanel.class);
private String revision = "";
private String symbolName = "";
private String symbolPosition = "";
private List<QueryHit> symbolHits = new ArrayList<>();
@Inject
private CodeSearchService searchService;
@Inject
private SettingService settingService;
@Inject
private ObjectMapper objectMapper;
public SymbolTooltipPanel(String id) {
super(id);
}
@ -69,7 +100,7 @@ public abstract class SymbolTooltipPanel extends Panel {
content.setOutputMarkupId(true);
add(content);
content.add(new ListView<QueryHit>("declarations", new AbstractReadOnlyModel<>() {
content.add(new ListView<QueryHit>("definitions", new AbstractReadOnlyModel<>() {
@Override
public List<QueryHit> getObject() {
@ -80,7 +111,7 @@ public abstract class SymbolTooltipPanel extends Panel {
@Override
protected void populateItem(ListItem<QueryHit> item) {
final QueryHit hit = item.getModelObject();
var hit = item.getModelObject();
item.add(hit.renderIcon("icon"));
AjaxLink<Void> delegateLink = new ViewStateAwareAjaxLink<Void>("delegateLink") {
@ -105,6 +136,7 @@ public abstract class SymbolTooltipPanel extends Panel {
link.add(new Label("scope", hit.getNamespace()).setVisible(hit.getNamespace()!=null));
item.add(link);
item.setOutputMarkupId(true);
}
@Override
@ -113,6 +145,15 @@ public abstract class SymbolTooltipPanel extends Panel {
setVisible(!symbolHits.isEmpty());
}
});
content.add(new WebMarkupContainer("definitionInferHint") {
@Override
protected void onConfigure() {
super.onConfigure();
setVisible(settingService.getAISetting().getLiteModelSetting() == null && symbolHits.size() > 1);
}
});
content.add(new ViewStateAwareAjaxLink<Void>("findOccurrences") {
@ -144,14 +185,12 @@ public abstract class SymbolTooltipPanel extends Panel {
List<QueryHit> hits;
// do this check to avoid TooGeneralQueryException
if (symbolName.length() >= IndexConstants.NGRAM_SIZE) {
int maxQueryEntries = OneDev.getInstance(SettingService.class)
.getPerformanceSetting().getMaxCodeSearchEntries();
int maxQueryEntries = settingService.getPerformanceSetting().getMaxCodeSearchEntries();
var query = new TextQuery.Builder(symbolName)
.wholeWord(true)
.caseSensitive(true)
.count(maxQueryEntries)
.build();
CodeSearchService searchService = OneDev.getInstance(CodeSearchService.class);
ObjectId commit = getProject().getRevCommit(revision, true);
hits = searchService.search(getProject(), commit, query);
} else {
@ -178,83 +217,147 @@ public abstract class SymbolTooltipPanel extends Panel {
}
});
var definitionInferBehavior = new AbstractPostAjaxBehavior() {
@Override
protected void respond(AjaxRequestTarget target) {
}
};
add(definitionInferBehavior);
add(new AbstractPostAjaxBehavior() {
@Override
protected void respond(AjaxRequestTarget target) {
IRequestParameters params = RequestCycle.get().getRequest().getPostParameters();
revision = params.getParameterValue("revision").toString();
symbolName = params.getParameterValue("symbol").toString();
var action = params.getParameterValue("action").toString();
if (action.equals("query")) {
revision = params.getParameterValue("revision").toString();
symbolName = params.getParameterValue("symbolName").toString();
symbolPosition = params.getParameterValue("symbolPosition").toString();
if (symbolName.startsWith("#include")) {
// handle c/c++ include directive as CodeMirror return the whole line as a meta
symbolName = symbolName.substring("#include".length()).trim();
}
if (symbolName.startsWith("#include")) {
// handle c/c++ include directive as CodeMirror return the whole line as a meta
symbolName = symbolName.substring("#include".length()).trim();
}
String charsToStrip = "@#'\"./\\";
symbolName = StringUtils.stripEnd(StringUtils.stripStart(symbolName, charsToStrip), charsToStrip);
symbolHits.clear();
// do this check to avoid TooGeneralQueryException
if (symbolName.length() != 0 && symbolName.indexOf('?') == -1 && symbolName.indexOf('*') == -1) {
BlobIdent blobIdent = new BlobIdent(revision, getBlobPath(), FileMode.TYPE_FILE);
Blob blob = getProject().getBlob(blobIdent, true);
String charsToStrip = "@#'\"./\\";
symbolName = StringUtils.stripEnd(StringUtils.stripStart(symbolName, charsToStrip), charsToStrip);
symbolHits.clear();
if (symbolHits.size() < QUERY_ENTRIES) {
// first find in current file for matched symbols
List<Symbol> symbols = OneDev.getInstance(CodeSearchService.class).getSymbols(getProject(),
blob.getBlobId(), getBlobPath());
if (symbols != null) {
for (Symbol symbol: symbols) {
if (symbolHits.size() < QUERY_ENTRIES
&& symbol.isSearchable()
&& symbolName.equals(symbol.getName())
&& symbol.isPrimary()) {
symbolHits.add(new SymbolHit(getBlobPath(), symbol, null));
// do this check to avoid TooGeneralQueryException
if (symbolName.length() != 0 && symbolName.indexOf('?') == -1 && symbolName.indexOf('*') == -1) {
BlobIdent blobIdent = new BlobIdent(revision, getBlobPath(), FileMode.TYPE_FILE);
Blob blob = getProject().getBlob(blobIdent, true);
if (symbolHits.size() < QUERY_ENTRIES) {
// first find in current file for matched symbols
List<Symbol> symbols = searchService.getSymbols(getProject(),
blob.getBlobId(), getBlobPath());
if (symbols != null) {
for (Symbol symbol: symbols) {
if (symbolHits.size() < QUERY_ENTRIES
&& symbol.isSearchable()
&& symbolName.equals(symbol.getName())
&& symbol.isPrimary()) {
symbolHits.add(new SymbolHit(getBlobPath(), symbol, null));
}
}
for (Symbol symbol: symbols) {
if (symbolHits.size() < QUERY_ENTRIES
&& symbol.isSearchable()
&& symbolName.equals(symbol.getName())
&& !symbol.isPrimary()) {
symbolHits.add(new SymbolHit(getBlobPath(), symbol, null));
}
}
}
for (Symbol symbol: symbols) {
if (symbolHits.size() < QUERY_ENTRIES
&& symbol.isSearchable()
&& symbolName.equals(symbol.getName())
&& !symbol.isPrimary()) {
symbolHits.add(new SymbolHit(getBlobPath(), symbol, null));
}
}
if (symbolHits.size() < QUERY_ENTRIES) {
// then find in other files for public symbols
ObjectId commit = getProject().getRevCommit(revision, true);
BlobQuery query;
if (symbolHits.size() < QUERY_ENTRIES) {
query = new SymbolQuery.Builder(symbolName)
.caseSensitive(true)
.excludeBlobPath(blobIdent.path)
.primary(true)
.local(false)
.count(QUERY_ENTRIES)
.build();
symbolHits.addAll(searchService.search(getProject(), commit, query));
}
if (symbolHits.size() < QUERY_ENTRIES) {
query = new SymbolQuery.Builder(symbolName)
.caseSensitive(true)
.excludeBlobPath(blobIdent.path)
.primary(false)
.local(false)
.count(QUERY_ENTRIES - symbolHits.size())
.build();
symbolHits.addAll(searchService.search(getProject(), commit, query));
}
}
}
target.add(content);
CharSequence callback;
if (settingService.getAISetting().getLiteModelSetting() != null && symbolHits.size() > 1)
callback = getCallbackFunction(explicit("action"));
else
callback = "undefined";
String script = String.format("onedev.server.symboltooltip.doneQuery('%s', %s);",
content.getMarkupId(), callback);
target.appendJavaScript(script);
} else {
var liteModel = settingService.getAISetting().getLiteModel();
try {
ObjectMapper mapperCopy = objectMapper.copy();
mapperCopy.addMixIn(PlanarRange.class, IgnorePlanarRangeMixin.class);
mapperCopy.addMixIn(LinearRange.class, IgnoreLinearRangeMixin.class);
var jsonOfSymbolHits = mapperCopy.writeValueAsString(symbolHits);
var symbolContext = getSymbolContext(symbolPosition, BEFORE_CONTEXT_SIZE,
AFTER_CONTEXT_SIZE, AT_START_CONTEXT_SIZE);
var jsonOfSymbolContext = mapperCopy.writeValueAsString(symbolContext);
var systemMessage = new SystemMessage("""
You are familiar with various programming languages. Given a symbol name, a json object of
symbol context, and a json array of possible symbol definitions, please determine the most
likely symbol definition and return its index in the array. Symbol definition may contain
parent symbol, and this is where the symbol is defined inside (namespace, package etc).
The @type property in symbol definition means category/kind of the symbol (type, method,
variable etc).
IMPORTANT: only return index of the definition, no other text or comments.
""");
var userMessage = new UserMessage(String.format("""
Symbol name:
%s
Symbol context json:
%s
Possible symbol definitions json:
%s
""", symbolName, jsonOfSymbolContext, jsonOfSymbolHits));
var lineNo = Integer.parseInt(liteModel.chat(systemMessage, userMessage).aiMessage().text());
if (lineNo >= 0 && lineNo < symbolHits.size()) {
@SuppressWarnings("unchecked")
ListView<QueryHit> definitionsView = (ListView<QueryHit>) content.get("definitions");
@SuppressWarnings("deprecation")
var script = String.format("onedev.server.symboltooltip.doneInfer('%s');",
definitionsView.get(lineNo).getMarkupId());
target.appendJavaScript(script);
}
}
if (symbolHits.size() < QUERY_ENTRIES) {
// then find in other files for public symbols
CodeSearchService searchService = OneDev.getInstance(CodeSearchService.class);
ObjectId commit = getProject().getRevCommit(revision, true);
BlobQuery query;
if (symbolHits.size() < QUERY_ENTRIES) {
query = new SymbolQuery.Builder(symbolName)
.caseSensitive(true)
.excludeBlobPath(blobIdent.path)
.primary(true)
.local(false)
.count(QUERY_ENTRIES)
.build();
symbolHits.addAll(searchService.search(getProject(), commit, query));
}
if (symbolHits.size() < QUERY_ENTRIES) {
query = new SymbolQuery.Builder(symbolName)
.caseSensitive(true)
.excludeBlobPath(blobIdent.path)
.primary(false)
.local(false)
.count(QUERY_ENTRIES - symbolHits.size())
.build();
symbolHits.addAll(searchService.search(getProject(), commit, query));
}
} catch (Exception e) {
logger.error("Error inferring most likely symbol definition", e);
Session.get().error("Error inferring most likely symbol definition, check server log for details");
}
}
target.add(content);
String script = String.format("onedev.server.symboltooltip.doneQuery('%s');", content.getMarkupId());
target.appendJavaScript(script);
}
@Override
@ -263,8 +366,10 @@ public abstract class SymbolTooltipPanel extends Panel {
response.render(JavaScriptHeaderItem.forReference(new SymbolTooltipResourceReference()));
String script = String.format("onedev.server.symboltooltip.init('%s', %s);",
getMarkupId(), getCallbackFunction(explicit("revision"), explicit("symbol")));
var callback = getCallbackFunction(explicit("action"), explicit("revision"),
explicit("symbolName"), explicit("symbolPosition"));
String script = String.format("onedev.server.symboltooltip.init('%s', %s, %s);",
getMarkupId(), callback, getSymbolPositionCalcFunction());
response.render(OnDomReadyHeaderItem.forScript(script));
}
@ -302,5 +407,18 @@ public abstract class SymbolTooltipPanel extends Panel {
protected abstract void onSelect(AjaxRequestTarget target, QueryHit hit);
protected abstract void onOccurrencesQueried(AjaxRequestTarget target, List<QueryHit> hits);
protected abstract String getSymbolPositionCalcFunction();
protected abstract SymbolContext getSymbolContext(String symbolPosition, int beforeContextSize,
int afterContextSize, int atStartContextSize);
}
@JsonIgnoreType
interface IgnorePlanarRangeMixin {
}
@JsonIgnoreType
interface IgnoreLinearRangeMixin {
}

View File

@ -9,6 +9,10 @@
box-shadow: 0px 0px 8px 0px rgba(0,0,0,0.1);
max-width: 720px;
}
.symbol-tooltip .hint {
font-size: 11px;
opacity: 0.7;
}
.dark-mode .symbol-tooltip {
background-color: var(--dark-mode-light-info);
color: var(--info);
@ -21,15 +25,28 @@
color: #0073e9;
}
.symbol-tooltip ul.declarations li {
.symbol-tooltip .definition-inferring-indicator img {
width: 14px;
height: 14px;
}
.symbol-tooltip .definitions.no-definition-infer>li .most-probable-indicator {
display: none;
}
.symbol-tooltip .definitions>li .most-probable-indicator {
visibility: hidden;
}
.symbol-tooltip .definitions>li.most-probable .most-probable-indicator {
visibility: visible;
}
.symbol-tooltip ul.definitions li {
vertical-align: top;
padding: 2px 4px;
white-space: nowrap;
}
.symbol-tooltip ul.declarations .scope {
.symbol-tooltip ul.definitions .scope {
color: var(--gray);
}
.dark-mode .symbol-tooltip ul.declarations .scope {
.dark-mode .symbol-tooltip ul.definitions .scope {
color: var(--dark-mode-gray);
}
.symbol-tooltip a.find-occurrences[disabled] {

View File

@ -1,5 +1,5 @@
onedev.server.symboltooltip = {
init: function(containerId, queryCallback) {
init: function(containerId, callback, symbolPositionCalcFunction) {
var container = document.getElementById(containerId);
var showTimer;
@ -69,21 +69,41 @@ onedev.server.symboltooltip = {
});
$tooltip.data("alignment", {placement: {x: 0, y:0, offset:2, targetX: 0, targetY: 100}, target: {element: symbolEl}});
$tooltip.align($tooltip.data("alignment"));
queryCallback(revision, $symbol.text());
$tooltip.align($tooltip.data("alignment"));
callback("query", revision, $symbol.text(), symbolPositionCalcFunction($symbol[0]));
showTimer = null;
}, 500);
};
},
doneQuery: function(contentId) {
doneQuery: function(contentId, callback) {
var $content = $("#" + contentId);
var $container = $content.parent();
var $tooltip = $("#" + $container.attr("id") + "-symbol-tooltip");
$tooltip.removeClass("d-none");
if ($tooltip.length != 0)
var $definitions = $content.children(".definitions");
if (callback) {
var $indicator;
if (onedev.server.isDarkMode())
$indicator = $('<div class="definition-inferring-indicator mb-2 ajax-loading-indicator"><img src="/~img/dark-ajax-indicator.gif"/> <wicket:t>Inferring the most probable...</wicket:t></div>');
else
$indicator = $('<div class="definition-inferring-indicator mb-2 ajax-loading-indicator"><img src="/~img/ajax-indicator.gif"/> <wicket:t>Inferring the most probable...</wicket:t></div>');
$indicator.insertBefore($definitions);
callback("infer");
} else {
$definitions.addClass("no-definition-infer");
}
if ($tooltip.length != 0) {
$tooltip.html($content.children()).align($tooltip.data("alignment"));
}
},
doneInfer: function(definitionId) {
var $definition = $("#" + definitionId);
$definition.addClass("most-probable");
var $tooltip = $definition.closest(".symbol-tooltip");
$tooltip.find(".definition-inferring-indicator").remove();
$tooltip.align($tooltip.data("alignment"));
},
// this is public API which can be called from other components using this component

View File

@ -8,7 +8,7 @@ import org.apache.wicket.request.mapper.CompoundRequestMapper;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.server.web.asset.icon.IconScope;
import io.onedev.server.web.page.admin.aisetting.AISettingPage;
import io.onedev.server.web.page.admin.aisetting.LiteModelPage;
import io.onedev.server.web.page.admin.alertsettings.AlertSettingPage;
import io.onedev.server.web.page.admin.authenticator.AuthenticatorPage;
import io.onedev.server.web.page.admin.brandingsetting.BrandingSettingPage;
@ -347,7 +347,7 @@ public class BaseUrlMapper extends CompoundRequestMapper {
add(new BasePageMapper("~administration/labels", LabelManagementPage.class));
add(new BasePageMapper("~administration/settings/alert", AlertSettingPage.class));
add(new BasePageMapper("~administration/settings/performance", PerformanceSettingPage.class));
add(new BasePageMapper("~administration/settings/ai", AISettingPage.class));
add(new BasePageMapper("~administration/settings/lite-ai-model", LiteModelPage.class));
add(new BasePageMapper("~administration/settings/backup", DatabaseBackupPage.class));
add(new BasePageMapper("~administration/settings/authenticator", AuthenticatorPage.class));
add(new BasePageMapper("~administration/settings/sso-providers", SsoProviderListPage.class));

View File

@ -1,10 +0,0 @@
<wicket:extend>
<div class="card">
<div class="card-body">
<form wicket:id="form" class="leave-confirm">
<div wicket:id="editor" class="mb-4"></div>
<input type="submit" class="btn btn-primary dirty-aware" t:value="Save Settings">
</form>
</div>
</div>
</wicket:extend>

View File

@ -0,0 +1,21 @@
<wicket:extend>
<div class="card">
<div class="card-body">
<div class="alert alert-notice alert-light">
This model will be used to perform lite tasks including:
<ul class="mb-0">
<li>Query issues, builds, and pull requests with natural language</li>
<li>Mark the most probable symbol definition in symbol navigation</li>
</ul>
It should be fast and cost-effective. Some suggested models:
<code>Google/gemini-2.5-flash</code>,
<code>OpenAI/gpt-4.1-mini</code>,
<code>Qwen/Qwen-2.5-72B-instruct</code>
</div>
<form wicket:id="form" class="leave-confirm">
<div wicket:id="editor" class="mb-4"></div>
<input type="submit" class="btn btn-primary dirty-aware" t:value="Save Settings">
</form>
</div>
</div>
</wicket:extend>

View File

@ -12,15 +12,15 @@ import org.apache.wicket.request.mapper.parameter.PageParameters;
import io.onedev.server.data.migration.VersionedXmlDoc;
import io.onedev.server.model.support.administration.AISetting;
import io.onedev.server.service.SettingService;
import io.onedev.server.web.editable.BeanContext;
import io.onedev.server.web.editable.PropertyContext;
import io.onedev.server.web.page.admin.AdministrationPage;
public class AISettingPage extends AdministrationPage {
public class LiteModelPage extends AdministrationPage {
@Inject
private SettingService settingService;
public AISettingPage(PageParameters params) {
public LiteModelPage(PageParameters params) {
super(params);
}
@ -39,20 +39,20 @@ public class AISettingPage extends AdministrationPage {
var newAuditContent = VersionedXmlDoc.fromBean(aiSetting).toXML();
settingService.saveAISetting(aiSetting);
auditService.audit(null, "changed AI settings", oldAuditContent, newAuditContent);
getSession().success(_T("AI settings have been saved"));
getSession().success(_T("Lite AI model settings have been saved"));
setResponsePage(AISettingPage.class);
setResponsePage(LiteModelPage.class);
}
};
form.add(BeanContext.edit("editor", aiSetting));
form.add(PropertyContext.edit("editor", aiSetting, AISetting.PROP_LITE_MODEL_SETTING));
add(form);
}
@Override
protected Component newTopbarTitle(String componentId) {
return new Label(componentId, _T("AI Settings"));
return new Label(componentId, _T("Lite AI Model"));
}
}

View File

@ -91,7 +91,7 @@ import io.onedev.server.web.component.svg.SpriteImage;
import io.onedev.server.web.component.user.UserAvatar;
import io.onedev.server.web.editable.EditableUtils;
import io.onedev.server.web.page.HomePage;
import io.onedev.server.web.page.admin.aisetting.AISettingPage;
import io.onedev.server.web.page.admin.aisetting.LiteModelPage;
import io.onedev.server.web.page.admin.alertsettings.AlertSettingPage;
import io.onedev.server.web.page.admin.authenticator.AuthenticatorPage;
import io.onedev.server.web.page.admin.brandingsetting.BrandingSettingPage;
@ -339,8 +339,12 @@ public abstract class LayoutPage extends BasePage {
administrationMenuItems.add(new SidebarMenuItem.Page(null, _T("Groovy Scripts"),
GroovyScriptListPage.class, new PageParameters()));
administrationMenuItems.add(new SidebarMenuItem.Page(null, _T("AI Settings"),
AISettingPage.class, new PageParameters()));
List<SidebarMenuItem> aiMenuItems = new ArrayList<>();
aiMenuItems.add(new SidebarMenuItem.Page(null, _T("Lite Model"),
LiteModelPage.class, new PageParameters()));
administrationMenuItems.add(new SidebarMenuItem.SubMenu(null, _T("AI Settings"), aiMenuItems));
administrationMenuItems.add(new SidebarMenuItem.Page(null, _T("Branding"),
BrandingSettingPage.class, new PageParameters()));

View File

@ -13,7 +13,6 @@ import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.jspecify.annotations.Nullable;
import javax.servlet.http.Cookie;
import org.apache.wicket.Component;
@ -51,6 +50,7 @@ import org.apache.wicket.request.http.WebResponse;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.revwalk.RevCommit;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.unbescape.html.HtmlEscape;
@ -73,10 +73,6 @@ import io.onedev.server.codequality.CodeProblem;
import io.onedev.server.codequality.CodeProblemContribution;
import io.onedev.server.codequality.CoverageStatus;
import io.onedev.server.codequality.LineCoverageContribution;
import io.onedev.server.service.BuildService;
import io.onedev.server.service.CodeCommentService;
import io.onedev.server.service.CodeCommentReplyService;
import io.onedev.server.service.CodeCommentStatusChangeService;
import io.onedev.server.git.BlameBlock;
import io.onedev.server.git.Blob;
import io.onedev.server.git.BlobIdent;
@ -92,6 +88,10 @@ import io.onedev.server.model.support.Mark;
import io.onedev.server.search.code.CodeSearchService;
import io.onedev.server.search.code.hit.QueryHit;
import io.onedev.server.security.SecurityUtils;
import io.onedev.server.service.BuildService;
import io.onedev.server.service.CodeCommentReplyService;
import io.onedev.server.service.CodeCommentService;
import io.onedev.server.service.CodeCommentStatusChangeService;
import io.onedev.server.util.DateUtils;
import io.onedev.server.util.Similarities;
import io.onedev.server.util.diff.DiffUtils;
@ -115,6 +115,7 @@ import io.onedev.server.web.component.sourceformat.SourceFormatPanel;
import io.onedev.server.web.component.suggestionapply.SuggestionApplyBean;
import io.onedev.server.web.component.suggestionapply.SuggestionApplyModalPanel;
import io.onedev.server.web.component.svg.SpriteImage;
import io.onedev.server.web.component.symboltooltip.SymbolContext;
import io.onedev.server.web.component.symboltooltip.SymbolTooltipPanel;
import io.onedev.server.web.page.project.blob.render.BlobRenderContext;
import io.onedev.server.web.page.project.blob.render.BlobRenderContext.Mode;
@ -135,7 +136,7 @@ import io.onedev.server.web.util.WicketUtils;
*/
public class SourceViewPanel extends BlobViewPanel implements Positionable, SearchMenuContributor {
private static final Logger logger = LoggerFactory.getLogger(SourceViewPanel.class);
private static final Logger logger = LoggerFactory.getLogger(SourceViewPanel.class);
private static final String COOKIE_OUTLINE = "sourceView.outline";
@ -835,6 +836,51 @@ public class SourceViewPanel extends BlobViewPanel implements Positionable, Sear
protected Project getProject() {
return context.getProject();
}
@Override
protected String getSymbolPositionCalcFunction() {
return
"""
function(symbolEl) {
var cm = symbolEl.closest(".CodeMirror").CodeMirror;
if (cm) {
var lineElement = symbolEl.closest('.CodeMirror-line').parentElement;
for (var view of cm.display.view) {
if (view.node === lineElement) {
return cm.getLineNumber(view.line);
}
}
}
return -1;
}""";
}
@Override
protected SymbolContext getSymbolContext(String symbolPosition, int beforeContextSize,
int afterContextSize, int atStartContextSize) {
var lineNo = Integer.parseInt(symbolPosition);
var lines = context.getProject().getBlob(context.getBlobIdent(), true).getText().getLines();
List<String> linesBefore = new ArrayList<>();
List<String> linesAfter = new ArrayList<>();
String symbolLine = lines.get(lineNo);
for (int i = Math.max(0, lineNo - beforeContextSize); i < lineNo; i++) {
linesBefore.add(lines.get(i));
}
for (int i = lineNo + 1; i <= Math.min(lineNo + afterContextSize, lines.size() - 1); i++) {
linesAfter.add(lines.get(i));
}
List<String> linesAtStart = new ArrayList<>();
for (int i = 0; i < Math.min(atStartContextSize, lines.size()); i++) {
linesAtStart.add(lines.get(i));
}
return new SymbolContext(
context.getBlobIdent().path,
symbolLine,
linesBefore,
linesAfter,
linesAtStart
);
}
});
}