新增AI助手聊天功能 #38

Merged
pvexk5qol merged 1 commits from caoweiqiong_branch into master 4 weeks ago

@ -251,15 +251,28 @@ public class Notes {
public static final String AVATAR_URL = "avatar_url";
}
// [新增] 聊天消息表列名定义
// [新增/更新] 聊天消息表列名定义
public interface ChatColumns {
public static final String ID = "_id";
public static final String SESSION_ID = "session_id";
public static final String SENDER_TYPE = "sender_type"; // 0: User, 1: AI
/**
* :
* 0: (User)
* 1: AI (Assistant)
* 2: /Prompt (System - UI)
*/
public static final String SENDER_TYPE = "sender_type";
/**
* :
* 0: (Text)
* 1: (Reminder Card)
*/
public static final String MSG_TYPE = "msg_type";
public static final int MSG_TYPE_TEXT = 0; // 普通对话
public static final int MSG_TYPE_REMINDER = 1; // [核心] 提醒卡片UI显示金色气泡
public static final String CONTENT = "content";
public static final String MSG_TYPE = "msg_type"; // 0: Text, 1: Agenda Card
public static final String CREATED_AT = "created_at";
public static final String SYNC_STATE = "sync_state";
}
public interface DataColumns {

@ -30,7 +30,7 @@ import net.micode.notes.data.Notes.NoteColumns;
public class NotesDatabaseHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "note.db";
private static final int DB_VERSION = 10;
private static final int DB_VERSION = 11;
public interface TABLE {
public static final String NOTE = "note";
@ -93,12 +93,10 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
private static final String CREATE_CHAT_TABLE_SQL =
"CREATE TABLE " + TABLE.CHAT_MESSAGES + "(" +
Notes.ChatColumns.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
Notes.ChatColumns.SESSION_ID + " TEXT," +
Notes.ChatColumns.SENDER_TYPE + " INTEGER DEFAULT 0," +
Notes.ChatColumns.CONTENT + " TEXT," +
Notes.ChatColumns.MSG_TYPE + " INTEGER DEFAULT 0," +
Notes.ChatColumns.CREATED_AT + " INTEGER," +
Notes.ChatColumns.SYNC_STATE + " INTEGER DEFAULT 1" +
Notes.ChatColumns.CONTENT + " TEXT," +
Notes.ChatColumns.CREATED_AT + " INTEGER" +
")";
private static final String CREATE_DATA_TABLE_SQL =
@ -388,6 +386,12 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
oldVersion = 10;
}
// [新增] 升级到 v11
if (oldVersion < 11) {
upgradeToV11(db);
oldVersion = 11;
}
if (reCreateTriggers) {
reCreateNoteTableTriggers(db);
reCreateDataTableTriggers(db);
@ -399,6 +403,13 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
}
}
private void upgradeToV11(SQLiteDatabase db) {
Log.d(TAG, "Upgrading database to version 11...");
// 先尝试删除旧表(如果是开发过程中的残留),再创建新表
db.execSQL("DROP TABLE IF EXISTS " + TABLE.CHAT_MESSAGES);
db.execSQL(CREATE_CHAT_TABLE_SQL);
}
private void upgradeToV10(SQLiteDatabase db) {
Log.d(TAG, "Upgrading database to version 10...");
// 使用 try-catch 确保即使字段已存在(手动清过数据的情况)也不会崩

@ -50,6 +50,7 @@ public class NotesProvider extends ContentProvider {
private static final int URI_SEARCH = 5;
private static final int URI_SEARCH_SUGGEST = 6;
private static final int URI_USER_ACCOUNT = 7; // 新增编号
private static final int URI_CHAT_MESSAGES = 8;
static {
mMatcher = new UriMatcher(UriMatcher.NO_MATCH);
@ -61,6 +62,8 @@ public class NotesProvider extends ContentProvider {
mMatcher.addURI(Notes.AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY, URI_SEARCH_SUGGEST);
mMatcher.addURI(Notes.AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY + "/*", URI_SEARCH_SUGGEST);
mMatcher.addURI(Notes.AUTHORITY, "user_account", URI_USER_ACCOUNT);
// [核心新增]:让系统认识 content://micode_notes/chat_messages
mMatcher.addURI(Notes.AUTHORITY, "chat_messages", URI_CHAT_MESSAGES);
}
/**
@ -140,6 +143,9 @@ public class NotesProvider extends ContentProvider {
Log.e(TAG, "got exception: " + ex.toString());
}
break;
case URI_CHAT_MESSAGES:
c = db.query(NotesDatabaseHelper.TABLE.CHAT_MESSAGES, projection, selection, selectionArgs, null, null, sortOrder);
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
@ -168,6 +174,12 @@ public class NotesProvider extends ContentProvider {
case URI_USER_ACCOUNT:
insertedId = db.insert(TABLE.USER_ACCOUNT, null, values);
break;
// [核心新增]:处理聊天消息的真正入库逻辑
case URI_CHAT_MESSAGES:
insertedId = db.insert(NotesDatabaseHelper.TABLE.CHAT_MESSAGES, null, values);
// 插入成功后发送数据变更通知,让 ChatFragment 能够自动刷新
getContext().getContentResolver().notifyChange(uri, null);
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}

@ -15,16 +15,29 @@ import androidx.work.WorkerParameters;
import com.google.firebase.auth.FirebaseAuth;
import com.google.firebase.firestore.FirebaseFirestore;
import com.google.firebase.firestore.QueryDocumentSnapshot;
import com.google.gson.JsonObject;
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
import net.micode.notes.model.CloudNote;
import net.micode.notes.tool.AiNotificationHelper;
import net.micode.notes.tool.SyncMapper;
import net.micode.notes.tool.ai.AiDataSyncHelper;
import net.micode.notes.tool.ai.CozeClient;
import net.micode.notes.tool.ai.CozeRequest;
import net.micode.notes.tool.ai.CozeResponse;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class SyncWorker extends Worker {
private static final String TAG = "SyncWorker";
public static final String KEY_SYNC_MODE = "sync_mode";
public static final int MODE_ALL = 0; // 默认:全量同步
public static final int MODE_PUSH = 1; // 仅上传
public static final int MODE_PULL = 2; // 仅拉取
public static final int MODE_REMINDER = 3;
public SyncWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
@ -33,34 +46,209 @@ public class SyncWorker extends Worker {
@NonNull
@Override
public Result doWork() {
// [确切位置]:在方法入口处立即进行安全隔离
// 准备一个锁,数量为 1
final CountDownLatch latch = new CountDownLatch(1);
// 用于记录是否发生错误
final boolean[] isSuccess = {true};
try {
String uid = FirebaseAuth.getInstance().getUid();
if (uid == null) {
Log.w(TAG, "Sync skipped: No user logged in.");
return Result.success();
}
if (uid == null) return Result.success();
FirebaseFirestore db = FirebaseFirestore.getInstance();
int mode = getInputData().getInt(KEY_SYNC_MODE, MODE_ALL);
// 1. 先执行上行同步
performPush(db, uid);
Log.d(TAG, "Starting Sync with mode: " + mode);
// --- PUSH 逻辑 (保持同步执行即可因为它本身不依赖回调返回数据给UI) ---
if (mode == MODE_PUSH || mode == MODE_ALL) {
performPush(db, uid);
syncToCozeAgent();
}
// --- PULL 逻辑 (异步变同步) ---
if (mode == MODE_PULL || mode == MODE_ALL) {
// 传递 latch 进去
performPull(db, uid, latch, isSuccess);
// [关键]:主线程在这里死等,直到 latch.countDown() 被调用,或者超时(10秒)
try {
latch.await(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
return Result.retry();
}
}
// 2. 后执行下行同步
performPull(db, uid);
// 2. 在 doWork() 中增加判断
if (mode == MODE_REMINDER) {
String title = getInputData().getString("reminder_title");
requestAiReminderReply(title);
}
return Result.success();
return isSuccess[0] ? Result.success() : Result.failure();
} catch (SecurityException e) {
// [核心修复]:捕获 "Unknown calling package name 'com.google.android.gms'"
Log.e(TAG, "GMS Security Exception caught, retrying later: " + e.getMessage());
return Result.retry(); // 告诉系统稍后重试,不要直接闪退进程
} catch (Exception e) {
Log.e(TAG, "General sync error: " + e.getMessage());
return Result.failure();
}
}
// 3. 实现提醒请求逻辑
private void requestAiReminderReply(String agendaTitle) {
try {
// 1. 构造请求参数
com.google.gson.JsonObject json = new com.google.gson.JsonObject();
json.addProperty("intent", "reminder");
json.addProperty("payload", "日程即将开始:" + agendaTitle);
json.addProperty("current_time", AiDataSyncHelper.getCurrentTime());
// 2. 发起对话
CozeRequest request = new CozeRequest(CozeClient.BOT_ID, "user_demo", json.toString());
retrofit2.Response<CozeResponse> response = CozeClient.getInstance()
.chat(CozeClient.getAuthToken(), request).execute();
// 声明变量,用于存放最终结果
String finalAnswer = null;
if (response.isSuccessful() && response.body() != null && response.body().data != null) {
String chatId = response.body().data.id;
String convId = response.body().data.conversation_id;
// 3. 轮询状态直到完成
String status = "";
int retries = 0;
while (!"completed".equals(status) && retries < 15) {
Thread.sleep(2000);
retrofit2.Response<CozeResponse> pollResp = CozeClient.getInstance()
.retrieveChat(CozeClient.getAuthToken(), chatId, convId).execute();
if (pollResp.isSuccessful() && pollResp.body() != null && pollResp.body().data != null) {
status = pollResp.body().data.status;
}
retries++;
}
// 4. [核心修复点]:状态完成后,请求消息列表并解析出 content
if ("completed".equals(status)) {
retrofit2.Response<com.google.gson.JsonObject> msgListResp = CozeClient.getInstance()
.getMessageList(CozeClient.getAuthToken(), chatId, convId).execute();
if (msgListResp.isSuccessful() && msgListResp.body() != null) {
com.google.gson.JsonArray messages = msgListResp.body().getAsJsonArray("data");
// 遍历寻找 AI 的回答内容
for (com.google.gson.JsonElement el : messages) {
com.google.gson.JsonObject m = el.getAsJsonObject();
if ("assistant".equals(m.get("role").getAsString()) &&
"answer".equals(m.get("type").getAsString())) {
finalAnswer = m.get("content").getAsString();
break;
}
}
}
}
}
// 5. 统一处理最终结果
if (finalAnswer != null && !finalAnswer.isEmpty()) {
saveReminderToLocal(finalAnswer);
AiNotificationHelper.sendAiNotification(getApplicationContext(), finalAnswer);
Log.d("AiReminder", "Successfully got AI reminder: " + finalAnswer);
} else {
Log.w("AiReminder", "AI returned empty answer for reminder.");
}
} catch (Exception e) {
Log.e("AiReminder", "Error in requestAiReminderReply", e);
}
}
private void saveReminderToLocal(String content) {
android.content.ContentValues values = new android.content.ContentValues();
values.put(Notes.ChatColumns.SENDER_TYPE, 1); // AI
values.put(Notes.ChatColumns.MSG_TYPE, 1); // [重要] 设为提醒卡片类型
values.put(Notes.ChatColumns.CONTENT, content);
values.put(Notes.ChatColumns.CREATED_AT, System.currentTimeMillis());
getApplicationContext().getContentResolver().insert(
android.net.Uri.parse("content://micode_notes/chat_messages"), values);
}
// 在 SyncWorker 类中添加
private void syncToCozeAgent() {
try {
com.google.gson.JsonObject payloadJson = new com.google.gson.JsonObject();
payloadJson.addProperty("intent", "sync");
payloadJson.addProperty("payload", AiDataSyncHelper.getSyncPayload(getApplicationContext()));
payloadJson.addProperty("current_time", AiDataSyncHelper.getCurrentTime());
CozeRequest request = new CozeRequest(CozeClient.BOT_ID, "user_demo", payloadJson.toString());
// 1. 发起对话
retrofit2.Response<CozeResponse> response = CozeClient.getInstance()
.chat(CozeClient.getAuthToken(), request).execute();
if (response.isSuccessful() && response.body() != null && response.body().data != null) {
String chatId = response.body().data.id;
String convId = response.body().data.conversation_id; // [关键] 获取会话ID
String status = "";
int retries = 0;
while (!"completed".equals(status) && retries < 15) {
Thread.sleep(2000);
// [关键] 传入两个 ID 轮询
retrofit2.Response<CozeResponse> pollResp = CozeClient.getInstance()
.retrieveChat(CozeClient.getAuthToken(), chatId, convId).execute();
if (pollResp.isSuccessful() && pollResp.body() != null && pollResp.body().data != null) {
status = pollResp.body().data.status;
}
retries++;
}
// 2. 完成后抓取真正的“Answer”
if ("completed".equals(status)) {
retrofit2.Response<com.google.gson.JsonObject> msgListResp = CozeClient.getInstance()
.getMessageList(CozeClient.getAuthToken(), chatId, convId).execute();
if (msgListResp.isSuccessful() && msgListResp.body() != null) {
com.google.gson.JsonArray messages = msgListResp.body().getAsJsonArray("data");
String finalAnswer = null;
// 遍历寻找 role=assistant 且 type=answer 的内容
for (com.google.gson.JsonElement el : messages) {
com.google.gson.JsonObject m = el.getAsJsonObject();
if ("assistant".equals(m.get("role").getAsString()) &&
"answer".equals(m.get("type").getAsString())) {
finalAnswer = m.get("content").getAsString();
break;
}
}
if (finalAnswer != null && !finalAnswer.isEmpty()) {
// 插入本地数据库并弹通知
saveChatMessageToLocal(finalAnswer);
AiNotificationHelper.sendAiNotification(getApplicationContext(), finalAnswer);
}
}
}
}
} catch (Exception e) {
Log.e("CozeSync", "Sync Failed", e);
}
}
// 辅助方法:将 AI 消息存入本地 chat_messages 表
private void saveChatMessageToLocal(String content) {
android.content.ContentValues values = new android.content.ContentValues();
values.put(net.micode.notes.data.Notes.ChatColumns.SENDER_TYPE, 1); // AI
values.put(net.micode.notes.data.Notes.ChatColumns.MSG_TYPE, 0); // 文本
values.put(net.micode.notes.data.Notes.ChatColumns.CONTENT, content);
values.put(net.micode.notes.data.Notes.ChatColumns.CREATED_AT, System.currentTimeMillis());
getApplicationContext().getContentResolver().insert(
android.net.Uri.parse("content://micode_notes/chat_messages"), values);
}
/**
* [] sync_state = 1
*/
@ -114,19 +302,33 @@ public class SyncWorker extends Worker {
/**
* []
*/
private void performPull(FirebaseFirestore db, String uid) {
// 修改 performPull 方法签名,增加 latch 和 状态标记
private void performPull(FirebaseFirestore db, String uid, CountDownLatch latch, boolean[] isSuccess) {
db.collection("users").document(uid).collection("notes")
.get()
.addOnSuccessListener(queryDocumentSnapshots -> {
for (QueryDocumentSnapshot doc : queryDocumentSnapshots) {
CloudNote cloudNote = doc.toObject(CloudNote.class);
if (cloudNote != null) {
mergeCloudNoteToLocal(cloudNote);
try {
for (QueryDocumentSnapshot doc : queryDocumentSnapshots) {
CloudNote cloudNote = doc.toObject(CloudNote.class);
if (cloudNote != null) {
mergeCloudNoteToLocal(cloudNote);
}
}
Log.d(TAG, "Pull complete. Cloud docs processed.");
} catch (Exception e) {
Log.e(TAG, "Error merging data", e);
isSuccess[0] = false;
} finally {
// [关键]:通知主线程,活干完了,可以放行了
latch.countDown();
}
Log.d(TAG, "Pull complete. Cloud docs processed.");
})
.addOnFailureListener(e -> Log.e(TAG, "Pull failed", e));
.addOnFailureListener(e -> {
Log.e(TAG, "Pull failed", e);
isSuccess[0] = false;
// [关键]:失败了也要通知放行,否则会死锁
latch.countDown();
});
}
/**

@ -0,0 +1,61 @@
package net.micode.notes.tool;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import androidx.core.app.NotificationCompat;
import net.micode.notes.R;
import net.micode.notes.ui.NotesListActivity;
public class AiNotificationHelper {
private static final String CHANNEL_ID = "mi_chat_channel";
private static final String CHANNEL_NAME = "Mi Chat AI Assistant";
private static final int NOTIFICATION_ID = 1001;
public static void sendAiNotification(Context context, String content) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
// 1. 创建通知渠道 (Android 8.0+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH // 重要通知,会弹出
);
channel.setDescription("Notifications from AI Assistant");
channel.enableLights(true);
channel.enableVibration(true);
manager.createNotificationChannel(channel);
}
// 2. 设置点击跳转意图
// 跳转到 NotesListActivity并携带参数指引它打开 ChatFragment
Intent intent = new Intent(context, NotesListActivity.class);
intent.putExtra("open_nav_ai", true); // 这个 Extra 需要在 NotesListActivity 中处理
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
// 3. 构建通知
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.icon_app) // 确保有这个资源
.setContentTitle("Mi Chat")
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setDefaults(Notification.DEFAULT_ALL);
// 4. 发送
manager.notify(NOTIFICATION_ID, builder.build());
}
}

@ -0,0 +1,170 @@
package net.micode.notes.tool;
import android.content.Context;
import android.os.AsyncTask;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class DeepSeekHelper {
private static final String TAG = "DeepSeekHelper";
private static final String API_KEY = "sk-6fd917bdb96a48fba119dc64e58bf458";
private static final String API_URL = "https://api.deepseek.com/chat/completions";
public interface AICallback {
void onSuccess(List<AgendaResult> results);
void onFailure(String error);
}
public static class AgendaResult {
public String title;
public long startTime;
public long endTime;
public String timeLabel; // 例如 "16:00", "下周", "12月", "全天"
public AgendaResult(String title, long startTime, long endTime, String timeLabel) {
this.title = title;
this.startTime = startTime;
this.endTime = endTime;
this.timeLabel = timeLabel;
}
}
public static void analyzeContent(String content, AICallback callback) {
new AnalyzeTask(content, callback).execute();
}
private static class AnalyzeTask extends AsyncTask<Void, Void, String> {
private String content;
private AICallback callback;
private List<AgendaResult> parsedResults = new ArrayList<>();
public AnalyzeTask(String content, AICallback callback) {
this.content = content;
this.callback = callback;
}
@Override
protected String doInBackground(Void... voids) {
try {
// 获取当前时间
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd EEEE HH:mm", Locale.CHINA);
String currentTime = sdf.format(new Date());
// [核心优化] 更加智能的 Prompt
String systemPrompt =
"Current Time: " + currentTime + ".\n" +
"Role: You are a strict JSON schedule parser.\n" +
"Task: Extract unique events from user text.\n" +
"Rules:\n" +
"1. ONE EVENT = ONE JSON OBJECT. Never duplicate an event.\n" +
"2. If an event has a specific time (e.g. 8:00), set 'start' and 'end' to that exact timestamp.\n" +
"3. If an event is for a whole day, set 'start' to 00:00 and 'end' to 23:59 of that day.\n" +
"4. Format: [{'title': string, 'start': 'yyyy-MM-dd HH:mm', 'end': 'yyyy-MM-dd HH:mm', 'label': string}].\n" +
"5. The 'label' MUST be either 'HH:mm' for specific points, or '全天', '本周', '本月'.\n" +
"No explanations, return ONLY JSON.";
// 3. 构建 JSON Body
JSONObject userMsg = new JSONObject();
userMsg.put("role", "user");
userMsg.put("content", content);
JSONObject sysMsg = new JSONObject();
sysMsg.put("role", "system");
sysMsg.put("content", systemPrompt);
JSONArray messages = new JSONArray();
messages.put(sysMsg);
messages.put(userMsg);
JSONObject jsonBody = new JSONObject();
jsonBody.put("model", "deepseek-chat");
jsonBody.put("messages", messages);
jsonBody.put("temperature", 0.1); // 低温度以保证格式稳定
// 4. 发起 HTTP 请求
URL url = new URL(API_URL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + API_KEY);
conn.setDoOutput(true);
try(OutputStream os = conn.getOutputStream()) {
byte[] input = jsonBody.toString().getBytes("utf-8");
os.write(input, 0, input.length);
}
// 5. 读取响应
int code = conn.getResponseCode();
if (code == 200) {
BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"));
StringBuilder response = new StringBuilder();
String responseLine = null;
while ((responseLine = br.readLine()) != null) {
response.append(responseLine.trim());
}
// 6. 解析 DeepSeek 返回的 JSON
JSONObject responseJson = new JSONObject(response.toString());
String aiContent = responseJson.getJSONArray("choices")
.getJSONObject(0).getJSONObject("message").getString("content");
// 清洗可能存在的 Markdown 代码块标记 ```json ... ```
if (aiContent.startsWith("```")) {
aiContent = aiContent.replaceAll("```json", "").replaceAll("```", "");
}
// 更新解析逻辑
JSONArray events = new JSONArray(aiContent.trim());
SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault());
for (int i = 0; i < events.length(); i++) {
JSONObject event = events.getJSONObject(i);
String title = event.getString("title");
String startStr = event.getString("start");
String endStr = event.getString("end");
String label = event.getString("label");
Date start = parser.parse(startStr);
Date end = parser.parse(endStr);
if (start != null && end != null) {
parsedResults.add(new AgendaResult(title, start.getTime(), end.getTime(), label));
}
}
return null; // Success
} else {
return "Error Code: " + code;
}
} catch (Exception e) {
Log.e(TAG, "AI Request Failed", e);
return e.getMessage();
}
}
@Override
protected void onPostExecute(String error) {
if (error == null) {
if (parsedResults.isEmpty()) {
callback.onFailure("未识别到包含时间的日程信息");
} else {
callback.onSuccess(parsedResults);
}
} else {
callback.onFailure("AI 服务连接失败: " + error);
}
}
}
}

@ -0,0 +1,57 @@
package net.micode.notes.tool.ai;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
public class AiDataSyncHelper {
public static String getSyncPayload(Context context) {
ContentResolver cr = context.getContentResolver();
StringBuilder sb = new StringBuilder();
// 1. 提取最近修改的便签 (取前10条以防Prompt过长)
Cursor cursor = cr.query(Notes.CONTENT_NOTE_URI, null,
NoteColumns.TYPE + "=" + Notes.TYPE_NOTE + " AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER,
null, NoteColumns.MODIFIED_DATE + " DESC LIMIT 10");
sb.append("【用户便签改动】\n");
if (cursor != null) {
while (cursor.moveToNext()) {
String title = cursor.getString(cursor.getColumnIndex(NoteColumns.TITLE));
String content = cursor.getString(cursor.getColumnIndex(NoteColumns.SNIPPET));
long time = cursor.getLong(cursor.getColumnIndex(NoteColumns.MODIFIED_DATE));
sb.append("- 标题: ").append(title != null ? title : "无")
.append(" | 内容: ").append(content)
.append(" | 时间: ").append(new Date(time).toString()).append("\n");
}
cursor.close();
}
// 2. 提取日程安排 (IS_AGENDA = 1)
Cursor agendaCursor = cr.query(Notes.CONTENT_NOTE_URI, null,
NoteColumns.IS_AGENDA + "=1", null, null);
sb.append("\n【当前日程安排】\n");
if (agendaCursor != null) {
while (agendaCursor.moveToNext()) {
String snippet = agendaCursor.getString(agendaCursor.getColumnIndex(NoteColumns.SNIPPET));
long date = agendaCursor.getLong(agendaCursor.getColumnIndex(NoteColumns.AGENDA_DATE));
sb.append("- 事项: ").append(snippet)
.append(" | 预定时间: ").append(new Date(date).toString()).append("\n");
}
agendaCursor.close();
}
return sb.toString();
}
public static String getCurrentTime() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(new Date());
}
}

@ -0,0 +1,33 @@
package net.micode.notes.tool.ai;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import androidx.work.Data;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import net.micode.notes.sync.SyncWorker;
public class AiReminderReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (AiReminderScheduler.ACTION_AI_REMINDER.equals(intent.getAction())) {
String title = intent.getStringExtra("title");
Log.d("AiReminder", "Alarm trigger for: " + title);
// 唤醒 WorkManager 执行一次性提醒请求
// 借用 SyncWorker 里的逻辑,或者单独建一个业务逻辑
Data inputData = new Data.Builder()
.putInt(SyncWorker.KEY_SYNC_MODE, 3) // 我们定义一个新模式MODE_REMINDER = 3
.putString("reminder_title", title)
.build();
OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(SyncWorker.class)
.setInputData(inputData)
.build();
WorkManager.getInstance(context).enqueue(request);
}
}
}

@ -0,0 +1,91 @@
package net.micode.notes.tool.ai;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
import java.util.Calendar;
public class AiReminderScheduler {
public static final String ACTION_AI_REMINDER = "net.micode.notes.ACTION_AI_REMINDER";
/**
*
*/
public static void updateAllReminders(Context context) {
AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
// 1. 查询所有有效日程 (未完成且是日程)
Cursor c = context.getContentResolver().query(Notes.CONTENT_NOTE_URI,
null, NoteColumns.IS_AGENDA + "=1 AND " + NoteColumns.IS_COMPLETED + "=0",
null, null);
if (c != null) {
while (c.moveToNext()) {
long noteId = c.getLong(c.getColumnIndex(NoteColumns.ID));
String title = c.getString(c.getColumnIndex(NoteColumns.SNIPPET));
long startTime = c.getLong(c.getColumnIndex(NoteColumns.AGENDA_DATE));
long endTime = c.getLong(c.getColumnIndex(NoteColumns.AGENDA_END_DATE));
String timeLabel = c.getString(c.getColumnIndex(NoteColumns.TIME_LABEL));
long triggerTime = calculateTriggerTime(startTime, timeLabel);
// 只有未来的时间才设闹钟
if (triggerTime > System.currentTimeMillis()) {
setExactAlarm(context, am, noteId, title, triggerTime);
}
}
c.close();
}
}
private static long calculateTriggerTime(long startTime, String timeLabel) {
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(startTime);
if (timeLabel != null && timeLabel.contains(":")) {
// A. 具体时间点事件提前1小时
return startTime - (60 * 60 * 1000);
} else if ("全天".equals(timeLabel)) {
// B. 全天事件:当天早上 6:00
cal.set(Calendar.HOUR_OF_DAY, 6);
cal.set(Calendar.MINUTE, 0);
return cal.getTimeInMillis();
} else {
// C. 跨度/周期事件:当天早上 8:00
cal.set(Calendar.HOUR_OF_DAY, 8);
cal.set(Calendar.MINUTE, 0);
return cal.getTimeInMillis();
}
}
private static void setExactAlarm(Context context, AlarmManager am, long id, String title, long time) {
try {
Intent intent = new Intent(context, AiReminderReceiver.class);
intent.setAction(ACTION_AI_REMINDER);
intent.putExtra("note_id", id);
intent.putExtra("title", title);
PendingIntent pi = PendingIntent.getBroadcast(context, (int)id, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// 核心修复:增加权限判断
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
if (am.canScheduleExactAlarms()) {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi);
} else {
// 退而求其次,使用非精准闹钟,防止崩溃
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi);
}
} else {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi);
}
} catch (Exception e) {
android.util.Log.e("AiReminder", "Failed to set alarm", e);
}
}
}

@ -0,0 +1,31 @@
package net.micode.notes.tool.ai;
import com.google.gson.JsonObject;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.Header;
import retrofit2.http.POST;
import retrofit2.http.Query;
public interface CozeApiService {
@POST("v3/chat")
Call<CozeResponse> chat(@Header("Authorization") String token, @Body CozeRequest request);
// [核心修复] 轮询接口:使用 Query 参数传递 IDs
@POST("v3/chat/retrieve")
Call<CozeResponse> retrieveChat(
@Header("Authorization") String token,
@Query("chat_id") String chatId,
@Query("conversation_id") String conversationId
);
// [核心新增] 获取消息列表:用于抓取 AI 的文本回复
@GET("v3/chat/message/list")
Call<com.google.gson.JsonObject> getMessageList(
@Header("Authorization") String token,
@Query("chat_id") String chatId,
@Query("conversation_id") String conversationId
);
}

@ -0,0 +1,40 @@
package net.micode.notes.tool.ai;
import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class CozeClient {
private static final String BASE_URL = "https://api.coze.cn/";
// TODO: 填入你的个人访问令牌 (PAT)
private static final String API_TOKEN = "Bearer pat_U2WS2ta9XWxz08ePQNloPw3mHxhmIGDf22ezG5JgyVC3CGrggE4SITQC4jZuIWE8";
// TODO: 填入你的智能体 ID (Bot ID)
public static final String BOT_ID = "7600011117091864585";
private static CozeApiService apiService;
public static CozeApiService getInstance() {
if (apiService == null) {
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build();
apiService = retrofit.create(CozeApiService.class);
}
return apiService;
}
public static String getAuthToken() {
return API_TOKEN;
}
}

@ -0,0 +1,42 @@
package net.micode.notes.tool.ai;
import com.google.gson.annotations.SerializedName;
import java.util.ArrayList;
import java.util.List;
public class CozeRequest {
@SerializedName("bot_id")
private String botId;
@SerializedName("user_id")
private String userId;
@SerializedName("additional_messages")
private List<Message> additionalMessages;
@SerializedName("stream")
private boolean stream = false; // 我们采用非流式,方便后台处理
public CozeRequest(String botId, String userId, String content) {
this.botId = botId;
this.userId = userId;
this.additionalMessages = new ArrayList<>();
// 将意图和数据组合成一条用户消息发给智能体
this.additionalMessages.add(new Message("user", content, "text"));
}
public static class Message {
@SerializedName("role")
private String role;
@SerializedName("content")
private String content;
@SerializedName("content_type")
private String contentType;
public Message(String role, String content, String contentType) {
this.role = role;
this.content = content;
this.contentType = contentType;
}
}
}

@ -0,0 +1,21 @@
package net.micode.notes.tool.ai;
import com.google.gson.annotations.SerializedName;
import java.util.List;
public class CozeResponse {
@SerializedName("code")
public int code;
@SerializedName("msg")
public String msg;
@SerializedName("data")
public ChatData data;
public static class ChatData {
public String id; // chat_id
public String conversation_id; // [核心修复] 必须添加此字段
public String status;
}
}

@ -20,6 +20,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.tool.ai.AiReminderScheduler;
import net.micode.notes.data.Notes.NoteColumns;
import java.util.ArrayList;
import java.util.Calendar;
@ -151,6 +152,14 @@ public class AgendaFragment extends Fragment {
cv.put(NoteColumns.LOCAL_MODIFIED, 1);
getContext().getContentResolver().insert(Notes.CONTENT_NOTE_URI, cv);
// [新增] 核心补丁:让 AI 助理感知到新日程
try {
AiReminderScheduler.updateAllReminders(getContext());
android.util.Log.d("AiReminder", "Scheduled from Agenda Quick Add");
} catch (Exception e) {
e.printStackTrace();
}
// 重置 UI
et.setText("");
tvQuickTime.setText("全天");

@ -20,6 +20,9 @@ public class ChatFragment extends Fragment {
private List<ChatMessage> mMessages = new ArrayList<>();
private EditText mInput;
private android.database.ContentObserver mChatObserver;
private static final android.net.Uri CHAT_URI = android.net.Uri.parse("content://micode_notes/chat_messages");
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_chat, container, false);
@ -31,19 +34,172 @@ public class ChatFragment extends Fragment {
mRecyclerView.setAdapter(mAdapter);
view.findViewById(R.id.btn_chat_send).setOnClickListener(v -> {
String text = mInput.getText().toString().trim();
if (!text.isEmpty()) {
// 1. 本地插入用户消息
ChatMessage userMsg = new ChatMessage(0, 0, text);
mMessages.add(userMsg);
mAdapter.notifyItemInserted(mMessages.size() - 1);
mRecyclerView.smoothScrollToPosition(mMessages.size() - 1);
mInput.setText("");
// TODO: Step 3 将在此处调用 CozeClient
}
final String text = mInput.getText().toString().trim();
if (text.isEmpty()) return;
// --- 第一部分:将用户消息存入数据库 ---
// 之前我们是手动 mMessages.add(...),现在不用了!
// 因为 ContentObserver 监听到数据库插入后,会自动调用 loadHistory 刷新列表。
new Thread(() -> {
android.content.ContentValues userValues = new android.content.ContentValues();
userValues.put(net.micode.notes.data.Notes.ChatColumns.SENDER_TYPE, 0); // 0 代表用户
userValues.put(net.micode.notes.data.Notes.ChatColumns.MSG_TYPE, 0); // 普通文本
userValues.put(net.micode.notes.data.Notes.ChatColumns.CONTENT, text);
userValues.put(net.micode.notes.data.Notes.ChatColumns.CREATED_AT, System.currentTimeMillis());
// 执行插入
getContext().getContentResolver().insert(CHAT_URI, userValues);
}).start();
// 清空输入框
mInput.setText("");
// --- 第二部分:向 AI 发起请求 (逻辑保持之前的异步方式) ---
new Thread(() -> {
try {
// 1. 组装请求 (intent=chat)
com.google.gson.JsonObject json = new com.google.gson.JsonObject();
json.addProperty("intent", "chat");
json.addProperty("payload", text);
json.addProperty("current_time", net.micode.notes.tool.ai.AiDataSyncHelper.getCurrentTime());
net.micode.notes.tool.ai.CozeRequest request = new net.micode.notes.tool.ai.CozeRequest(
net.micode.notes.tool.ai.CozeClient.BOT_ID, "user_demo", json.toString());
// 2. 发起 API 请求并轮询完成状态
retrofit2.Response<net.micode.notes.tool.ai.CozeResponse> response =
net.micode.notes.tool.ai.CozeClient.getInstance().chat(
net.micode.notes.tool.ai.CozeClient.getAuthToken(), request).execute();
if (response.isSuccessful() && response.body() != null && response.body().data != null) {
String chatId = response.body().data.id;
String convId = response.body().data.conversation_id;
android.util.Log.d("MiChat", "开始轮询 AI 响应, ChatID: " + chatId);
String status = "";
int retries = 0;
while (!"completed".equals(status) && retries < 30) { // 最多等 60 秒
Thread.sleep(2000);
retrofit2.Response<net.micode.notes.tool.ai.CozeResponse> poll =
net.micode.notes.tool.ai.CozeClient.getInstance().retrieveChat(
net.micode.notes.tool.ai.CozeClient.getAuthToken(), chatId, convId).execute();
if (poll.isSuccessful() && poll.body() != null && poll.body().data != null) {
status = poll.body().data.status;
android.util.Log.d("MiChat", "当前状态: " + status);
if ("failed".equals(status)) break;
}
retries++;
}
if ("completed".equals(status)) {
android.util.Log.d("MiChat", "轮询完成,准备抓取回复内容...");
retrofit2.Response<com.google.gson.JsonObject> msgList =
net.micode.notes.tool.ai.CozeClient.getInstance().getMessageList(
net.micode.notes.tool.ai.CozeClient.getAuthToken(), chatId, convId).execute();
if (msgList.isSuccessful() && msgList.body() != null) {
com.google.gson.JsonArray dataArray = msgList.body().getAsJsonArray("data");
boolean foundAnswer = false;
for (com.google.gson.JsonElement el : dataArray) {
com.google.gson.JsonObject m = el.getAsJsonObject();
// 【核心检查点】打印出 AI 返回的所有消息类型,看看为什么没匹配到
android.util.Log.d("MiChat", "收到消息: role=" + m.get("role").getAsString() +
", type=" + m.get("type").getAsString());
// 寻找 AI 发送的真正的回答内容
if ("assistant".equals(m.get("role").getAsString()) &&
"answer".equals(m.get("type").getAsString())) {
String answer = m.get("content").getAsString();
foundAnswer = true;
// 存入数据库,触发 ContentObserver 刷新 UI
android.content.ContentValues aiValues = new android.content.ContentValues();
aiValues.put(net.micode.notes.data.Notes.ChatColumns.SENDER_TYPE, 1);
aiValues.put(net.micode.notes.data.Notes.ChatColumns.MSG_TYPE, 0);
aiValues.put(net.micode.notes.data.Notes.ChatColumns.CONTENT, answer);
aiValues.put(net.micode.notes.data.Notes.ChatColumns.CREATED_AT, System.currentTimeMillis());
getContext().getContentResolver().insert(CHAT_URI, aiValues);
android.util.Log.d("MiChat", "AI 回复已入库: " + answer);
break;
}
}
if (!foundAnswer) {
android.util.Log.w("MiChat", "未在消息列表中找到 type 为 answer 的回复!");
}
}
} else {
android.util.Log.e("MiChat", "轮询超时或失败,最终状态: " + status);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
});
// 1. 初次加载历史记录
loadHistory();
// 2. 注册观察者:监听数据库变化
mChatObserver = new android.database.ContentObserver(new android.os.Handler()) {
@Override
public void onChange(boolean selfChange) {
super.onChange(selfChange);
android.util.Log.d("ChatFragment", "Database changed, reloading...");
loadHistory(); // 数据库一变就重新加载
}
};
getContext().getContentResolver().registerContentObserver(CHAT_URI, true, mChatObserver);
return view;
}
@Override
public void onDestroyView() {
super.onDestroyView();
if (mChatObserver != null) {
getContext().getContentResolver().unregisterContentObserver(mChatObserver);
}
}
private void loadHistory() {
new Thread(() -> {
android.database.Cursor cursor = getContext().getContentResolver().query(
CHAT_URI,
null, null, null, "created_at ASC" // 按时间顺序排列
);
List<ChatMessage> history = new ArrayList<>();
if (cursor != null) {
while (cursor.moveToNext()) {
// 根据 Step 1 定义的列顺序取值
int senderType = cursor.getInt(cursor.getColumnIndex(net.micode.notes.data.Notes.ChatColumns.SENDER_TYPE));
int msgType = cursor.getInt(cursor.getColumnIndex(net.micode.notes.data.Notes.ChatColumns.MSG_TYPE));
String content = cursor.getString(cursor.getColumnIndex(net.micode.notes.data.Notes.ChatColumns.CONTENT));
long time = cursor.getLong(cursor.getColumnIndex(net.micode.notes.data.Notes.ChatColumns.CREATED_AT));
ChatMessage msg = new ChatMessage(senderType, msgType, content);
msg.createdAt = time;
history.add(msg);
}
cursor.close();
}
// 回到主线程更新 UI
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
mMessages.clear();
mMessages.addAll(history);
mAdapter.notifyDataSetChanged();
if (mMessages.size() > 0) {
mRecyclerView.scrollToPosition(mMessages.size() - 1);
}
});
}
}).start();
}
}

@ -78,6 +78,7 @@ import net.micode.notes.tool.ResourceParser;
import net.micode.notes.tool.ResourceParser.TextAppearanceResources;
import net.micode.notes.ui.DateTimePickerDialog.OnDateTimeSetListener;
import net.micode.notes.ui.NoteEditText.OnTextViewChangeListener;
import net.micode.notes.tool.ai.AiReminderScheduler;
import net.micode.notes.widget.NoteWidgetProvider_2x;
import net.micode.notes.widget.NoteWidgetProvider_4x;
@ -1612,6 +1613,15 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
saveImages();
setResult(RESULT_OK);
// [新增] 刷新 AI 助理的闹钟计划
try {
// 每次保存成功,都让调度器重新扫描一遍所有日程,更新系统闹钟
AiReminderScheduler.updateAllReminders(this);
android.util.Log.d("AiAssistant", "Reminders scheduled successfully after save.");
} catch (Exception e) {
android.util.Log.e("AiAssistant", "Failed to schedule reminders", e);
}
}
return saved;
}

@ -1442,19 +1442,21 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe
}
});
break;
// [新增] 处理 Push
case R.id.menu_push:
Toast.makeText(this, "正在上传数据到云端...", Toast.LENGTH_SHORT).show();
// 手术点:更新提示语
Toast.makeText(this, "正在备份数据并同步 AI 大脑...", Toast.LENGTH_SHORT).show();
// 构造参数MODE_PUSH
// [修复] 去掉前面的 androidx.work.Data直接赋值
inputData = new androidx.work.Data.Builder()
.putInt(net.micode.notes.sync.SyncWorker.KEY_SYNC_MODE, net.micode.notes.sync.SyncWorker.MODE_PUSH)
.build();
// [修复] 去掉前面的 androidx.work.OneTimeWorkRequest直接赋值
syncRequest = new androidx.work.OneTimeWorkRequest.Builder(net.micode.notes.sync.SyncWorker.class)
.setInputData(inputData)
.build();
// 提交任务
androidx.work.WorkManager.getInstance(this).enqueue(syncRequest);
break;
case R.id.menu_setting: {

Loading…
Cancel
Save