人们记录灵感的方式有很多种,笔记是一种很好的方式,但仅仅通过文字的方式似乎有些局限。我更喜欢画板的方式,通过图形+文字的方式,把一个想法拆成几块,连上关系,再补两行注释,思路会自己“跑起来”。
我把“笔记软件”当成日常的工作台:左边写写画画,右边开一个 AI 侧栏。侧栏不是用来“炫技”的,它就是四个朴素的入口:图像生成、拍照解题、3D生成、对话。我的目标也不复杂——不打断思路,让它们在该出现的时刻出现,生成尽量结构化的结果,能落地、能复用、能回看。下面我就按这四个入口,讲我怎么把它们揉进日常,怎么在 Flutter 里实现可用的组件。
我不喜欢那种“先去另一个页面生成,复制回来再粘贴”的流程。侧栏的意义是把“生成”放到手边,让我在写、画、翻素材时随手发起动作,结果就在当下的上下文里落地。图像生成在当前画布中央插入一张图;拍照解题把识别/解析的步骤和答案嵌回到笔记段落;3D生成先放一张预览卡片,不阻塞画布;对话就贴在当前页面右侧,信息保留在元数据里,不需要另起一个“聊天页”。
我的工作习惯是先有一个粗糙的页面草图,再把图像当“素材”往上叠。图像生成做的事情只有三件:
工程实现不难。Flutter 侧的关键在于:一个 service 负责调用,状态管理器拿到结果后插入画布对象,Widget 只关心 UI。我用 Riverpod 的 StateNotifier 做状态,Dio 走网络,示意代码如下(简化了异常与重试):
// lib/features/ai/services/image_service.dart
import 'package:dio/dio.dart';
class ImageService {
final Dio _dio;
ImageService(this._dio);
Future<ImageGenResult> generate({
required String prompt,
String size = "1280x720",
Map<String, dynamic>? palette,
}) async {
final resp = await _dio.post(
"/hunyuan/image/generate",
data: {
"prompt": prompt,
"size": size,
"palette": palette,
},
);
final data = resp.data as Map<String, dynamic>;
return ImageGenResult(
url: data["url"] as String,
meta: data["meta"] ?? {},
);
}
}
class ImageGenResult {
final String url;
final Map<String, dynamic> meta;
ImageGenResult({required this.url, required this.meta});
}
// lib/features/canvas/state/canvas_state.dart
import 'package:flutter/material.dart';
import 'package:riverpod/riverpod.dart';
class DrawingPoint {
final double x, y;
const DrawingPoint(this.x, this.y);
}
class ImageObject {
final String id;
final String path;
final DrawingPoint position;
final double width;
final double height;
final double scale;
final double rotation;
final Map<String, dynamic> meta;
ImageObject({
required this.id,
required this.path,
required this.position,
required this.width,
required this.height,
this.scale = 0.8,
this.rotation = 0,
this.meta = const {},
});
}
class CanvasState extends StateNotifier<List<Object>> {
CanvasState() : super(const []);
void addImage(ImageObject img) {
state = [...state, img];
}
DrawingPoint center(Size size) => DrawingPoint(size.width / 2, size.height / 2);
}
// lib/features/ai/presentation/widgets/image_generate_panel.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../services/image_service.dart';
import '../../../canvas/state/canvas_state.dart';
import 'package:uuid/uuid.dart';
final dioProvider = Provider((ref) => Dio(BaseOptions(baseUrl: "https://api.example.com")));
final imageServiceProvider = Provider((ref) => ImageService(ref.watch(dioProvider)));
final canvasStateProvider = StateNotifierProvider<CanvasState, List<Object>>((ref) => CanvasState());
class ImageGeneratePanel extends ConsumerWidget {
const ImageGeneratePanel({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final promptCtrl = TextEditingController();
return Column(
children: [
TextField(
controller: promptCtrl,
decoration: const InputDecoration(labelText: "提示词"),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () async {
final service = ref.read(imageServiceProvider);
final result = await service.generate(prompt: promptCtrl.text, size: "1280x720");
final size = MediaQuery.of(context).size;
final center = ref.read(canvasStateProvider.notifier).center(size);
final obj = ImageObject(
id: const Uuid().v4(),
path: result.url,
position: DrawingPoint(center.x, center.y),
width: 640,
height: 360,
meta: result.meta,
);
ref.read(canvasStateProvider.notifier).addImage(obj);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("已插入生成图像")),
);
},
child: const Text("生成插图并插入画布"),
),
],
);
}
}
做两点约束,我能少踩坑:
把这个流程画成一个时序图,大致如下:
拍照解题的本质是三个步骤:拍照(或从白板选图)→识别(文字/公式)→解析(题目结构/意图)→输出(步骤与答案)。
不走“端到端一下出答案”的路径,因为那种结果不透明。我的偏好是把识别和解析拆开,让每一步都能被替换和回放。
Flutter 侧需要两个基础:
下面是一段最小调用示例,演示拍照到识别的流程。识别服务返回一个结构体,我把它写回到笔记段落里,并且保留识别的原图和 trace id。
// lib/features/ai/services/solve_service.dart
class SolveService {
final Dio _dio;
SolveService(this._dio);
Future<SolveResult> analyzeImage({required String filePath}) async {
final form = FormData.fromMap({
"file": await MultipartFile.fromFile(filePath),
});
final resp = await _dio.post("/hunyuan/solve/analyze", data: form);
final data = resp.data as Map<String, dynamic>;
return SolveResult.fromJson(data);
}
}
class SolveResult {
final String traceId;
final String question;
final List<String> options;
final String? answer;
final List<String> steps;
SolveResult({
required this.traceId,
required this.question,
required this.options,
required this.answer,
required this.steps,
});
factory SolveResult.fromJson(Map<String, dynamic> json) => SolveResult(
traceId: json["trace_id"],
question: json["question"],
options: List<String>.from(json["options"] ?? []),
answer: json["answer"],
steps: List<String>.from(json["steps"] ?? []),
);
}
// lib/features/ai/presentation/widgets/solve_panel.dart
class SolvePanel extends ConsumerWidget {
const SolvePanel({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
ElevatedButton(
onPressed: () async {
final picker = ImagePicker();
final image = await picker.pickImage(source: ImageSource.camera);
if (image == null) return;
final service = SolveService(ref.read(dioProvider));
final result = await service.analyzeImage(filePath: image.path);
final md = """
> 识别来源:拍照
> Trace:${result.traceId}
${result.question}
${result.options.isNotEmpty ? result.options.map((o) => "- $o").join("\n") : ""}
${result.answer != null ? "**答案:** ${result.answer}" : ""}
**解析步骤:**
${result.steps.map((s) => "- $s").join("\n")}
""";
// 假定 notesService 负责把 md 插入当前笔记
await notesService.insertMarkdown(md, meta: {
"trace_id": result.traceId,
"source": "camera",
"image_path": image.path,
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("已插入拍照解题结果")),
);
},
child: const Text("拍照解题"),
),
],
);
}
}
“拍照解题”的流程图大致,核心思想是识别与解析分开,结果回写到笔记:
不过大致内容已经写好了,苦于找不到混元图生文服务开通位置。
3D生成的东西重,不适合当场就加载或预览。用一种“占位卡片”的策略:调用返回的是一个 model_url 和一张预览图(或缩略图),侧栏只插入一个占位卡片到笔记或画布边上,包含标题、预览图、尺寸、生成时间。真正的预览或渲染,在用户主动点击的时候再做,这样可以使流程不被重资源阻塞。
// lib/features/ai/services/three_service.dart
class ThreeService {
final Dio _dio;
ThreeService(this._dio);
Future<ThreeGenResult> generate({
required String prompt,
String format = "glb",
}) async {
final resp = await _dio.post("/hunyuan/three/generate", data: {
"prompt": prompt,
"format": format,
});
final data = resp.data as Map<String, dynamic>;
return ThreeGenResult(
modelUrl: data["model_url"] as String,
previewImg: data["preview_img"] as String?,
meta: data["meta"] ?? {},
);
}
}
class ThreeGenResult {
final String modelUrl;
final String? previewImg;
final Map<String, dynamic> meta;
ThreeGenResult({required this.modelUrl, this.previewImg, required this.meta});
}
// lib/features/ai/presentation/widgets/three_panel.dart
class ThreePanel extends ConsumerWidget {
const ThreePanel({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final promptCtrl = TextEditingController();
return Column(
children: [
TextField(
controller: promptCtrl,
decoration: const InputDecoration(labelText: "3D 描述"),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () async {
final result = await ThreeService(ref.read(dioProvider))
.generate(prompt: promptCtrl.text);
final md = """
**3D 占位卡片**
- 预览:${result.previewImg ?? "(无预览)"}
- 链接:${result.modelUrl}
- 时间:${DateTime.now().toIso8601String()}
""";
await notesService.insertMarkdown(md, meta: {
"model_url": result.modelUrl,
"preview_img": result.previewImg,
...result.meta,
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("已插入 3D 占位卡片")),
);
},
child: const Text("生成 3D 并插入占位"),
),
],
);
}
}
大致流程如下:
对话这件事,最容易做成“另一个聊天页”,然后和笔记脱节。我更喜欢把对话嵌在当前页面右侧,和笔记共享同一个上下文,消息保留到会话的 metadata 里,有需要就能把一段对话摘录进笔记正文。
实现层面,我会做两条线:
示意代码:
// lib/core/services/tencent/models/chat_models.dart 假定已有定义
// 这里仅示意请求构造与调用封装
class ChatService {
final Dio _dio;
ChatService(this._dio);
Future<ChatCompletionResponse> complete(ChatCompletionRequest req) async {
final resp = await _dio.post("/hunyuan/chat/complete", data: req.toJson());
return ChatCompletionResponse.fromJson(resp.data);
}
}
class ChatPanel extends ConsumerWidget {
const ChatPanel({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final inputCtrl = TextEditingController();
final messages = <ChatMessage>[];
return Column(
children: [
Expanded(
child: ListView(
children: messages
.map((m) => ListTile(
title: Text(m.role),
subtitle: Text(m.content ?? ""),
))
.toList(),
),
),
TextField(controller: inputCtrl),
Row(
children: [
ElevatedButton(
onPressed: () async {
final req = ChatCompletionRequest(
model: "hunyuan-pro",
messages: [
ChatMessage.system("你是一个耐心的技术助手,回答简洁"),
...messages,
ChatMessage.user(inputCtrl.text),
],
stream: false,
temperature: 0.5,
);
final resp = await ChatService(ref.read(dioProvider)).complete(req);
final reply = resp.choices.first.message;
messages.add(ChatMessage.user(inputCtrl.text));
messages.add(reply);
// 写入会话记录
await notesService.appendConversation(messages, meta: {
"trace_id": resp.id,
"time": DateTime.now().toIso8601String(),
});
},
child: const Text("发送"),
),
],
),
],
);
}
}
为了让对话和笔记绑得更紧,需在每次调用时附带当前页面的上下文摘要,比如选中的段落标题、最近插入的图像描述,这样对话不会离题。
对话的时序图很简单:
四个入口其实共享几个原则:
失败兜底的代码架构我会这样做:给每个 service 加一个简单的重试与降级;UI 侧用 SnackBar 或 Banner 明确提示;数据侧把不完整的结果也落到笔记里,后续由人补齐。示意:
Future<T> safeCall<T>(Future<T> Function() fn, {int retry = 1}) async {
try {
return await fn();
} catch (e) {
if (retry > 0) {
await Future.delayed(const Duration(milliseconds: 200));
return await safeCall(fn, retry: retry - 1);
}
rethrow;
}
}
Future<ImageGenResult?> tryGenerateImage(ImageService svc, String prompt) async {
try {
return await safeCall(() => svc.generate(prompt: prompt));
} catch (e) {
await notesService.insertMarkdown("> 图像生成失败:${e.toString()}");
return null;
}
}
生成出来的东西没法复用,就等于白做。我的做法是把资源当“第一等公民”,所有生成的图像、3D 模型、会话记录都带着 meta:来源、提示摘要、时间、主题标签。这样在笔记里搜索或筛选的时候,就能快速定位一类东西。
比如图像的 meta,我会固定以下字段:
3D 模型的 meta 则包含格式、预览图、体积大小等。对话的 meta 包含参与者、上下文摘要。
我经常会在 白板 和 markdown 中切换
我不试图让侧栏“发号施令”。它更多是一组贴心的动作:在我需要素材的时候给我一张图,在我拿着纸面题目犹豫的时候给我一个拆解,在我只是想确认某段文字该不该删的时候给我一个简短的建议,在我考虑是否需要 3D 示意的时候先帮我把卡片放好。小而稳,才能更顺手。
写到这里,仔细想想,我其实只是把日常工作里面那些常见动作,放到一个不打扰的角落里。你可以把这篇当作一个“怎么做”的笔记:有 4 个入口,有能跑的 Flutter 代码片段,集成最基础的 AI 功能。它不神奇,也不追求“惊艳”。但以这样方式落地,至少能让人一眼看懂你在做什么,怎么复用,哪里稳,哪里有退路。
补充:常用 Flutter 片段清单
final dioProvider = Provider((ref) => Dio(BaseOptions(
baseUrl: "https://api.example.com",
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
)));
class AiSidebar extends StatelessWidget {
const AiSidebar({super.key});
@override
Widget build(BuildContext context) {
return Drawer(
child: ListView(
padding: const EdgeInsets.all(12),
children: const [
Text("图像生成", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
ImageGeneratePanel(),
Divider(),
Text("拍照解题", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SolvePanel(),
Divider(),
Text("3D 生成", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
ThreePanel(),
Divider(),
Text("对话", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
ChatPanel(),
],
),
);
}
}
class NotesService {
Future<void> insertMarkdown(String md, {Map<String, dynamic>? meta}) async {
// 写入当前页面的笔记文档,meta 落地到 Front Matter 或文档内部隐含块
}
Future<void> appendConversation(List<ChatMessage> messages, {Map<String, dynamic>? meta}) async {
// 记录会话到文档侧边或结尾
}
}
void insertImageToCanvas(BuildContext ctx, WidgetRef ref, ImageGenResult r) {
final size = MediaQuery.of(ctx).size;
final center = ref.read(canvasStateProvider.notifier).center(size);
final obj = ImageObject(
id: const Uuid().v4(),
path: r.url,
position: DrawingPoint(center.x, center.y),
width: 640,
height: 360,
meta: {
"source": "gen",
"prompt": r.meta["prompt"],
"size": "1280x720",
"trace_id": r.meta["trace_id"],
"created_at": DateTime.now().toIso8601String(),
},
);
ref.read(canvasStateProvider.notifier).addImage(obj);
}
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。