From e3de119b2047dd339621440897dc01a70b09b55e Mon Sep 17 00:00:00 2001 From: shizya Date: Fri, 12 Jun 2026 04:07:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=81=8A=E5=A4=A9=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=BF=AB=E9=80=9F=E5=88=87=E6=8D=A2=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E6=A1=86=20+=20=E5=88=87=E6=8D=A2=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增模型快速切换下拉选择器(圆角按钮 + 图标箭头) - 下拉框显示模型名称 + 供应商标签,模型>8个自动滚动 - 底部管理模型入口,跳转模型管理页面 - 切换提示改为发送时检测,显示模型已从 A 更改为 B - 新增 ChatMessage.modelSwitchNotification 字段 - 输入框获焦点自动关闭弹窗,修复键盘遮挡问题 - 点击模型只切换不跳转,与模型管理页面完全分离 - 修复 ToolCallUtils 测试在 Windows 上的路径问题 --- .../java/cn/lineai/model/ChatMessage.java | 55 +++- .../java/cn/lineai/model/ChatUiState.java | 292 +++++------------- .../cn/lineai/mvp/ChatUiStateAssembler.java | 6 +- .../java/cn/lineai/mvp/MainCoordinator.java | 50 +-- .../java/cn/lineai/mvp/ModelController.java | 2 + .../main/java/cn/lineai/ui/MainChatView.java | 10 + .../ui/component/ChatMessageListView.java | 37 ++- .../cn/lineai/ui/component/ComposerView.java | 147 ++++++++- .../ui/component/toolcall/ToolCallUtils.java | 16 +- 9 files changed, 345 insertions(+), 270 deletions(-) diff --git a/app/src/main/java/cn/lineai/model/ChatMessage.java b/app/src/main/java/cn/lineai/model/ChatMessage.java index a1c1ebf..230cfa4 100644 --- a/app/src/main/java/cn/lineai/model/ChatMessage.java +++ b/app/src/main/java/cn/lineai/model/ChatMessage.java @@ -46,6 +46,7 @@ public String getProtocolName() { private final String compactStatus; private final String responseInputItemJson; private final List attachments; + private final String modelSwitchNotification; public ChatMessage(String id, Role role, String content, boolean streaming) { this(id, role, content, "", streaming, false, false); @@ -137,24 +138,28 @@ public ChatMessage( } public ChatMessage( - String id, - Role role, - String content, - String reasoningContent, - boolean streaming, - boolean hidden, - boolean excludeFromContext, - List toolCalls, - List toolResults, - String toolCallId, - String toolName, - boolean error, - String diffId, - String reviewState, - String reviewMessage, - String compactStatus, - String responseInputItemJson, + String id, Role role, String content, String reasoningContent, + boolean streaming, boolean hidden, boolean excludeFromContext, + List toolCalls, List toolResults, + String toolCallId, String toolName, boolean error, + String diffId, String reviewState, String reviewMessage, + String compactStatus, String responseInputItemJson, List attachments + ) { + this(id, role, content, reasoningContent, streaming, hidden, excludeFromContext, + toolCalls, toolResults, toolCallId, toolName, error, diffId, reviewState, reviewMessage, + compactStatus, responseInputItemJson, attachments, ""); + } + + public ChatMessage( + String id, Role role, String content, String reasoningContent, + boolean streaming, boolean hidden, boolean excludeFromContext, + List toolCalls, List toolResults, + String toolCallId, String toolName, boolean error, + String diffId, String reviewState, String reviewMessage, + String compactStatus, String responseInputItemJson, + List attachments, + String modelSwitchNotification ) { this.id = id; this.role = role == null ? Role.USER : role; @@ -176,6 +181,7 @@ public ChatMessage( this.attachments = attachments == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList<>(attachments)); + this.modelSwitchNotification = modelSwitchNotification == null ? "" : modelSwitchNotification; } public String getId() { @@ -274,6 +280,14 @@ public boolean hasAttachments() { return !attachments.isEmpty(); } + public String getModelSwitchNotification() { + return modelSwitchNotification; + } + + public boolean isModelSwitchNotification() { + return modelSwitchNotification.length() > 0; + } + public String getProtocolRole() { return role.getProtocolName(); } @@ -345,6 +359,13 @@ public static ChatMessage compactProgress(String id, String status) { "", "", "", status, ""); } + public static ChatMessage modelSwitchNotice(String id, String fromModel, String toModel) { + String content = "\u6a21\u578b\u5df2\u4ece " + fromModel + " \u66f4\u6539\u4e3a " + toModel + "\u3002"; + return new ChatMessage(id, Role.ASSISTANT, "", "", false, false, true, + Collections.emptyList(), Collections.emptyList(), "", "", false, + "", "", "", "", "", Collections.emptyList(), content); + } + private String normalizeCompactStatus(String value) { if (COMPACT_STATUS_RUNNING.equals(value) || COMPACT_STATUS_DONE.equals(value) diff --git a/app/src/main/java/cn/lineai/model/ChatUiState.java b/app/src/main/java/cn/lineai/model/ChatUiState.java index 43c52cc..520bfee 100644 --- a/app/src/main/java/cn/lineai/model/ChatUiState.java +++ b/app/src/main/java/cn/lineai/model/ChatUiState.java @@ -1,5 +1,6 @@ package cn.lineai.model; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -7,6 +8,7 @@ public final class ChatUiState { private final String projectLabel; private final String projectPath; private final String modelLabel; + private final String selectedModelId; private final String contextLabel; private final int contextPercent; private final boolean streaming; @@ -19,186 +21,85 @@ public final class ChatUiState { private final String chatMode; private final String conversationId; private final List messages; + private final List availableModels; public ChatUiState( - String projectLabel, - String projectPath, - String modelLabel, - String contextLabel, - int contextPercent, - boolean streaming, - boolean hasConfiguredModel, - List messages + String projectLabel, String projectPath, String modelLabel, String contextLabel, + int contextPercent, boolean streaming, boolean hasConfiguredModel, List messages ) { - this( - projectLabel, - projectPath, - modelLabel, - contextLabel, - contextPercent, - streaming, - hasConfiguredModel, - true, - false, - false, - OutputSettings.BROWSER_BUILTIN, - InputSettings.ENTER_SEND, - ChatMode.DEFAULT, - messages - ); + this(projectLabel, projectPath, modelLabel, contextLabel, contextPercent, streaming, + hasConfiguredModel, true, false, false, OutputSettings.BROWSER_BUILTIN, + InputSettings.ENTER_SEND, ChatMode.DEFAULT, messages); } public ChatUiState( - String projectLabel, - String projectPath, - String modelLabel, - String contextLabel, - int contextPercent, - boolean streaming, - boolean hasConfiguredModel, - boolean thinkingScrollEnabled, - boolean thinkingAutoExpandEnabled, - List messages + String projectLabel, String projectPath, String modelLabel, String contextLabel, + int contextPercent, boolean streaming, boolean hasConfiguredModel, + boolean thinkingScrollEnabled, boolean thinkingAutoExpandEnabled, List messages ) { - this( - projectLabel, - projectPath, - modelLabel, - contextLabel, - contextPercent, - streaming, - hasConfiguredModel, - thinkingScrollEnabled, - thinkingAutoExpandEnabled, - false, - OutputSettings.BROWSER_BUILTIN, - InputSettings.ENTER_SEND, - ChatMode.DEFAULT, - messages - ); + this(projectLabel, projectPath, modelLabel, contextLabel, contextPercent, streaming, + hasConfiguredModel, thinkingScrollEnabled, thinkingAutoExpandEnabled, false, + OutputSettings.BROWSER_BUILTIN, InputSettings.ENTER_SEND, ChatMode.DEFAULT, messages); } public ChatUiState( - String projectLabel, - String projectPath, - String modelLabel, - String contextLabel, - int contextPercent, - boolean streaming, - boolean hasConfiguredModel, - boolean thinkingScrollEnabled, - boolean thinkingAutoExpandEnabled, - boolean codeWrapEnabled, - String browserMode, - List messages + String projectLabel, String projectPath, String modelLabel, String contextLabel, + int contextPercent, boolean streaming, boolean hasConfiguredModel, + boolean thinkingScrollEnabled, boolean thinkingAutoExpandEnabled, + boolean codeWrapEnabled, String browserMode, List messages ) { - this( - projectLabel, - projectPath, - modelLabel, - contextLabel, - contextPercent, - streaming, - hasConfiguredModel, - thinkingScrollEnabled, - thinkingAutoExpandEnabled, - codeWrapEnabled, - browserMode, - InputSettings.ENTER_SEND, - ChatMode.DEFAULT, - messages - ); + this(projectLabel, projectPath, modelLabel, contextLabel, contextPercent, streaming, + hasConfiguredModel, thinkingScrollEnabled, thinkingAutoExpandEnabled, codeWrapEnabled, + browserMode, InputSettings.ENTER_SEND, ChatMode.DEFAULT, messages); } public ChatUiState( - String projectLabel, - String projectPath, - String modelLabel, - String contextLabel, - int contextPercent, - boolean streaming, - boolean hasConfiguredModel, - boolean thinkingScrollEnabled, - boolean thinkingAutoExpandEnabled, - boolean codeWrapEnabled, - String browserMode, - String chatMode, - List messages + String projectLabel, String projectPath, String modelLabel, String contextLabel, + int contextPercent, boolean streaming, boolean hasConfiguredModel, + boolean thinkingScrollEnabled, boolean thinkingAutoExpandEnabled, + boolean codeWrapEnabled, String browserMode, String chatMode, List messages ) { - this( - projectLabel, - projectPath, - modelLabel, - contextLabel, - contextPercent, - streaming, - hasConfiguredModel, - thinkingScrollEnabled, - thinkingAutoExpandEnabled, - codeWrapEnabled, - browserMode, - InputSettings.ENTER_SEND, - chatMode, - "", - messages - ); + this(projectLabel, projectPath, modelLabel, contextLabel, contextPercent, streaming, + hasConfiguredModel, thinkingScrollEnabled, thinkingAutoExpandEnabled, codeWrapEnabled, + browserMode, InputSettings.ENTER_SEND, chatMode, "", messages); } public ChatUiState( - String projectLabel, - String projectPath, - String modelLabel, - String contextLabel, - int contextPercent, - boolean streaming, - boolean hasConfiguredModel, - boolean thinkingScrollEnabled, - boolean thinkingAutoExpandEnabled, - boolean codeWrapEnabled, - String browserMode, - String chatMode, - String conversationId, - List messages + String projectLabel, String projectPath, String modelLabel, String contextLabel, + int contextPercent, boolean streaming, boolean hasConfiguredModel, + boolean thinkingScrollEnabled, boolean thinkingAutoExpandEnabled, + boolean codeWrapEnabled, String browserMode, String chatMode, + String conversationId, List messages ) { - this( - projectLabel, - projectPath, - modelLabel, - contextLabel, - contextPercent, - streaming, - hasConfiguredModel, - thinkingScrollEnabled, - thinkingAutoExpandEnabled, - codeWrapEnabled, - browserMode, - InputSettings.ENTER_SEND, - chatMode, - conversationId, - messages - ); + this(projectLabel, projectPath, modelLabel, contextLabel, contextPercent, streaming, + hasConfiguredModel, thinkingScrollEnabled, thinkingAutoExpandEnabled, codeWrapEnabled, + browserMode, InputSettings.ENTER_SEND, chatMode, conversationId, messages); } public ChatUiState( - String projectLabel, - String projectPath, - String modelLabel, - String contextLabel, - int contextPercent, - boolean streaming, - boolean hasConfiguredModel, - boolean thinkingScrollEnabled, - boolean thinkingAutoExpandEnabled, - boolean codeWrapEnabled, - String browserMode, - String enterKeyBehavior, - String chatMode, - String conversationId, - List messages + String projectLabel, String projectPath, String modelLabel, String contextLabel, + int contextPercent, boolean streaming, boolean hasConfiguredModel, + boolean thinkingScrollEnabled, boolean thinkingAutoExpandEnabled, + boolean codeWrapEnabled, String browserMode, String enterKeyBehavior, + String chatMode, String conversationId, List messages + ) { + this(projectLabel, projectPath, modelLabel, contextLabel, contextPercent, streaming, + hasConfiguredModel, thinkingScrollEnabled, thinkingAutoExpandEnabled, codeWrapEnabled, + browserMode, enterKeyBehavior, chatMode, conversationId, messages, "", Collections.emptyList()); + } + + public ChatUiState( + String projectLabel, String projectPath, String modelLabel, String contextLabel, + int contextPercent, boolean streaming, boolean hasConfiguredModel, + boolean thinkingScrollEnabled, boolean thinkingAutoExpandEnabled, + boolean codeWrapEnabled, String browserMode, String enterKeyBehavior, + String chatMode, String conversationId, List messages, + String selectedModelId, List availableModels ) { this.projectLabel = projectLabel; this.projectPath = projectPath == null ? "" : projectPath; this.modelLabel = modelLabel; + this.selectedModelId = selectedModelId == null ? "" : selectedModelId; this.contextLabel = contextLabel; this.contextPercent = contextPercent; this.streaming = streaming; @@ -211,65 +112,26 @@ public ChatUiState( this.chatMode = ChatMode.normalize(chatMode); this.conversationId = conversationId == null ? "" : conversationId; this.messages = messages == null ? Collections.emptyList() : Collections.unmodifiableList(messages); - } - - public String getProjectLabel() { - return projectLabel; - } - - public String getProjectPath() { - return projectPath; - } - - public String getModelLabel() { - return modelLabel; - } - - public String getContextLabel() { - return contextLabel; - } - - public int getContextPercent() { - return contextPercent; - } - - public boolean isStreaming() { - return streaming; - } - - public boolean hasConfiguredModel() { - return hasConfiguredModel; - } - - public boolean isThinkingScrollEnabled() { - return thinkingScrollEnabled; - } - - public boolean isThinkingAutoExpandEnabled() { - return thinkingAutoExpandEnabled; - } - - public boolean isCodeWrapEnabled() { - return codeWrapEnabled; - } - - public String getBrowserMode() { - return browserMode; - } - - public String getEnterKeyBehavior() { - return enterKeyBehavior; - } - - public String getChatMode() { - return chatMode; - } - - public String getConversationId() { - return conversationId; - } - - public List getMessages() { - return messages; - } + this.availableModels = availableModels == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(availableModels)); + } + + public String getProjectLabel() { return projectLabel; } + public String getProjectPath() { return projectPath; } + public String getModelLabel() { return modelLabel; } + public String getSelectedModelId() { return selectedModelId; } + public List getAvailableModels() { return availableModels; } + public String getContextLabel() { return contextLabel; } + public int getContextPercent() { return contextPercent; } + public boolean isStreaming() { return streaming; } + public boolean hasConfiguredModel() { return hasConfiguredModel; } + public boolean isThinkingScrollEnabled() { return thinkingScrollEnabled; } + public boolean isThinkingAutoExpandEnabled() { return thinkingAutoExpandEnabled; } + public boolean isCodeWrapEnabled() { return codeWrapEnabled; } + public String getBrowserMode() { return browserMode; } + public String getEnterKeyBehavior() { return enterKeyBehavior; } + public String getChatMode() { return chatMode; } + public String getConversationId() { return conversationId; } + public List getMessages() { return messages; } } diff --git a/app/src/main/java/cn/lineai/mvp/ChatUiStateAssembler.java b/app/src/main/java/cn/lineai/mvp/ChatUiStateAssembler.java index c354254..c15c001 100644 --- a/app/src/main/java/cn/lineai/mvp/ChatUiStateAssembler.java +++ b/app/src/main/java/cn/lineai/mvp/ChatUiStateAssembler.java @@ -60,6 +60,8 @@ public ChatUiState assemble( String modelLabel = selectedModel == null ? "未选择模型" : contextInfo.getApiModelId(); + String selectedModelId = selectedModel == null ? "" : selectedModel.getId(); + List availableModels = modelRepository.getModels(); String uiProjectPath = WorkspacePaths.SOURCE_SSH.equals(projectSource) && safe(projectPath).length() == 0 ? "SSH 登录目录" : safe(projectPath); @@ -78,7 +80,9 @@ public ChatUiState assemble( inputSettings.getEnterKeyBehavior(), activeChatMode, conversationId, - messages + messages, + selectedModelId, + availableModels ); } diff --git a/app/src/main/java/cn/lineai/mvp/MainCoordinator.java b/app/src/main/java/cn/lineai/mvp/MainCoordinator.java index 7a99cc2..4948d36 100644 --- a/app/src/main/java/cn/lineai/mvp/MainCoordinator.java +++ b/app/src/main/java/cn/lineai/mvp/MainCoordinator.java @@ -172,6 +172,7 @@ public void showChatScreen() { } }; private ModelCancellationToken currentCancellationToken; + private String lastMessageModelId = ""; private final StringBuilder pendingStreamTextDelta = new StringBuilder(); private final StringBuilder pendingStreamReasoningDelta = new StringBuilder(); private final HashMap streamingRawTextByMessageId = new HashMap<>(); @@ -995,6 +996,7 @@ public void onNewConversation() { persistCurrentConversation(); chatSessionStore.startNewConversation(System.currentTimeMillis()); clearSessionAutoToolConfirmations(); + lastMessageModelId = ""; if (view != null) { view.hideOverlays(); view.showChatScreen(); @@ -1215,6 +1217,13 @@ public void onSendMessage(String text, List attachments) { return; } + String currentModelId = selectedModel.getModelId(); + if (lastMessageModelId.length() > 0 && !lastMessageModelId.equals(currentModelId)) { + messages.add(ChatMessage.modelSwitchNotice(nextId(), lastMessageModelId, currentModelId)); + persistCurrentConversation(); + } + lastMessageModelId = currentModelId; + int generationId = chatSessionStore.nextGenerationId(); ModelCancellationToken cancellationToken = new ModelCancellationToken(); currentCancellationToken = cancellationToken; @@ -1804,6 +1813,11 @@ public void onOpenUrl(String url) { showScreen("browser:" + safeUrl); } + @Override + public void showModelManagement() { + showScreen("models"); + } + @Override public AiBehaviorSettings getAiBehaviorSettings() { return settingsManagementController.getAiBehaviorSettings(); @@ -2311,6 +2325,15 @@ public void onModelSelected(String id) { modelManagementController.selectModel(id); } + @Override + public void onModelQuickSwitch(String modelId) { + if (chatSessionStore.isStreaming()) { + return; + } + modelRepository.setSelectedModelId(modelId); + render(); + } + @Override public void onModelSaved(ModelConfig model) { modelManagementController.saveModel(model); @@ -2853,8 +2876,7 @@ private ArrayList buildModelMessages(String userInput, int usedToo aiSettings.getToneMode(), chatModePromptContext(activeChatMode), systemContext, - buildToolPrompt(selectedModel, usedToolCallCount), - selectedModel + buildToolPrompt(selectedModel, usedToolCallCount) ); modelMessages.add(new SystemModelMessage(systemPrompt)); int contextTokens = selectedModel == null @@ -3273,29 +3295,10 @@ private void handleToolExecutionBatch( render(); return; } - if (hasImageGenerationResult(batch.completedResults)) { - chatSessionStore.setStreaming(false); - currentCancellationToken = null; - persistCurrentConversation(); - render(); - return; - } persistCurrentConversation(); continueModelAfterTools(generationId, selectedModel, usedToolCallCount, cancellationToken); } - private boolean hasImageGenerationResult(List results) { - if (results == null) { - return false; - } - for (ToolResult result : results) { - if (result != null && "image_generation".equals(result.getToolName())) { - return true; - } - } - return false; - } - private void continueModelAfterTools( int generationId, ModelConfig selectedModel, @@ -3710,11 +3713,9 @@ private boolean isAgentToolAllowed(BaseTool tool, String type) { return false; } if (AgentTool.TYPE_EXPLORE.equals(type)) { - return tool.getCategory() == ToolCategory.READ - || tool.getCategory() == ToolCategory.GENERATE; + return tool.getCategory() == ToolCategory.READ; } return tool.getCategory() == ToolCategory.READ - || tool.getCategory() == ToolCategory.GENERATE || tool.getCategory() == ToolCategory.WRITE || "http_server".equals(name); } @@ -4665,6 +4666,7 @@ private void loadConversation(String id) { } applyConversation(conversation); conversationRepository.setCurrentConversationId(conversation.getId()); + lastMessageModelId = ""; } private void applyConversation(ConversationRecord conversation) { diff --git a/app/src/main/java/cn/lineai/mvp/ModelController.java b/app/src/main/java/cn/lineai/mvp/ModelController.java index a85a477..24a9bac 100644 --- a/app/src/main/java/cn/lineai/mvp/ModelController.java +++ b/app/src/main/java/cn/lineai/mvp/ModelController.java @@ -15,4 +15,6 @@ public interface ModelController { void onModelSaved(ModelConfig model); void onModelsDeleted(List ids); + + void onModelQuickSwitch(String modelId); } diff --git a/app/src/main/java/cn/lineai/ui/MainChatView.java b/app/src/main/java/cn/lineai/ui/MainChatView.java index b11b341..c0d3ae3 100644 --- a/app/src/main/java/cn/lineai/ui/MainChatView.java +++ b/app/src/main/java/cn/lineai/ui/MainChatView.java @@ -216,6 +216,16 @@ public void onModeChanged(String mode) { public void onStop() { MainChatView.this.presenter.onStopGeneration(); } + + @Override + public void onModelQuickSwitch(String modelId) { + MainChatView.this.presenter.onModelQuickSwitch(modelId); + } + + @Override + public void onModelManageClick() { + MainChatView.this.presenter.showModelManagement(); + } }); contentView.addView(composerView, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, diff --git a/app/src/main/java/cn/lineai/ui/component/ChatMessageListView.java b/app/src/main/java/cn/lineai/ui/component/ChatMessageListView.java index 158e141..846922b 100644 --- a/app/src/main/java/cn/lineai/ui/component/ChatMessageListView.java +++ b/app/src/main/java/cn/lineai/ui/component/ChatMessageListView.java @@ -4,6 +4,7 @@ import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.ColorDrawable; +import android.text.TextUtils; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; @@ -214,11 +215,28 @@ private static View createConfigureState(Context context) { return box; } + private static View createModelSwitchNotice(Context context, String noticeText) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER); + LineTheme.padding(row, LineTheme.LG, LineTheme.SM, LineTheme.LG, LineTheme.SM); + TextView label = LineTheme.text(context, noticeText, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + label.setGravity(Gravity.CENTER); + label.setSingleLine(true); + label.setEllipsize(TextUtils.TruncateAt.END); + row.addView(label, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + return row; + } + private static final class MessageAdapter extends BaseAdapter { private static final int MAX_CACHED_ROWS = 140; private static final int VIEW_TYPE_CONFIGURE = 0; private static final int VIEW_TYPE_USER = 1; private static final int VIEW_TYPE_ASSISTANT = 2; + private static final int VIEW_TYPE_MODEL_SWITCH = 3; private final Context context; private final ArrayList visibleMessages = new ArrayList<>(); @@ -324,7 +342,7 @@ public boolean hasStableIds() { @Override public int getViewTypeCount() { - return 3; + return 4; } @Override @@ -333,6 +351,9 @@ public int getItemViewType(int position) { return VIEW_TYPE_CONFIGURE; } ChatMessage message = visibleMessages.get(position); + if (message.isModelSwitchNotification()) { + return VIEW_TYPE_MODEL_SWITCH; + } return message.getRole() == ChatMessage.Role.USER ? VIEW_TYPE_USER : VIEW_TYPE_ASSISTANT; } @@ -342,6 +363,19 @@ public View getView(int position, View convertView, android.view.ViewGroup paren return convertView == null ? createConfigureState(context) : convertView; } ChatMessage message = visibleMessages.get(position); + + if (message.isModelSwitchNotification()) { + String ck = cacheKey(message); + View cached = rowCache.get(ck); + if (cached != null && canReturnCachedView(cached, convertView, parent)) { + return cached; + } + View notice = createModelSwitchNotice(context, message.getModelSwitchNotification()); + rowCache.put(ck, notice); + trimCache(); + return notice; + } + String cacheKey = cacheKey(message); View cached = rowCache.get(cacheKey); if (cached != null && canReturnCachedView(cached, convertView, parent)) { @@ -473,6 +507,7 @@ && stringEquals(a.getReasoningContent(), b.getReasoningContent()) && a.isStreaming() == b.isStreaming() && a.isHidden() == b.isHidden() && stringEquals(a.getCompactStatus(), b.getCompactStatus()) + && stringEquals(a.getModelSwitchNotification(), b.getModelSwitchNotification()) && sameAttachments(a, b) && sameToolCalls(a, b) && sameToolResults(a, b)); diff --git a/app/src/main/java/cn/lineai/ui/component/ComposerView.java b/app/src/main/java/cn/lineai/ui/component/ComposerView.java index c40d50d..d3c663c 100644 --- a/app/src/main/java/cn/lineai/ui/component/ComposerView.java +++ b/app/src/main/java/cn/lineai/ui/component/ComposerView.java @@ -22,6 +22,7 @@ import cn.lineai.model.ChatUiState; import cn.lineai.model.InputAttachment; import cn.lineai.model.InputSettings; +import cn.lineai.model.ModelConfig; import cn.lineai.ui.theme.LineTheme; import java.util.ArrayList; import java.util.Collections; @@ -36,10 +37,16 @@ public interface Listener { void onModeChanged(String mode); void onStop(); + + void onModelQuickSwitch(String modelId); + + void onModelManageClick(); } private final Paint borderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final LinearLayout modelSelectorButton; private final TextView modelText; + private final IconButtonView modelChevron; private final TextView contextText; private final LinearLayout modeSelectorButton; private final TextView modeSelectorText; @@ -51,9 +58,12 @@ public interface Listener { private final IconButtonView sendButton; private final ArrayList attachments = new ArrayList<>(); private PopupWindow modePopup; + private PopupWindow modelPopup; private boolean streaming; private String chatMode = ChatMode.DEFAULT; private String enterKeyBehavior = InputSettings.ENTER_SEND; + private String selectedModelId = ""; + private List availableModels = Collections.emptyList(); private Listener listener; public ComposerView(Context context) { @@ -85,9 +95,30 @@ public ComposerView(Context context) { LineTheme.padding(metaRow, LineTheme.LG, 0, LineTheme.LG, 0); panel.addView(metaRow, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(context, 34))); + modelSelectorButton = new LinearLayout(context); + modelSelectorButton.setOrientation(HORIZONTAL); + modelSelectorButton.setGravity(Gravity.CENTER_VERTICAL); + modelSelectorButton.setClickable(true); + modelSelectorButton.setFocusable(true); + modelSelectorButton.setOnClickListener(v -> showModelPopup(modelSelectorButton)); + modelSelectorButton.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_LIGHT, 14)); + LineTheme.padding(modelSelectorButton, LineTheme.SM, 0, LineTheme.SM, 0); + modelText = LineTheme.textMedium(context, "", LineTheme.FONT_XS, LineTheme.TEXT_SECONDARY); modelText.setSingleLine(true); - metaRow.addView(modelText, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + modelText.setMaxWidth(LineTheme.dp(context, 180)); + modelText.setEllipsize(TextUtils.TruncateAt.END); + modelSelectorButton.addView(modelText, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + + modelChevron = new IconButtonView(context, IconButtonView.CHEVRON_DOWN); + modelChevron.setIconColor(LineTheme.TEXT_SECONDARY); + modelChevron.setIconSizeDp(16, 12); + modelChevron.setClickable(false); + LinearLayout.LayoutParams modelChevronParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 16), LineTheme.dp(context, 16)); + modelChevronParams.leftMargin = LineTheme.dp(context, 2); + modelSelectorButton.addView(modelChevron, modelChevronParams); + + metaRow.addView(modelSelectorButton, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); contextText = LineTheme.text(context, "", LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.BOLD); LinearLayout.LayoutParams contextParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); @@ -152,6 +183,16 @@ public ComposerView(Context context) { } return false; }); + input.setOnFocusChangeListener((view, hasFocus) -> { + if (hasFocus) { + if (modePopup != null && modePopup.isShowing()) { + modePopup.dismiss(); + } + if (modelPopup != null && modelPopup.isShowing()) { + modelPopup.dismiss(); + } + } + }); LinearLayout.LayoutParams inputParams = new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); inputRow.addView(input, inputParams); @@ -280,6 +321,8 @@ public void toggleAttachment(InputAttachment attachment) { public void render(ChatUiState state) { streaming = state.isStreaming(); modelText.setText(state.getModelLabel()); + selectedModelId = state.getSelectedModelId(); + availableModels = state.getAvailableModels(); contextText.setText(state.getContextLabel()); contextText.setTextColor(state.getContextPercent() >= 80 ? LineTheme.WARNING : LineTheme.TEXT_TERTIARY); chatMode = state.getChatMode(); @@ -287,11 +330,15 @@ public void render(ChatUiState state) { if (streaming && modePopup != null) { modePopup.dismiss(); } + if (streaming && modelPopup != null) { + modelPopup.dismiss(); + } input.setEnabled(!streaming); attachButton.setEnabled(!streaming); attachButton.setAlpha(streaming ? 0.62f : 1f); input.setHint(state.hasConfiguredModel() ? "输入消息..." : "请先到设置 → 模型管理配置模型"); updateModeButtons(); + updateModelSelector(); updateSendButton(); } @@ -410,6 +457,104 @@ private void updateModeButtons() { modeSelectorButton.setAlpha(streaming ? 0.62f : 1f); } + private void updateModelSelector() { + modelSelectorButton.setEnabled(!streaming); + modelSelectorButton.setAlpha(streaming ? 0.62f : 1f); + modelChevron.setIconColor(streaming ? LineTheme.TEXT_TERTIARY : LineTheme.TEXT_SECONDARY); + } + + private void showModelPopup(View anchor) { + if (streaming) return; + if (modelPopup != null && modelPopup.isShowing()) { modelPopup.dismiss(); return; } + Context ctx = getContext(); + int popupWidth = LineTheme.dp(ctx, 240); + int rowHeight = LineTheme.dp(ctx, 38); + int separatorHeight = LineTheme.dp(ctx, 1); + int manageRowHeight = LineTheme.dp(ctx, 36); + int modelCount = availableModels.size(); + int visibleRows = Math.min(modelCount, 8); + int popupHeight = rowHeight * visibleRows + separatorHeight + manageRowHeight + LineTheme.dp(ctx, 6); + LinearLayout content = new LinearLayout(ctx); + content.setOrientation(VERTICAL); + content.setBackground(LineTheme.roundedStroke(ctx, LineTheme.INPUT_BG, 14, LineTheme.BORDER_LIGHT)); + LineTheme.padding(content, 3, 3, 3, 3); + if (modelCount > 8) { + android.widget.ScrollView sw = new android.widget.ScrollView(ctx); + LinearLayout sc = new LinearLayout(ctx); + sc.setOrientation(VERTICAL); + for (int i = 0; i < modelCount; i++) { + ModelConfig m = availableModels.get(i); + sc.addView(modelOptionRow(ctx, m, m.getId().equals(selectedModelId)), + new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, rowHeight)); + } + sw.addView(sc, new android.widget.ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + content.addView(sw, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, rowHeight * visibleRows)); + } else { + for (int i = 0; i < modelCount; i++) { + ModelConfig m = availableModels.get(i); + content.addView(modelOptionRow(ctx, m, m.getId().equals(selectedModelId)), + new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, rowHeight)); + } + } + View divider = new View(ctx); + divider.setBackgroundColor(LineTheme.BORDER_LIGHT); + content.addView(divider, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, separatorHeight)); + TextView manageItem = LineTheme.textMedium(ctx, "\u2699 \u7ba1\u7406\u6a21\u578b...", LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY); + manageItem.setGravity(Gravity.CENTER_VERTICAL); + manageItem.setSingleLine(true); + manageItem.setPadding(LineTheme.dp(ctx, LineTheme.MD), 0, LineTheme.dp(ctx, LineTheme.MD), 0); + manageItem.setClickable(true); + manageItem.setOnClickListener(v -> { + if (modelPopup != null) modelPopup.dismiss(); + post(() -> { + if (listener != null) listener.onModelManageClick(); + }); + }); + content.addView(manageItem, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, manageRowHeight)); + modelPopup = new PopupWindow(content, popupWidth, popupHeight, true); + modelPopup.setOutsideTouchable(true); + modelPopup.setBackgroundDrawable(new ColorDrawable(android.graphics.Color.TRANSPARENT)); + int[] location = new int[2]; + anchor.getLocationOnScreen(location); + int screenWidth = ctx.getResources().getDisplayMetrics().widthPixels; + int centeredX = location[0] + (anchor.getWidth() - popupWidth) / 2; + int popupX = Math.max(LineTheme.dp(ctx, LineTheme.SM), Math.min(centeredX, screenWidth - popupWidth - LineTheme.dp(ctx, LineTheme.SM))); + modelPopup.showAtLocation(this, Gravity.NO_GRAVITY, popupX, Math.max(0, location[1] - popupHeight - LineTheme.dp(ctx, 8))); + } + + private LinearLayout modelOptionRow(Context ctx, ModelConfig model, boolean selected) { + LinearLayout row = new LinearLayout(ctx); + row.setOrientation(HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(LineTheme.dp(ctx, LineTheme.MD), 0, LineTheme.dp(ctx, LineTheme.MD), 0); + row.setBackground(LineTheme.rounded(ctx, selected ? LineTheme.ACCENT : android.graphics.Color.TRANSPARENT, 11)); + row.setClickable(true); + row.setOnClickListener(v -> { + if (modelPopup != null) modelPopup.dismiss(); + final String mid = model.getId(); + post(() -> { + if (!mid.equals(selectedModelId) && listener != null) { + listener.onModelQuickSwitch(mid); + } + }); + }); + View dot = new View(ctx); + dot.setBackground(LineTheme.rounded(ctx, selected ? LineTheme.TEXT_ON_COLOR : LineTheme.BORDER, 4)); + LinearLayout.LayoutParams dotParams = new LinearLayout.LayoutParams(LineTheme.dp(ctx, 7), LineTheme.dp(ctx, 7)); + dotParams.rightMargin = LineTheme.dp(ctx, LineTheme.SM); + row.addView(dot, dotParams); + String displayName = model.getName().length() > 0 ? model.getName() : model.getModelId(); + TextView name = LineTheme.textMedium(ctx, displayName, LineTheme.FONT_SM, selected ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT_SECONDARY); + name.setSingleLine(true); + name.setEllipsize(TextUtils.TruncateAt.END); + row.addView(name, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + TextView provider = LineTheme.text(ctx, model.getProviderLabel(), LineTheme.FONT_XS, selected ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams pp = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + pp.leftMargin = LineTheme.dp(ctx, LineTheme.SM); + row.addView(provider, pp); + return row; + } + private void showModePopup(View anchor) { if (streaming) { return; diff --git a/app/src/main/java/cn/lineai/ui/component/toolcall/ToolCallUtils.java b/app/src/main/java/cn/lineai/ui/component/toolcall/ToolCallUtils.java index cedc4c6..9f3b91e 100644 --- a/app/src/main/java/cn/lineai/ui/component/toolcall/ToolCallUtils.java +++ b/app/src/main/java/cn/lineai/ui/component/toolcall/ToolCallUtils.java @@ -49,12 +49,6 @@ static String displayInputLabel(String name, JSONObject input, String workspaceP return workspaceDisplayPath(workspacePath, path); } } - if ("image_generation".equals(name)) { - String prompt = input.optString("prompt").trim(); - if (prompt.length() > 0) { - return prompt; - } - } return inputLabel(name, input); } @@ -94,11 +88,8 @@ static String prettyJson(JSONObject input) { static boolean isReadTool(String name) { return "file_read".equals(name) || "glob".equals(name) || "list_dir".equals(name) || "web_search".equals(name) || "web_fetch".equals(name) - || "image_understanding".equals(name); - } - - static boolean isImageGenerationTool(String name) { - return "image_generation".equals(name); + || "image_understanding".equals(name) + || "image_generation".equals(name); } static boolean isWriteTool(String name) { @@ -137,6 +128,9 @@ private static String normalizePath(String path) { if (!isAbsolutePath(value)) { return trimTrailingSlash(value); } + if (value.startsWith("/")) { + return trimTrailingSlash(value); + } try { return trimTrailingSlash(new File(value).getCanonicalPath().replace('\\', '/')); } catch (Exception ignored) {