给Termux添加GUI模块,免费网页AI编程+Ollama构建一个本地AI聊天应用

前言

这几年AI这么火,只作为下游使用别人开发好的AI产品太没劲了,既然现在AI也可以编程了,我们完全可以自己使用AI开发一款AI产品。刚开始简单一点,我们开发一个已经“烂大街”的AI Chat Android APP。聊天对话是AI应用的基本功能,在F-Droid上也可以找到基于API的开源AI聊天应用。为了让我们的AI应用看起来不那么“平凡”,我们开发一款本地运行AI模型+聊天界面的Android APP。在Android上运行AI模型的常规方法是Termux+Ollama。Termux是Android终端仿真器和Linux环境应用程序,Ollama是本地运行LLM的工具,基于它们我们可以在手机上运行一些小模型(例如Qwen3-0.6B、Gemma4-1B等)。常规的技术方案是Termux作为服务器运行AI模型,浏览器或则AI对话APP作为客户端为用户提供聊天界面。为了使我们的项目新颖一点,我们可以将服务端和客户端合二为一,给Termux添加GUI模块,然后在GUI模块里面构建AI对话功能。当然,这整个过程的代码和技术方案尽可能由AI提供,这样才显得我们像AI时代的程序员。由于没有钱购买token,我们使用免费网页AI。

开发前准备

  1. 下载和安装Android Studio
    Android Studio是用于开发 Android 应用的官方集成开发环境 (IDE)。这款应用比较笨重,下载和安装还挺麻烦的(主要是网络因素影响)。
  2. 免费网页AI
    我使用谷歌的gemini。在我使用的各款免费AI中,综合来看gemini是属于比较聪明和靠谱的,而且它破事少,只要你能连上就能正常稳定使用,不会被封号或者限额。
  3. GitHub上拉取源码,创建新的项目
    我们在GitHub上fork Termux的项目源码(termux/termux-app),新建一个 TermuxWithGUI项目。我们在TermuxWithGUI项目中开发自己的应用。原来的Termux使用Java开发,我们继续使用Java开发。
  4. 基本的Android开发知识(例如Activity、Service、HTTP请求等)
    理想状态下我们什么也不用懂,把需求交给AI,AI就应该交付可用的代码,不过现在的AI似乎还做不到这一点。而且为了省钱我们用的是免费网页AI,也不能对人家要求太高,只能靠人肉多学一些知识弥补AI的不足。

分析Termux源码,如何塞进一个GUI

Termux本身只提供终端界面,我们的思路是往Termux里面塞进一个新的空白的界面,这个界面上的内容由我们自由绘制,为用户提供诸如AI聊天界面这样的图形化功能。所以需要阅读一下Termux源码,看从哪里塞进自己的GUI代码比较好。
分析源码这件事就不劳烦AI了,首先是网页AI读不了源码,其次源码上下文太长,AI抓不住重点。不要被分析源码给吓到,我们不需要从头到尾弄清Termux是如何实现一个Linux终端的,只需要弄明白两件事:

  1. 如何在Termux的终端界面加个按钮,让用户跳转到GUI界面。
  2. Termux的终端界面是如何与后台进程交互的?我们的GUI界面也采用一样的方式与后台进程交互。Ollama在后台进程中运行,用户在AI聊天界面与Ollama的模型对话。
    经过分析,我们可以粗略得到Termux的运行机制:
┌────────────────────────────────────────────┐
│              Termux运行机制                 |   
│                                            | 
│              TermuxActivity                | 
|                   |                        | 
|                   |                        | 
│                   ▼                        | 
│              TermuxService                 | 
│                   |                        | 
│                   |                        | 
│                   ▼                        | 
│              TermuxSession                 | 
│                   |                        | 
│                   |                        | 
│                   ▼                        | 
│              TerminalSession               | 
│                                            | 
|                                            | 
└────────────────────────────────────────────┘

TermuxActivity 就是用户看到的那个黑色终端界面,负责显示文字和接收输入。用户输入命令按下回车后,命令会交给 TermuxService(一个一直后台运行的服务,保证退出界面后任务也不会中断)。随后命令依次经过 TermuxSession(管理当前终端会话的环境,比如工作目录)和 TerminalSession(真正与 Linux 内核交互、执行命令的底层会话)。Activity + Service是Android开发中UI与后台服务分离的经典模式。TerminalSession是应用层的最低层,再往下就是JNI(Java Native Interface)和 Native层开发了。
所以我们接下来要做的事情很清楚了:

  1. 在TermuxActivity 的页面上加一个按钮,支持跳转到我们开发的GuiActivity中。在GuiActivity中我们想开发什么样的页面就开发什么样的页面。
  2. GuiActivity也通过TermuxService与后台进程交互。我们直接复用已有的TermuxService,不需要自己操心后台服务的生命周期和应用保活问题。
    我们的思路是在不影响Termux原有一切功能的同时,塞进自己的GUI模块。
    首先,直接在TermuxActivity的视图文件activity_termux.xml插入跳转按钮。
<com.termux.app.terminal.TermuxActivityRootView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_termux_root_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:fitsSystemWindows="true">
    
    <!-- Termux原来的视图元素在这里面,我们不管它们 -->
    <RelativeLayout...>

   <!-- 下面是我们自己的跳转按钮 -->
    <LinearLayout
        android:id="@+id/custom_bottom_nav"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:background="#1C1C1C"
        android:orientation="horizontal"
        android:gravity="center">

        <LinearLayout
            android:id="@+id/btn_nav_terminal"
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="?android:attr/selectableItemBackground"
            android:clickable="true"
            android:focusable="true"
            android:gravity="center"
            android:orientation="vertical">
            <ImageView
                android:id="@+id/iv_nav_terminal"
                android:layout_width="24dp"
                android:layout_height="24dp"
                android:src="@android:drawable/ic_menu_edit"
                app:tint="#2196F3" />
            <TextView
                android:id="@+id/tv_nav_terminal"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="终端"
                android:textSize="12sp"
                android:textColor="#2196F3" />
        </LinearLayout>

        <View
            android:layout_width="1dp"
            android:layout_height="24dp"
            android:background="#333333" />

        <LinearLayout
            android:id="@+id/btn_nav_gui"
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="?android:attr/selectableItemBackground"
            android:clickable="true"
            android:focusable="true"
            android:gravity="center"
            android:orientation="vertical">
            <ImageView
                android:id="@+id/iv_nav_gui"
                android:layout_width="24dp"
                android:layout_height="24dp"
                android:src="@android:drawable/ic_menu_manage"
                app:tint="#999999" />
            <TextView
                android:id="@+id/tv_nav_gui"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="图形化"
                android:textSize="12sp"
                android:textColor="#999999" />
        </LinearLayout>
    </LinearLayout>
    <View
        android:id="@+id/activity_termux_bottom_space_view"
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="@android:color/transparent" />

</com.termux.app.terminal.TermuxActivityRootView>    

GUI按钮

跳转按钮的逻辑。

        //初始化底边栏的视图元素
        initNavViews();
        // 4. 设置点击监听
        btnNavGui.setOnClickListener(v -> {
            Intent intent = new Intent(this, GuiActivity.class);
            // 核心:复用已有的 Activity 实例,不销毁重建
            intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
            startActivity(intent);
            // 禁用 Activity 跳转动画,实现平滑切换
            overridePendingTransition(0, 0);
        });

我们需要往TerminalSession和TermuxService添加自己的一些方法,这是因为原来的方法都是为Termux自己的终端页面服务的,我们难以直接复用。
TerminalSession中我们主要添加一个新的创建终端会话方法,自定义获得执行命令结果输出的接口。

    //改造
    private TerminalSessionOutputListener terminalSessionOutputListener = null; // 新增:输出监听器

    // 新增:设置监听器
    public void setOutputListener(TerminalSessionOutputListener listener) {
        terminalSessionOutputListener = listener;
    }
    //获得执行命令结果输出的接口
    @SuppressLint("HandlerLeak")
    class BridgeMainThreadHandler extends Handler{
        //...
    }

    final Handler mBridgeMainThreadHandler = new BridgeMainThreadHandler();
    //创建终端会话方法
    public void createEmulator(int columns, int rows, int cellWidthPixels, int cellHeightPixels){
        //...
    }    

在TermuxService中,我们新增创建和管理Termux会话的方法。Service内部使用sessionId管理各个会话,页面视图使用sessionName与自己的会话交互。我们自己创建的会话不和正常的Termux终端界面发起的会话混在一起,大家各管各的,但是应用完全退出后(TermuxService销毁后)所有终端会话都要销毁。


    //内部新增创建Termux会话的方法
    @Nullable
    public TermuxSession createTermuxSessionForGUI(String executablePath, String[] arguments, String stdin,
                                             String workingDirectory, boolean isFailSafe, String sessionName){
                                                //...
                                             }
    @Nullable
    public synchronized TermuxSession createTermuxSessionForGUI(ExecutionCommand executionCommand){
     //...
    }
    //外部调用新增一个会话
    public String addNewSession(boolean isFailSafe, String sessionName,int columns, int rows, int cellWidthPixels, int cellHeightPixels){
    //...
    }
    //外部通过sessionName获得最新输出日志
    public List<LogEntry> getNewLogs(String sessionName, long lastId){
    //...
    }
    //判断某个session是否被创建
    public Boolean containsSession(String sessionName){
    //...
    }
    //执行命令
    public void executeCommand(String sessionName,String command){
    //执行命令
    }
    //终止一个会话
    public void stopSession(String sessionName) {
    //...
    }

经过上面的一番努力,我们终于为我们的AI项目打好了底子。接下来,我们可以在GuiActivity中开发自己的页面,并且通过TermuxService调用Termux的一切功能!

让AI生成完整的聊天界面

好了,接下来是gemini的工作,我们已经把地基打好,接下来就是看它的表现了。由于我们是在Termux的基础上进行二次开发,为了保证与原项目的兼容性,我们尽量避免一口气引入大量的新库、新依赖,我们还是使用传统的Java+Android View模式开发,而不是比较新的Kotlin+Compose(其实也不是什么新鲜玩意了)。

  1. 创建一个Ollama及其模型下载和部署的管理页面。
    用户首先需要安装部署Ollama和AI模型,才能和AI对话。我们通过以下相关命令实现功能。
# 获取ollama版本,用于判断ollama是否已近安装
ollama -v
# 下载安装ollama
pkg update && pkg install ollama -y
# 启动ollama服务
ollama serve &
# 拉取AI模型
ollama pull qwen3:0.6b
# 删除本地模型
ollama rm qwen3:0.6b
# 获取本地模型列表
ollama list

页面上每一个图形化功能对应一个命令。

管理页面

Ollama安装部署的工作流程如下:
判断Ollama是否安装,未安装则安装Ollama,判断Ollama服务是否启动,未启动则启动Ollama服务。

流程图

我们使用网络探测的方式判断ollama的服务是否已经启动。

            try {
                URL url = new URL("http://127.0.0.1:11434/api/tags");
                HttpURLConnection conn = (HttpURLConnection) url.openConnection();
                conn.setConnectTimeout(800);
                isUp = (conn.getResponseCode() == 200);
                System.out.println("ollama http:"+conn.getResponseCode());
                conn.disconnect();
            } catch (Exception ignored) {
                ignored.printStackTrace();
            }
  1. 对话记录
    我们使用数据库框架Room来管理对话记录,Room是 SQLite 的抽象封装,提供了更便捷的数据库操作方式。
    我们记录用户与模型的每一次对话,发起一个对话后用户不能切换模型。
    我们用id、对话标题、对话模型名称、发起对话时间四个要素来表达一个对话。
@Entity(tableName = "chat_sessions")
public class ChatSession {
    @PrimaryKey(autoGenerate = true)
    public long id; // 数据库永久 ID

    public String title;        // 会话标题(如:Ollama 调优建议)
    public String modelName;    // 该会话使用的模型(如:gemma:2b)
    public long lastTimestamp;  // 用于列表排序的时间戳

    public ChatSession(String title, String modelName, long lastTimestamp) {
        this.title = title;
        this.modelName = modelName;
        this.lastTimestamp = lastTimestamp;
    }
}

每一个对话里面都有聊天记录,每一条信息由信息id,关联的对话id,角色,消息内容,发送时间等要素确定。

@Entity(
    tableName = "chat_messages",
    foreignKeys = @ForeignKey(
        entity = ChatSession.class,
        parentColumns = "id",
        childColumns = "sessionId", // 指向持久化的 ChatSession.id
        onDelete = ForeignKey.CASCADE
    ),
    indices = {@Index("sessionId")} // 索引:加快提取上下文的速度
)
public class ChatMessage {
    @PrimaryKey(autoGenerate = true)
    public long id;

    public long sessionId;    // 关联的数据库持久化会话 ID
    public String role;       // "user", "assistant" 或 "system"
    public String content;    // 消息内容
    public String thinking;   //思考过程
    public long timestamp;    // 发送时间

    // 新增:用于 UI 状态反馈
    public int status;        // 0: 发送中, 1: 成功, 2: 失败

    public ChatMessage(long sessionId, String role, String content,String thinking, long timestamp, int status) {
        this.sessionId = sessionId;
        this.role = role;
        this.content = content;
        this.thinking=thinking;
        this.timestamp = timestamp;
        this.status = status;
    }
}
  1. 对话功能
    尽管ollama run+模型名 可以直接启动一个AI对话,但是我们还是选择基于Ollama 的HTTP API构建聊天功能,这样方便直接加载历史对话上下文。我们使用okhttp3网络库。
    我们先定义Ollama API的请求体类和响应体类。
public class OllamaChatRequest {
    public String model;
    public List<Message> messages;
    public boolean stream;

    public boolean think;

    public OllamaChatRequest(String model, List<Message> messages, boolean stream,boolean think) {
        this.model = model;
        this.messages = messages;
        this.stream = stream;
        this.think=think;
    }

    public static class Message {
        public String role;
        public String content;

        public Message(String role, String content) {
            this.role = role;
            this.content = content;
        }
    }
}


public class OllamaResponse {
    public String model;
    @SerializedName("created_at")
    public String createdAt;
    public Message message;
    public boolean done;

    public static class Message {
        public String role;
        public String content;
        public String thinking;
    }
}

有的模型支持输出思考过程(Thinking),所以在请求体里面添加一个think参数用于开启输出思考模式。
我们创建一个OllamaChatRepository类,在这个类里面基于单线程池进行网络IO和数据库IO。

public class OllamaChatRepository {
    private static final String TAG = "OllamaChatRepo";
    private final ChatDao chatDao;
    private final ChatSessionDao sessionDao;
    private final OkHttpClient client;
    private final Gson gson;

    // 使用单线程池确保同一个 Repository 的操作序列化,避免并发写入竞争
    private final ExecutorService executor = Executors.newSingleThreadExecutor();

    public OllamaChatRepository(Context context) {
        AppDatabase db = AppDatabase.getDatabase(context);
        this.chatDao = db.chatDao();
        this.sessionDao = db.chatSessionDao();

        this.gson = new Gson();
        this.client = new OkHttpClient.Builder()
            .connectTimeout(10, TimeUnit.SECONDS)
            .readTimeout(0, TimeUnit.MILLISECONDS) // AI响应可能很长,禁用读取超时
            .build();
    }

    /**
     * 发送流式消息
     * @param sessionId 会话ID
     * @param modelName 使用的模型名
     * @param content 用户输入内容
     */
    public void sendMessageStream(long sessionId, String modelName, String content){
        executor.execute(() -> {
               //网络IO和数据库IO
        }
        )
    }
}

当 OllamaChatRepository 收到网络流式数据(Chunk)后,会不断将新内容追加并调用 chatDao.updateMessage(assistantMsg) 写入本地 SQLite 数据库。

                    OllamaResponse ollamaResp = gson.fromJson(line, OllamaResponse.class);
                    if (ollamaResp != null) {
                        String chunk = "";
                        if(ollamaResp.message.content!=null){
                            chunk = ollamaResp.message.content;
                        }
                        fullContentResponse.append(chunk);
                        String chunk2 ="";
                        if(ollamaResp.message.thinking!=null){
                            chunk2 = ollamaResp.message.thinking;
                        }
                        fullThinkResponse.append(chunk2);
                        // 实时更新数据库,UI 层观察到的 LiveData 会自动刷新同步
                        assistantMsg.content = fullContentResponse.toString();
                        assistantMsg.thinking=fullThinkResponse.toString();
                        Log.d(TAG,"chunk:"+chunk);
                        Log.d(TAG,"thinking:"+chunk2);
                        chatDao.updateMessage(assistantMsg);
                    }

Room 数据库中,getMessagesBySession 返回的是一个 LiveData 对象。这意味着 Room 内部会主动监听该表的变化。一旦数据库发生写入,LiveData 会在子线程检测到,并立即将最新的消息列表推送到主线程。

@Dao
public interface ChatDao {
    @Insert
    long insertMessage(ChatMessage message);

    @Update
    void updateMessage(ChatMessage message);

    // 用于 UI 实时显示消息列表
    @Query("SELECT * FROM chat_messages WHERE sessionId = :sessionId ORDER BY timestamp ASC")
    LiveData<List<ChatMessage>> getMessagesBySession(long sessionId);

    // 核心:用于提取最近的上下文发给 Ollama
    // 我们可以限制条数(如最近 20 条),防止上下文过长导致 Token 溢出
    @Query("SELECT * FROM chat_messages WHERE sessionId = :sessionId ORDER BY timestamp ASC LIMIT :limit")
    List<ChatMessage> getChatContext(long sessionId, int limit);

    @Query("DELETE FROM chat_messages WHERE sessionId = :sessionId")
    void deleteSessionMessages(long sessionId);
}

ChatFragment 中的 setupObservers() 注册了对这个 LiveData 的观察。一旦收到新数据,就会自动触发内部的回调,调用 adapter.setMessages(messages) 更新界面。

chatViewModel.getMessages(sessionId).observe(getViewLifecycleOwner(), messages -> 
{
   //刷新UI 
}
);

整个对话功能采用的是典型的响应式数据流(Reactive Architecture)架构,底层由数据库驱动,遵循网络流 -> 数据库 -> LiveData -> UI这一过程。

聊天记录

存在的问题

  1. AI模型相应速度很慢,这倒不是ollama本地模型的问题,因为ollama本身的命令行对话相应挺快的,而应该是是代码的网络交互和UI刷新问题。
  2. 渲染最新消息时UI的刷新会抽风。

总结

使用免费网页AI编程还是挺有趣的,只是AI第一次给出的代码不一定完全符合你的需求,需要你讨价还价,这个过程还是蛮累的,一点也不轻松,不省心。
项目地址:
TermuxWithGUI

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐