首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >让工具更好用,我把混元塞进了笔记工具里

让工具更好用,我把混元塞进了笔记工具里

原创
作者头像
繁依Fanyi
修改2025-10-13 07:48:53
修改2025-10-13 07:48:53
820
举报

人们记录灵感的方式有很多种,笔记是一种很好的方式,但仅仅通过文字的方式似乎有些局限。我更喜欢画板的方式,通过图形+文字的方式,把一个想法拆成几块,连上关系,再补两行注释,思路会自己“跑起来”。

我把“笔记软件”当成日常的工作台:左边写写画画,右边开一个 AI 侧栏。侧栏不是用来“炫技”的,它就是四个朴素的入口:图像生成、拍照解题、3D生成、对话。我的目标也不复杂——不打断思路,让它们在该出现的时刻出现,生成尽量结构化的结果,能落地、能复用、能回看。下面我就按这四个入口,讲我怎么把它们揉进日常,怎么在 Flutter 里实现可用的组件。

一、侧栏为何存在:让操作与结果“挨得近一点”

我不喜欢那种“先去另一个页面生成,复制回来再粘贴”的流程。侧栏的意义是把“生成”放到手边,让我在写、画、翻素材时随手发起动作,结果就在当下的上下文里落地。图像生成在当前画布中央插入一张图;拍照解题把识别/解析的步骤和答案嵌回到笔记段落;3D生成先放一张预览卡片,不阻塞画布;对话就贴在当前页面右侧,信息保留在元数据里,不需要另起一个“聊天页”。

在这里插入图片描述
在这里插入图片描述

二、图像生成:不是“海报一键出”,而是“素材长在该长的位置”

我的工作习惯是先有一个粗糙的页面草图,再把图像当“素材”往上叠。图像生成做的事情只有三件:

  • 免费我一个“可用的底图”,有风格参考、尺寸和色板;
  • 把图像插到当前画布中央,默认缩放到合适大小,让我直接拖动;
  • 对结果留痕:把来源、提示词、色板、尺寸写进资源元数据,后续复用有据可查。

工程实现不难。Flutter 侧的关键在于:一个 service 负责调用,状态管理器拿到结果后插入画布对象,Widget 只关心 UI。我用 Riverpod 的 StateNotifier 做状态,Dio 走网络,示意代码如下(简化了异常与重试):

在这里插入图片描述
在这里插入图片描述
代码语言:dart
复制
// 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});
}
代码语言:dart
复制
// 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);
}
代码语言:dart
复制
// 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("生成插图并插入画布"),
        ),
      ],
    );
  }
}

做两点约束,我能少踩坑:

  • 插图默认饱和度降低一点,不抢主内容(在 CanvasPainter 里统一调样式即可)。
  • 结果的 meta 必须写入:prompt、size、palette、生成时间,后续复用才会顺畅。

把这个流程画成一个时序图,大致如下:

在这里插入图片描述
在这里插入图片描述

三、拍照解题:少做魔法,多做拆解

拍照解题的本质是三个步骤:拍照(或从白板选图)→识别(文字/公式)→解析(题目结构/意图)→输出(步骤与答案)。

不走“端到端一下出答案”的路径,因为那种结果不透明。我的偏好是把识别和解析拆开,让每一步都能被替换和回放。

Flutter 侧需要两个基础:

  • 拍照/相册:camera 或 image_picker;
  • 上传与识别:网络调用,拿到结构化结果(比如题干、选项、答案、解析)。

下面是一段最小调用示例,演示拍照到识别的流程。识别服务返回一个结构体,我把它写回到笔记段落里,并且保留识别的原图和 trace id。

在这里插入图片描述
在这里插入图片描述
代码语言:dart
复制
// 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"] ?? []),
  );
}
代码语言:dart
复制
// 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生成:先占位,再决定

3D生成的东西重,不适合当场就加载或预览。用一种“占位卡片”的策略:调用返回的是一个 model_url 和一张预览图(或缩略图),侧栏只插入一个占位卡片到笔记或画布边上,包含标题、预览图、尺寸、生成时间。真正的预览或渲染,在用户主动点击的时候再做,这样可以使流程不被重资源阻塞。

在这里插入图片描述
在这里插入图片描述
代码语言:dart
复制
// 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});
}
代码语言:dart
复制
// 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 里,有需要就能把一段对话摘录进笔记正文。

实现层面,我会做两条线:

  • 简单的非流式对话:一次请求一次返回,易于写入记录;
  • 可选的流式对话:调试或实时写作时用,显示体验更顺滑,但要注意收尾,把完整消息合并存档。
在这里插入图片描述
在这里插入图片描述

示意代码:

代码语言:dart
复制
// 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("发送"),
            ),
          ],
        ),
      ],
    );
  }
}

为了让对话和笔记绑得更紧,需在每次调用时附带当前页面的上下文摘要,比如选中的段落标题、最近插入的图像描述,这样对话不会离题。

对话的时序图很简单:

在这里插入图片描述
在这里插入图片描述

六、状态管理与失败兜底:让侧栏“不拖人后腿”

四个入口其实共享几个原则:

  • 少参数,稳输出:temperature、seed、size 这种基础项够用;需要就从 UI 给一个“高级”折叠面板。
  • 留痕、可回放:每次调用记录 trace id、时间、上下文摘要;结果能从笔记或画布复用。
  • 明确失败路径:网络超时、格式不对、链接失效,都有降级方案。
  • 不锁死 UI:长耗时操作把按钮置为 loading,但不阻塞其他交互。

失败兜底的代码架构我会这样做:给每个 service 加一个简单的重试与降级;UI 侧用 SnackBar 或 Banner 明确提示;数据侧把不完整的结果也落到笔记里,后续由人补齐。示意:

代码语言:dart
复制
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,我会固定以下字段:

  • source: "gen" | "upload" | "camera"
  • prompt: 文本
  • palette: 颜色建议
  • size: 尺寸
  • trace_id: 唯一标识
  • created_at: 时间

3D 模型的 meta 则包含格式、预览图、体积大小等。对话的 meta 包含参与者、上下文摘要。

八、在 白板 和 markdown 中切换

我经常会在 白板 和 markdown 中切换

  • 先画一个页面草稿:标题、三块内容、两张插图位。
  • 打开侧栏,给“图像生成”一个提示词,生成两张淡色插图,插到画布里;
  • 拍照解题用于补充某个纸质白板上的公式讨论,识别出题干和步骤,嵌回到对应段落;
  • 关键段落里,开一个对话,问一个“删繁就简”的问题,把答案/笔记摘要写进笔记;
  • 如果有 3D 示意需求,从白板取图生成 3D 模型,等后续再决定是否预览,不过这部分大功率用不到,也是我强塞的功能吧 😂。
在这里插入图片描述
在这里插入图片描述

我不试图让侧栏“发号施令”。它更多是一组贴心的动作:在我需要素材的时候给我一张图,在我拿着纸面题目犹豫的时候给我一个拆解,在我只是想确认某段文字该不该删的时候给我一个简短的建议,在我考虑是否需要 3D 示意的时候先帮我把卡片放好。小而稳,才能更顺手。

结语:我喜欢这样的节奏

写到这里,仔细想想,我其实只是把日常工作里面那些常见动作,放到一个不打扰的角落里。你可以把这篇当作一个“怎么做”的笔记:有 4 个入口,有能跑的 Flutter 代码片段,集成最基础的 AI 功能。它不神奇,也不追求“惊艳”。但以这样方式落地,至少能让人一眼看懂你在做什么,怎么复用,哪里稳,哪里有退路。

补充:常用 Flutter 片段清单

  • Dio 初始化与 Provider:
代码语言:dart
复制
final dioProvider = Provider((ref) => Dio(BaseOptions(
  baseUrl: "https://api.example.com",
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 20),
)));
  • 侧栏骨架:
代码语言:dart
复制
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(),
        ],
      ),
    );
  }
}
  • Notes 服务接口(伪示例):
代码语言:dart
复制
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 {
    // 记录会话到文档侧边或结尾
  }
}
  • 画布插入图片的通用方法:
代码语言:dart
复制
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 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、侧栏为何存在:让操作与结果“挨得近一点”
  • 二、图像生成:不是“海报一键出”,而是“素材长在该长的位置”
  • 三、拍照解题:少做魔法,多做拆解
  • 四、3D生成:先占位,再决定
  • 五、对话:把“聊天”变成“记录”
  • 六、状态管理与失败兜底:让侧栏“不拖人后腿”
  • 七、资源与元数据:复用是王道
  • 八、在 白板 和 markdown 中切换
  • 结语:我喜欢这样的节奏
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档