diff --git a/example/app/src/main/AndroidManifest.xml b/example/app/src/main/AndroidManifest.xml index ec66937..d9d1e0c 100644 --- a/example/app/src/main/AndroidManifest.xml +++ b/example/app/src/main/AndroidManifest.xml @@ -30,6 +30,10 @@ android:name=".MealPlanningActivity" android:exported="false" android:screenOrientation="portrait" /> + finish()); + if (binding.btnMealHistory != null) { + binding.btnMealHistory.setOnClickListener(v -> openHistory()); + } binding.btnGenerate.setOnClickListener(v -> doGenerate()); binding.btnCopy.setOnClickListener(v -> copyResult()); observeViewModel(); } + private static final int REQ_MEAL_HISTORY = 2001; + + private void openHistory() { + startActivityForResult(new Intent(this, MealPlanningHistoryActivity.class), REQ_MEAL_HISTORY); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQ_MEAL_HISTORY && resultCode == RESULT_OK && data != null) { + String content = data.getStringExtra(MealPlanningHistoryActivity.EXTRA_MEAL_PLAN_CONTENT); + if (content != null && !content.isEmpty()) { + binding.tvResult.setText(content); + binding.tvResult.setTextColor(getResources().getColor(R.color.colorTextDark, getTheme())); + Snackbar.make(binding.getRoot(), "已填入规划结果", Snackbar.LENGTH_SHORT).show(); + } + } + } + private void setupSpinners() { ArrayAdapter regionAdapter = ArrayAdapter.createFromResource(this, R.array.meal_region_types, android.R.layout.simple_spinner_item); regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); @@ -69,6 +93,37 @@ public class MealPlanningActivity extends AppCompatActivity { binding.spinnerBudget.setSelection(1); // 50-100元 } + /** 与主界面 GenerationRepository 一致:0 短 1 中 2 长 */ + private static int levelToMaxTokens(int level) { + switch (level) { + case 0: return 256; + case 1: return 512; + case 2: return 1024; + default: return 512; + } + } + + /** 与主界面一致:0 保守 1 平衡 2 创意 */ + private static float levelToTemperature(int level) { + switch (level) { + case 0: return 0.3f; + case 1: return 0.7f; + case 2: return 1.2f; + default: return 0.7f; + } + } + + private void setupSliders() { + Slider lengthSlider = binding.sliderMealLength; + if (lengthSlider != null) { + lengthSlider.setValue(1f); + } + Slider tempSlider = binding.sliderMealTemperature; + if (tempSlider != null) { + tempSlider.setValue(1f); + } + } + private void doGenerate() { String hometown = getText(binding.etHometown); if (hometown.isEmpty()) { @@ -83,6 +138,10 @@ public class MealPlanningActivity extends AppCompatActivity { params.setPreferences(getText(binding.etPreferences)); params.setDietaryRestrictions(getText(binding.etDietary)); params.setBudget(BUDGET_VALUES[binding.spinnerBudget.getSelectedItemPosition()]); + int lengthLevel = binding.sliderMealLength != null ? (int) binding.sliderMealLength.getValue() : 1; + int tempLevel = binding.sliderMealTemperature != null ? (int) binding.sliderMealTemperature.getValue() : 1; + params.setMaxTokens(levelToMaxTokens(lengthLevel)); + params.setTemperature(levelToTemperature(tempLevel)); viewModel.generate(params); } diff --git a/example/app/src/main/java/com/ruilaizi/example/MealPlanningHistoryActivity.java b/example/app/src/main/java/com/ruilaizi/example/MealPlanningHistoryActivity.java new file mode 100644 index 0000000..9e5286d --- /dev/null +++ b/example/app/src/main/java/com/ruilaizi/example/MealPlanningHistoryActivity.java @@ -0,0 +1,54 @@ +package com.ruilaizi.example; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.ruilaizi.example.adapter.MealPlanHistoryAdapter; +import com.ruilaizi.example.data.meal.MealPlanningRepository; +import com.ruilaizi.example.databinding.ActivityMealPlanningHistoryBinding; + +/** + * 饭菜规划历史列表。 + */ +public class MealPlanningHistoryActivity extends AppCompatActivity { + + /** 从历史项「使用」时,通过 Intent 带回的规划正文 */ + public static final String EXTRA_MEAL_PLAN_CONTENT = "meal_plan_content"; + + private ActivityMealPlanningHistoryBinding binding; + private MealPlanHistoryAdapter adapter; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityMealPlanningHistoryBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + adapter = new MealPlanHistoryAdapter(); + adapter.setOnUseMealPlanListener(content -> { + Intent data = new Intent(); + data.putExtra(EXTRA_MEAL_PLAN_CONTENT, content); + setResult(RESULT_OK, data); + finish(); + }); + + binding.recycler.setLayoutManager(new LinearLayoutManager(this)); + binding.recycler.setAdapter(adapter); + + binding.btnBack.setOnClickListener(v -> finish()); + + MealPlanningRepository repo = new MealPlanningRepository(getApplication()); + repo.getRecentMealPlanRecords().observe(this, list -> { + adapter.setData(list); + binding.emptyText.setVisibility(list == null || list.isEmpty() ? View.VISIBLE : View.GONE); + }); + } +} diff --git a/example/app/src/main/java/com/ruilaizi/example/adapter/MealPlanHistoryAdapter.java b/example/app/src/main/java/com/ruilaizi/example/adapter/MealPlanHistoryAdapter.java new file mode 100644 index 0000000..2eae76f --- /dev/null +++ b/example/app/src/main/java/com/ruilaizi/example/adapter/MealPlanHistoryAdapter.java @@ -0,0 +1,113 @@ +package com.ruilaizi.example.adapter; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.button.MaterialButton; +import com.ruilaizi.example.R; +import com.ruilaizi.example.data.db.MealPlanRecord; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class MealPlanHistoryAdapter extends RecyclerView.Adapter { + + private final List list = new ArrayList<>(); + private OnUseMealPlanListener onUseMealPlanListener; + + public interface OnUseMealPlanListener { + void onUseMealPlan(String mealPlanContent); + } + + public void setOnUseMealPlanListener(OnUseMealPlanListener listener) { + this.onUseMealPlanListener = listener; + } + + public void setData(List data) { + list.clear(); + if (data != null) { + list.addAll(data); + } + notifyDataSetChanged(); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_meal_plan_history, parent, false); + return new ViewHolder(v); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + MealPlanRecord r = list.get(position); + String summary = "家乡:" + safe(r.hometown) + " | " + safe(r.mealType) + " | " + safe(r.dinerCount) + "人 | " + safe(r.budget) + "元"; + holder.tvSummary.setText(summary); + holder.tvContentPreview.setText(truncate(r.mealPlanContent, 80)); + holder.tvTime.setText(formatTime(r.createdAt)); + + holder.btnUse.setOnClickListener(v -> { + if (onUseMealPlanListener != null && r.mealPlanContent != null) { + onUseMealPlanListener.onUseMealPlan(r.mealPlanContent); + } + }); + + holder.btnCopy.setOnClickListener(v -> { + if (r.mealPlanContent != null && !r.mealPlanContent.isEmpty()) { + ClipboardManager cm = (ClipboardManager) holder.itemView.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + if (cm != null) { + cm.setPrimaryClip(ClipData.newPlainText("饭菜规划", r.mealPlanContent)); + } + } + }); + } + + @Override + public int getItemCount() { + return list.size(); + } + + private static String safe(String s) { + return s != null ? s : ""; + } + + private static String truncate(String s, int maxLen) { + if (s == null) return ""; + s = s.trim(); + if (s.length() <= maxLen) return s; + return s.substring(0, maxLen) + "…"; + } + + private static String formatTime(long timestamp) { + try { + return new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(new Date(timestamp)); + } catch (Exception e) { + return ""; + } + } + + static class ViewHolder extends RecyclerView.ViewHolder { + TextView tvSummary, tvContentPreview, tvTime; + MaterialButton btnUse, btnCopy; + + ViewHolder(View itemView) { + super(itemView); + tvSummary = itemView.findViewById(R.id.tv_summary); + tvContentPreview = itemView.findViewById(R.id.tv_content_preview); + tvTime = itemView.findViewById(R.id.tv_time); + btnUse = itemView.findViewById(R.id.btn_use); + btnCopy = itemView.findViewById(R.id.btn_copy); + } + } +} diff --git a/example/app/src/main/java/com/ruilaizi/example/data/api/OpenAICompletionService.java b/example/app/src/main/java/com/ruilaizi/example/data/api/OpenAICompletionService.java index ca7f4ba..bf789e7 100644 --- a/example/app/src/main/java/com/ruilaizi/example/data/api/OpenAICompletionService.java +++ b/example/app/src/main/java/com/ruilaizi/example/data/api/OpenAICompletionService.java @@ -42,7 +42,7 @@ public class OpenAICompletionService { conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setConnectTimeout(30000); - conn.setReadTimeout(60000); + conn.setReadTimeout(120000); // 120s,饭菜规划等长文生成易超时 conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Authorization", "Bearer " + apiKey); diff --git a/example/app/src/main/java/com/ruilaizi/example/data/db/AppDatabase.java b/example/app/src/main/java/com/ruilaizi/example/data/db/AppDatabase.java index 34e604b..e6064a6 100644 --- a/example/app/src/main/java/com/ruilaizi/example/data/db/AppDatabase.java +++ b/example/app/src/main/java/com/ruilaizi/example/data/db/AppDatabase.java @@ -6,7 +6,7 @@ import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; -@Database(entities = {GenerationRecord.class, OptimizeRecord.class, DirectAnswerRecord.class}, version = 3, exportSchema = false) +@Database(entities = {GenerationRecord.class, OptimizeRecord.class, DirectAnswerRecord.class, MealPlanRecord.class}, version = 4, exportSchema = false) public abstract class AppDatabase extends RoomDatabase { private static volatile AppDatabase INSTANCE; @@ -29,4 +29,5 @@ public abstract class AppDatabase extends RoomDatabase { public abstract GenerationRecordDao generationRecordDao(); public abstract OptimizeRecordDao optimizeRecordDao(); public abstract DirectAnswerRecordDao directAnswerRecordDao(); + public abstract MealPlanRecordDao mealPlanRecordDao(); } diff --git a/example/app/src/main/java/com/ruilaizi/example/data/db/MealPlanRecord.java b/example/app/src/main/java/com/ruilaizi/example/data/db/MealPlanRecord.java new file mode 100644 index 0000000..7c517c0 --- /dev/null +++ b/example/app/src/main/java/com/ruilaizi/example/data/db/MealPlanRecord.java @@ -0,0 +1,43 @@ +package com.ruilaizi.example.data.db; + +import androidx.room.Entity; +import androidx.room.Ignore; +import androidx.room.PrimaryKey; + +import com.ruilaizi.example.data.meal.MealPlanGenerator; + +/** + * 饭菜规划历史记录。 + */ +@Entity(tableName = "meal_plan_records") +public class MealPlanRecord { + + @PrimaryKey(autoGenerate = true) + public long id; + + public String regionType; + public String dinerCount; + public String mealType; + public String hometown; + public String preferences; + public String dietaryRestrictions; + public String budget; + /** 生成的规划正文(Markdown) */ + public String mealPlanContent; + public long createdAt; + + public MealPlanRecord() {} + + @Ignore + public MealPlanRecord(MealPlanGenerator.MealPlanParams params, String mealPlanContent) { + this.regionType = params.getRegionType(); + this.dinerCount = params.getDinerCount(); + this.mealType = params.getMealType(); + this.hometown = params.getHometown(); + this.preferences = params.getPreferences(); + this.dietaryRestrictions = params.getDietaryRestrictions(); + this.budget = params.getBudget(); + this.mealPlanContent = mealPlanContent; + this.createdAt = System.currentTimeMillis(); + } +} diff --git a/example/app/src/main/java/com/ruilaizi/example/data/db/MealPlanRecordDao.java b/example/app/src/main/java/com/ruilaizi/example/data/db/MealPlanRecordDao.java new file mode 100644 index 0000000..b17c5ae --- /dev/null +++ b/example/app/src/main/java/com/ruilaizi/example/data/db/MealPlanRecordDao.java @@ -0,0 +1,21 @@ +package com.ruilaizi.example.data.db; + +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; + +import java.util.List; + +@Dao +public interface MealPlanRecordDao { + + @Insert + long insert(MealPlanRecord record); + + @Query("SELECT * FROM meal_plan_records ORDER BY createdAt DESC LIMIT 50") + LiveData> getRecentRecords(); + + @Query("DELETE FROM meal_plan_records WHERE id NOT IN (SELECT id FROM meal_plan_records ORDER BY createdAt DESC LIMIT 50)") + void keepOnlyRecent(); +} diff --git a/example/app/src/main/java/com/ruilaizi/example/data/meal/MealPlanGenerator.java b/example/app/src/main/java/com/ruilaizi/example/data/meal/MealPlanGenerator.java index ae833de..044ed1d 100644 --- a/example/app/src/main/java/com/ruilaizi/example/data/meal/MealPlanGenerator.java +++ b/example/app/src/main/java/com/ruilaizi/example/data/meal/MealPlanGenerator.java @@ -8,8 +8,9 @@ import com.ruilaizi.example.data.api.OpenAICompletionService; */ public class MealPlanGenerator { - private static final float TEMPERATURE = 0.7f; - private static final int MAX_TOKENS = 2000; + /** 默认与 Flask 一致,可由 MealPlanParams 覆盖 */ + private static final float DEFAULT_TEMPERATURE = 0.7f; + private static final int DEFAULT_MAX_TOKENS = 1000; private final OpenAICompletionService completionService; private final ApiKeyProvider apiKeyProvider; @@ -28,6 +29,7 @@ public class MealPlanGenerator { throw new Exception("请先在设置中配置 API Key"); } String regionType = params.getRegionType() != null ? params.getRegionType() : "全国"; + // 与 Flask meal_planning.py 完全一致的系统提示 String systemPrompt = "你是一位专业的" + regionType + "智能饭菜清单规划师和营养搭配专家。\n\n" + "请按以下**固定结构**输出 Markdown,便于用户查阅:\n\n" + "**一、菜品推荐与搭配思路**\n" @@ -50,20 +52,24 @@ public class MealPlanGenerator { if (hometown.isEmpty()) { throw new Exception("请输入用餐者家乡"); } + // 默认值与 Flask generate_meal_plan_api 一致 String dinerCount = params.getDinerCount() != null ? params.getDinerCount() : "2"; String mealType = params.getMealType() != null ? params.getMealType() : "午餐"; - String preferences = params.getPreferences() != null ? params.getPreferences().trim() : "无"; - String dietaryRestrictions = params.getDietaryRestrictions() != null ? params.getDietaryRestrictions().trim() : "无"; + String preferences = params.getPreferences() != null ? params.getPreferences().trim() : ""; + String dietaryRestrictions = params.getDietaryRestrictions() != null ? params.getDietaryRestrictions().trim() : ""; String budget = params.getBudget() != null ? params.getBudget() : "100"; + // 与 Flask user_prompt 逐字一致:为{diner_count}人制定{meal_type}饭菜清单。家乡:{hometown}。喜好:{preferences}。禁忌:{dietary_restrictions}。预算:{budget}元。请按「一、二、三」结构输出... String userPrompt = "为" + dinerCount + "人制定" + mealType + "饭菜清单。家乡:" + hometown + "。喜好:" + preferences + "。禁忌:" + dietaryRestrictions + "。预算:" + budget + "元。请按「一、二、三」结构输出,每道菜包含菜品特色、食材清单、制作步骤、营养信息、预算估算。"; - return completionService.chat(systemPrompt, userPrompt, TEMPERATURE, MAX_TOKENS, apiKey); + float temperature = params.getTemperature() != null ? params.getTemperature() : DEFAULT_TEMPERATURE; + int maxTokens = params.getMaxTokens() != null ? params.getMaxTokens() : DEFAULT_MAX_TOKENS; + return completionService.chat(systemPrompt, userPrompt, temperature, maxTokens, apiKey); } - /** 饭菜规划参数,与 Web 端一致 */ + /** 饭菜规划参数,与 Web 端一致;temperature / maxTokens 与优化提示词一样可配置。 */ public static class MealPlanParams { private String regionType; private String dinerCount; @@ -72,6 +78,10 @@ public class MealPlanGenerator { private String preferences; private String dietaryRestrictions; private String budget; + /** 创意程度,null 时用默认 0.7 */ + private Float temperature; + /** 最大 token 数,null 时用默认 1000 */ + private Integer maxTokens; public String getRegionType() { return regionType; } public void setRegionType(String regionType) { this.regionType = regionType; } @@ -87,5 +97,9 @@ public class MealPlanGenerator { public void setDietaryRestrictions(String dietaryRestrictions) { this.dietaryRestrictions = dietaryRestrictions; } public String getBudget() { return budget; } public void setBudget(String budget) { this.budget = budget; } + public Float getTemperature() { return temperature; } + public void setTemperature(Float temperature) { this.temperature = temperature; } + public Integer getMaxTokens() { return maxTokens; } + public void setMaxTokens(Integer maxTokens) { this.maxTokens = maxTokens; } } } diff --git a/example/app/src/main/java/com/ruilaizi/example/data/meal/MealPlanningRepository.java b/example/app/src/main/java/com/ruilaizi/example/data/meal/MealPlanningRepository.java index 2943a58..16a8956 100644 --- a/example/app/src/main/java/com/ruilaizi/example/data/meal/MealPlanningRepository.java +++ b/example/app/src/main/java/com/ruilaizi/example/data/meal/MealPlanningRepository.java @@ -2,21 +2,30 @@ package com.ruilaizi.example.data.meal; import android.content.Context; +import androidx.lifecycle.LiveData; + import com.ruilaizi.example.data.api.ApiKeyProvider; import com.ruilaizi.example.data.api.OpenAICompletionService; +import com.ruilaizi.example.data.db.AppDatabase; +import com.ruilaizi.example.data.db.MealPlanRecord; +import com.ruilaizi.example.data.db.MealPlanRecordDao; + +import java.util.List; /** - * 饭菜规划仓库:本地调用 DeepSeek 生成饭菜清单,与 example 项目提示词优化/生成同构。 + * 饭菜规划仓库:本地调用 DeepSeek 生成饭菜清单,与 example 项目提示词优化/生成同构;支持历史记录。 */ public class MealPlanningRepository { private final ApiKeyProvider apiKeyProvider; private final MealPlanGenerator generator; + private final MealPlanRecordDao mealPlanRecordDao; public MealPlanningRepository(Context context) { apiKeyProvider = new ApiKeyProvider(context); OpenAICompletionService completionService = new OpenAICompletionService(null); generator = new MealPlanGenerator(completionService, apiKeyProvider); + mealPlanRecordDao = AppDatabase.getInstance(context).mealPlanRecordDao(); } public String getApiKey() { @@ -29,4 +38,18 @@ public class MealPlanningRepository { public String generateMealPlan(MealPlanGenerator.MealPlanParams params) throws Exception { return generator.generate(params); } + + /** + * 保存一条规划历史。需在后台线程调用。 + */ + public void saveMealPlanRecord(MealPlanRecord record) { + new Thread(() -> { + mealPlanRecordDao.insert(record); + mealPlanRecordDao.keepOnlyRecent(); + }).start(); + } + + public LiveData> getRecentMealPlanRecords() { + return mealPlanRecordDao.getRecentRecords(); + } } diff --git a/example/app/src/main/java/com/ruilaizi/example/ui/MealPlanningViewModel.java b/example/app/src/main/java/com/ruilaizi/example/ui/MealPlanningViewModel.java index 10d2f4d..3fcb463 100644 --- a/example/app/src/main/java/com/ruilaizi/example/ui/MealPlanningViewModel.java +++ b/example/app/src/main/java/com/ruilaizi/example/ui/MealPlanningViewModel.java @@ -10,6 +10,7 @@ import androidx.lifecycle.MutableLiveData; import com.ruilaizi.example.data.meal.MealPlanGenerator; import com.ruilaizi.example.data.meal.MealPlanningRepository; +import java.net.SocketTimeoutException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -52,9 +53,17 @@ public class MealPlanningViewModel extends AndroidViewModel { try { final String plan = repository.generateMealPlan(params); resultLiveData.postValue(plan); + if (plan != null && !plan.isEmpty()) { + repository.saveMealPlanRecord(new com.ruilaizi.example.data.db.MealPlanRecord(params, plan)); + } snackbarLiveData.postValue("饭菜规划生成完成"); } catch (Exception e) { - errorLiveData.postValue(e != null ? e.getMessage() : "生成失败"); + String raw = e != null ? e.getMessage() : ""; + boolean isTimeout = e instanceof SocketTimeoutException + || (e.getCause() != null && e.getCause() instanceof SocketTimeoutException) + || (raw != null && (raw.toLowerCase().contains("timeout") || raw.contains("timed out"))); + String msg = isTimeout ? "请求超时,请检查网络或稍后重试" : (raw != null && !raw.isEmpty() ? raw : "生成失败"); + errorLiveData.postValue(msg); } finally { loadingLiveData.postValue(false); } diff --git a/example/app/src/main/res/layout/activity_meal_planning.xml b/example/app/src/main/res/layout/activity_meal_planning.xml index e3c49a7..03b1ee1 100644 --- a/example/app/src/main/res/layout/activity_meal_planning.xml +++ b/example/app/src/main/res/layout/activity_meal_planning.xml @@ -35,7 +35,12 @@ android:textSize="20sp" android:textStyle="bold" android:gravity="center" /> - + @@ -108,7 +113,43 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/app/src/main/res/layout/item_meal_plan_history.xml b/example/app/src/main/res/layout/item_meal_plan_history.xml new file mode 100644 index 0000000..fbbbd8a --- /dev/null +++ b/example/app/src/main/res/layout/item_meal_plan_history.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + diff --git a/example/app/src/main/res/values/strings.xml b/example/app/src/main/res/values/strings.xml index c3b404f..5c34973 100644 --- a/example/app/src/main/res/values/strings.xml +++ b/example/app/src/main/res/values/strings.xml @@ -36,4 +36,6 @@ 生成饭菜规划 填写左侧参数后点击「生成饭菜规划」 饭菜规划 + 规划历史 + 暂无规划记录