普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月27日技术

每日一题-会议室 III🔴

2025年12月27日 00:00

给你一个整数 n ,共有编号从 0n - 1n 个会议室。

给你一个二维整数数组 meetings ,其中 meetings[i] = [starti, endi] 表示一场会议将会在 半闭 时间区间 [starti, endi) 举办。所有 starti 的值 互不相同

会议将会按以下方式分配给会议室:

  1. 每场会议都会在未占用且编号 最小 的会议室举办。
  2. 如果没有可用的会议室,会议将会延期,直到存在空闲的会议室。延期会议的持续时间和原会议持续时间 相同
  3. 当会议室处于未占用状态时,将会优先提供给原 开始 时间更早的会议。

返回举办最多次会议的房间 编号 。如果存在多个房间满足此条件,则返回编号 最小 的房间。

半闭区间 [a, b)ab 之间的区间,包括 a 不包括 b

 

示例 1:

输入:n = 2, meetings = [[0,10],[1,5],[2,7],[3,4]]
输出:0
解释:
- 在时间 0 ,两个会议室都未占用,第一场会议在会议室 0 举办。
- 在时间 1 ,只有会议室 1 未占用,第二场会议在会议室 1 举办。
- 在时间 2 ,两个会议室都被占用,第三场会议延期举办。
- 在时间 3 ,两个会议室都被占用,第四场会议延期举办。
- 在时间 5 ,会议室 1 的会议结束。第三场会议在会议室 1 举办,时间周期为 [5,10) 。
- 在时间 10 ,两个会议室的会议都结束。第四场会议在会议室 0 举办,时间周期为 [10,11) 。
会议室 0 和会议室 1 都举办了 2 场会议,所以返回 0 。 

示例 2:

输入:n = 3, meetings = [[1,20],[2,10],[3,5],[4,9],[6,8]]
输出:1
解释:
- 在时间 1 ,所有三个会议室都未占用,第一场会议在会议室 0 举办。
- 在时间 2 ,会议室 1 和 2 未占用,第二场会议在会议室 1 举办。
- 在时间 3 ,只有会议室 2 未占用,第三场会议在会议室 2 举办。
- 在时间 4 ,所有三个会议室都被占用,第四场会议延期举办。 
- 在时间 5 ,会议室 2 的会议结束。第四场会议在会议室 2 举办,时间周期为 [5,10) 。
- 在时间 6 ,所有三个会议室都被占用,第五场会议延期举办。 
- 在时间 10 ,会议室 1 和 2 的会议结束。第五场会议在会议室 1 举办,时间周期为 [10,12) 。 
会议室 1 和会议室 2 都举办了 2 场会议,所以返回 1 。 

 

提示:

  • 1 <= n <= 100
  • 1 <= meetings.length <= 105
  • meetings[i].length == 2
  • 0 <= starti < endi <= 5 * 105
  • starti 的所有值 互不相同

n<=100,代码可以写得简单(比赛时想复杂了)

作者 newhar
2022年9月4日 12:21

由于 $n\le 100$,可以直接按照题目模拟:

  1. 按开始时间排序,依次安排 meetings

  2. 维护每个会议室的 最早可用时间 $t$。每次安排会议$[start, end)$时,将那些 $t$ 早于 $start$ 的会议室的 $t$ 设为 $start$。这样处理后只需从中选择 $t$ 最早的会议室即可(如果有相等的选下标最小的)。

  3. 同时维护 $cnt$ 数组,遍历完成后按要求返回答案即可

###python

class Solution:
    def mostBooked(self, n: int, meetings: List[List[int]]) -> int:
        cnt, t = [0] * n, [0] * n
        
        for [s, e] in sorted(meetings):
            t = list(map(lambda x : max(x, s), t))
            choice = t.index(min(t))
            t[choice], cnt[choice] = t[choice] + e - s, cnt[choice] + 1
            
        return cnt.index(max(cnt))

双堆模拟(Python/Java/C++/Go)

作者 endlesscheng
2022年9月4日 12:07

本题 视频讲解 已出炉,欢迎素质三连,在评论区分享你对这场周赛的看法~


用两个小顶堆模拟:

  • $\textit{idle}$ 维护在 $\textit{start}_i$ 时刻空闲的会议室的编号;
  • $\textit{using}$ 维护在 $\textit{start}_i$ 时刻使用中的会议室的结束时间和编号。

这两类会议室是互补关系,伴随着会议的开始和结束,会议室在这两类中来回倒。

对 $\textit{meetings}$ 按照开始时间排序,然后遍历 $\textit{meetings}$,按照题目要求模拟即可,具体模拟方式见代码。

复杂度分析

  • 时间复杂度:$O(n+m(\log n + \log m))$,其中 $m$ 为 $\textit{meetings}$ 的长度。注意每个会议至多入堆出堆各一次。
  • 空间复杂度:$O(n)$。

相似题目

class Solution:
    def mostBooked(self, n: int, meetings: List[List[int]]) -> int:
        cnt = [0] * n
        idle, using = list(range(n)), []
        meetings.sort(key=lambda m: m[0])
        for st, end in meetings:
            while using and using[0][0] <= st:
                heappush(idle, heappop(using)[1])  # 维护在 st 时刻空闲的会议室
            if len(idle) == 0:
                e, i = heappop(using)  # 没有可用的会议室,那么弹出一个最早结束的会议室(若有多个同时结束的,会弹出下标最小的)
                end += e - st  # 更新当前会议的结束时间
            else:
                i = heappop(idle)
            cnt[i] += 1
            heappush(using, (end, i))  # 使用一个会议室
        ans = 0
        for i, c in enumerate(cnt):
            if c > cnt[ans]:
                ans = i
        return ans
class Solution {
    public int mostBooked(int n, int[][] meetings) {
        var cnt = new int[n];
        var idle = new PriorityQueue<Integer>();
        for (var i = 0; i < n; ++i) idle.offer(i);
        var using = new PriorityQueue<Pair<Long, Integer>>((a, b) -> !Objects.equals(a.getKey(), b.getKey()) ? Long.compare(a.getKey(), b.getKey()) : Integer.compare(a.getValue(), b.getValue()));
        Arrays.sort(meetings, (a, b) -> Integer.compare(a[0], b[0]));
        for (var m : meetings) {
            long st = m[0], end = m[1];
            while (!using.isEmpty() && using.peek().getKey() <= st) {
                idle.offer(using.poll().getValue()); // 维护在 st 时刻空闲的会议室
            }
            int id;
            if (idle.isEmpty()) {
                var p = using.poll(); // 没有可用的会议室,那么弹出一个最早结束的会议室(若有多个同时结束的,会弹出下标最小的)
                end += p.getKey() - st; // 更新当前会议的结束时间
                id = p.getValue();
            } else id = idle.poll();
            ++cnt[id];
            using.offer(new Pair<>(end, id)); // 使用一个会议室
        }
        var ans = 0;
        for (var i = 0; i < n; ++i) if (cnt[i] > cnt[ans]) ans = i;
        return ans;
    }
}
class Solution {
public:
    int mostBooked(int n, vector<vector<int>> &meetings) {
        int cnt[n]; memset(cnt, 0, sizeof(cnt));
        priority_queue<int, vector<int>, greater<>> idle;
        for (int i = 0; i < n; ++i) idle.push(i);
        priority_queue<pair<long, int>, vector<pair<long, int>>, greater<>> using_;
        sort(meetings.begin(), meetings.end(), [](auto &a, auto &b) { return a[0] < b[0]; });
        for (auto &m : meetings) {
            long st = m[0], end = m[1], id;
            while (!using_.empty() && using_.top().first <= st) {
                idle.push(using_.top().second); // 维护在 st 时刻空闲的会议室
                using_.pop();
            }
            if (idle.empty()) {
                auto[e, i] = using_.top(); // 没有可用的会议室,那么弹出一个最早结束的会议室(若有多个同时结束的,会弹出下标最小的)
                using_.pop();
                end += e - st; // 更新当前会议的结束时间
                id = i;
            } else {
                id = idle.top();
                idle.pop();
            }
            ++cnt[id];
            using_.emplace(end, id); // 使用一个会议室
        }
        int ans = 0;
        for (int i = 0; i < n; ++i) if (cnt[i] > cnt[ans]) ans = i;
        return ans;
    }
};
func mostBooked(n int, meetings [][]int) (ans int) {
cnt := make([]int, n)
idle := hp{make([]int, n)}
for i := 0; i < n; i++ {
idle.IntSlice[i] = i
}
using := hp2{}
sort.Slice(meetings, func(i, j int) bool { return meetings[i][0] < meetings[j][0] })
for _, m := range meetings {
st, end := m[0], m[1]
for len(using) > 0 && using[0].end <= st {
heap.Push(&idle, heap.Pop(&using).(pair).i) // 维护在 st 时刻空闲的会议室
}
var i int
if idle.Len() == 0 {
p := heap.Pop(&using).(pair) // 没有可用的会议室,那么弹出一个最早结束的会议室(若有多个同时结束的,会弹出下标最小的)
end += p.end - st // 更新当前会议的结束时间
i = p.i
} else {
i = heap.Pop(&idle).(int)
}
cnt[i]++
heap.Push(&using, pair{end, i}) // 使用一个会议室
}
for i, c := range cnt {
if c > cnt[ans] {
ans = i
}
}
return
}

type hp struct{ sort.IntSlice }
func (h *hp) Push(v interface{}) { h.IntSlice = append(h.IntSlice, v.(int)) }
func (h *hp) Pop() interface{}   { a := h.IntSlice; v := a[len(a)-1]; h.IntSlice = a[:len(a)-1]; return v }

type pair struct{ end, i int }
type hp2 []pair
func (h hp2) Len() int            { return len(h) }
func (h hp2) Less(i, j int) bool  { a, b := h[i], h[j]; return a.end < b.end || a.end == b.end && a.i < b.i }
func (h hp2) Swap(i, j int)       { h[i], h[j] = h[j], h[i] }
func (h *hp2) Push(v interface{}) { *h = append(*h, v.(pair)) }
func (h *hp2) Pop() interface{}   { a := *h; v := a[len(a)-1]; *h = a[:len(a)-1]; return v }

模拟

作者 tsreaper
2022年9月4日 12:07

解法:模拟

因为“当会议室处于未占用状态时,将会优先提供给原开始时间更早的会议”,因此有重要性质:会议开始的相对顺序不会改变。我们只需要按顺序模拟每个会议分配给哪个会议室即可。

复杂度 $\mathcal{O}(m\log m + nm)$,其中 $m$ 是会议的数量。具体实现见参考代码,注意会议的结束时间可能超出 int 的范围。

参考代码(c++)

###c++

class Solution {
public:
    int mostBooked(int n, vector<vector<int>>& meetings) {
        int m = meetings.size();
        // 将会议按开始时间排序
        sort(meetings.begin(), meetings.end());
        // 每个会议室被分配了多少会议
        vector<int> cnt(n);
        // 每个会议室的最早可用时间
        vector<long long> tim(n);
        // 按顺序处理会议
        for (auto &meet : meetings) {
            // best 表示当前不可用,但可用时间最早的会议室编号
            int best = 0;
            for (int i = 0; i < n; i++) {
                if (tim[i] <= meet[0]) {
                    // 当前会议室可用,直接分配
                    cnt[i]++;
                    tim[i] = meet[1];
                    goto OK;
                } else if (tim[i] < tim[best]) best = i; // 当前会议室不可用,更新 best
            }
            // 当前没有会议室可用,等待会议室 best 可用再分配
            cnt[best]++;
            tim[best] += meet[1] - meet[0];
            OK: continue;
        }
        // 统计答案
        int ans = 0;
        for (int i = 0; i < n; i++) if (cnt[i] > cnt[ans]) ans = i;
        return ans;
    }
};

也可以使用线段树将复杂度降至 $\mathcal{O}(m\log m + m\log n)$。

参考代码(c++)

###c++

class Solution {
    vector<long long> mino;

    // 修改 pos 位置的值为 val
    void modify(int id, int l, int r, int pos, long long val) {
        if (l == r) mino[id] = val;
        else {
            int nxt = id << 1, mid = (l + r) >> 1;
            if (pos <= mid) modify(nxt, l, mid, pos, val);
            else modify(nxt | 1, mid + 1, r, pos, val);
            mino[id] = min(mino[nxt], mino[nxt | 1]);
        }
    }

    // 求编号最小的,且值小等于 val 的位置
    int query1(int id, int l, int r, int val) {
        if (l == r) return l;
        else {
            int nxt = id << 1, mid = (l + r) >> 1;
            if (mino[id] > val) return -1;
            else if (mino[nxt] <= val) return query1(nxt, l, mid, val);
            else return query1(nxt | 1, mid + 1, r, val);
        }
    }

    // 求值最小的位置在哪里
    int query2(int id, int l, int r) {
        if (l == r) return l;
        else {
            int nxt = id << 1, mid = (l + r) >> 1;
            return mino[nxt] <= mino[nxt | 1] ? query2(nxt, l, mid) : query2(nxt | 1, mid + 1, r);
        }
    }

public:
    int mostBooked(int n, vector<vector<int>>& meetings) {
        mino.resize(n * 4 + 10);
        int m = meetings.size();
        sort(meetings.begin(), meetings.end());

        vector<int> cnt(n);
        for (auto &meet : meetings) {
            // 是否直接有会议室可用
            int x = query1(1, 0, n - 1, meet[0]);
            if (x >= 0) cnt[x]++, modify(1, 0, n - 1, x, meet[1]); // 直接有会议室可用,更新该会议室的可用时间
            else {
                // 没有会议室直接可用,找接下来最早可用的会议室
                x = query2(1, 0, n - 1);
                // 更新该会议室的可用时间
                cnt[x]++;
                modify(1, 0, n - 1, x, mino[1] + meet[1] - meet[0]);
            }
        }

        // 统计答案
        int ans = 0;
        for (int i = 0; i < n; i++) if (cnt[i] > cnt[ans]) ans = i;
        return ans;
    }
};

别再让 AI 直接写页面了:一种更稳的中后台开发方式

2025年12月27日 00:35

本文讨论的不是 Demo 级别的 AI 编码体验,而是面向真实团队、长期维护的中后台工程实践。

AI 能写代码,但不意味着它适合直接“产出页面”。

最近一年,大模型在前端领域的讨论几乎都围绕一个问题:

“能不能让 AI 直接把页面写出来?”

在真实的中后台项目中,我的答案是:
不但不稳,而且很危险。

这篇文章想分享一种我在真实项目中实践过、可长期使用、可规模化的方式:
不是让 AI 写页面,而是把 AI 纳入中后台前端的工程体系中。 把 AI 的不确定性关进了笼子里,用工程流程保证可控性。

模板固化规范,Spec 描述变化,大模型生成 Spec,脚本生成代码,lint/test 做兜底。 它解决了 AI 上工程最致命的四件事:

  1. 可审计:变化在 spec,生成结果可 diff
  2. 可重复:同一个 spec 反复生成结果一致
  3. 可兜底:lint/test 是硬门槛
  4. 可规模化:从 prompt 工艺变成流程

在中后台场景,尤其是 CRUD 占比高 的项目里,这几乎就是“性价比最优解”。

一、中后台页面开发的真实困境

如果你做过中后台前端,一定对下面这些场景不陌生:

  • 页面 80% 是 CRUD
  • 列表页结构高度一致
  • 表单字段不断变化
  • 大量复制粘贴
  • 页面逻辑“看起来差不多,但永远不完全一样”

最终结果往往是:

  • 代码冗余
  • 风格不统一
  • 新人上手慢
  • 改一个字段要改好几个地方

这些问题不是某个框架的问题,而是中后台开发的结构性问题。

二、为什么“让 AI 直接写页面”在真实项目里行不通?

很多人第一反应是:

“既然页面这么重复,为什么不直接让 AI 写 Vue / React 页面?”

在真实项目中,这种方式往往会遇到几个致命问题。

1️⃣ 不稳定

  • 同样的 prompt,每次生成结果不同
  • 组件结构、命名风格不断漂移
  • 难以保证团队统一规范

2️⃣ 难以 review

  • AI 一次生成几百行代码
  • reviewer 很难判断“这是不是对的”
  • 出问题时难以定位责任

3️⃣ 无法规模化

  • prompt 是隐性的
  • 经验无法沉淀
  • 每个页面都是“重新生成一次”

4️⃣ 工程体系无法兜底

  • lint / test 很难提前发现语义问题
  • 一旦出错,往往是运行期问题

结论很明确:
AI 直接写页面,更像是 demo,而不是工程方案。

三、一个更稳的思路:把“变化”和“稳定”拆开

在真实项目中,我最终选择了一种更偏工程化的做法:

Template + Spec + Generator + AI

核心思想只有一句话:

模板负责稳定性,Spec 负责变化,AI 只参与变化。

这个流程长什么样?

需求描述
   ↓
页面规格(Spec)
   ↓
模板(Template)
   ↓
生成脚本(Generator)
   ↓
页面代码
   ↓
lint / test 校验

这不是为了“多一层抽象”,而是为了把 AI 的不确定性限制在可控范围内

四、什么是 Spec?

**Spec(Specification)**可以理解为:

页面的“规格说明书”

它描述的是:

  • 页面标题
  • 接口地址
  • 表格字段
  • 查询条件
  • 表单字段与校验规则

而不是:

  • 生命周期怎么写
  • API 怎么调用
  • UI 组件怎么拼

这些内容,非常适合用一份结构化数据来表达。

一个简化的 Spec 示例

{
  "title": "供应商管理",
  "api": {
    "list": "/api/supplier/list",
    "create": "/api/supplier/create",
    "update": "/api/supplier/update",
    "delete": "/api/supplier/delete"
  },
  "columns": [
    { "prop": "name", "label": "供应商名称" },
    { "prop": "contact", "label": "联系人" },
    { "prop": "status", "label": "状态" }
  ],
  "formSchema": [
    { "prop": "name", "label": "供应商名称", "required": true },
    { "prop": "contact", "label": "联系人" },
    {
      "prop": "status",
      "label": "状态",
      "type": "select",
      "options": ["启用", "停用"]
    }
  ]
}

这份 JSON 不依赖任何前端框架,但已经完整描述了一个中后台页面的“变化点”。

五、Template:把重复劳动固化成资产

Template 是固定不变的部分,例如:

  • 页面整体结构
  • 表格 / 表单 / 弹窗骨架
  • API 调用方式
  • 分页逻辑
  • 错误处理方式

它的特点是:

  • 人工维护
  • 版本化
  • 可 review
  • 很少变动

你可以用 Vue、React、Svelte,模板思想本身与框架无关

六、Generator:让生成变成确定性行为

Generator 的职责非常单一:

把 Spec 填进 Template,生成代码文件

这一点非常重要:

  • Generator 是脚本
  • 输出是确定的
  • 不涉及 AI 决策

换句话说:

Generator 不是“智能的”,但它是可靠的。

七、AI 在这里扮演什么角色?

在这套体系中,AI 的职责被严格限制在两点:

✅ 1. 从自然语言生成 Spec

AI 非常擅长:

  • 理解业务描述
  • 生成结构化 JSON
  • 补全字段信息

✅ 2. 按 lint / 报错做最小修复

  • 只修具体文件
  • 只做最小 diff
  • 不重写整体结构

❌ AI 不该做的事

  • 直接写页面代码
  • 修改模板
  • 改动基础设施
  • 引入新依赖

这样做的结果是:
AI 的能力被“工程流程”约束,而不是反过来。

✅ 正确姿势

让 Codex 做两件事:

1️⃣ 根据自然语言 生成 Spec JSON
2️⃣ 根据 lint / 报错 做最小 patch 修复

示例指令(在 Codex CLI / IDE 中):

specs/ 下生成 supplier.json,字段为供应商名称、联系人、电话、状态(启用/停用),接口路径为 /api/supplier/*,输出必须是严格 JSON。

然后:

yarn gen:page specs/supplier.json
yarn lint

如果 lint 报错,再让 Codex 修:

根据 lint 报错,只修改 src/views/supplier/List.vue,用最小改动修到通过。

八、为什么这种方式更适合真实团队?

从工程角度看,这种方式有几个明显优势:

  • 可控:模板稳定,变化集中在 Spec
  • 可 review:Spec 是结构化数据
  • 可回滚:git diff 非常清晰
  • 可规模化:不是 prompt 驱动,而是流程驱动
  • 可迁移:换框架只需换模板

这也是为什么它比“直接让 AI 写页面”更稳。

九、这套思路不只适用于中后台

这种模式可以自然扩展到:

  • 表单页 / 详情页
  • 权限路由生成
  • 页面迁移(如 Vue2 → Vue3)
  • 低代码 / 页面工厂
  • 前端工程自动化

核心不在工具,而在拆分变化与稳定的边界

十、模板化不是终点:一条更现实的“最佳进化路线”

需要说明的是,Template + Spec + Generator 并不是终极方案,而是一个非常合适的工程起点。并不是所有团队都需要走到配置驱动或 AST 修改阶段,对很多团队来说,Template + Spec 本身已经是最优解。

在真实项目中,我更推荐把它看作一条“可进化的路线”,而不是一锤定音的设计。

第一步:Template + Spec(现在的方案)

适用场景:

  • CRUD 页面占比高
  • 新页面数量多
  • 团队希望尽快统一规范

价值:

  • 快速落地
  • 风险可控
  • 非常适合引入 AI 的第一步

第二步:抽象稳定能力,弱化模板复杂度

当发现模板里开始出现大量条件分支时,一个更稳的做法是:

把模板中的稳定逻辑抽成基础组件(如 BaseCrudPage)

此时:

  • 模板变薄
  • spec 只描述“页面配置”
  • 页面本身不再频繁生成新文件

这一步,已经非常接近配置驱动页面渲染

第三步:从“生成代码”走向“配置即页面”

在 CRUD 占比极高的系统中,最终形态往往是:

页面 = 配置(Spec) + 渲染器

此时:

  • 新页面不再生成 .vue 文件
  • 只新增 spec + 路由配置
  • AI 直接生成 spec,收益最大

这本质上就是低代码/页面工厂的雏形

第四步:存量项目引入结构化修改(AST / Patch)

对于已有大量页面的系统,更稳妥的方式是:

  • 用 spec 描述“变更意图”
  • 用工具对代码做结构化修改(如只改 columns、formSchema)
  • AI 只产出 patch,而不是重写页面

这一步非常适合:

  • 老项目
  • 安全要求高的团队
  • 渐进式演进

一句话总结这条路线

模板化是把 AI 引入工程体系的第一步,
配置驱动和结构化修改,才是中后台工程的长期形态。

十一、总结

大模型的价值,不在于“替代工程师写页面”,
而在于:

把重复劳动结构化,并嵌入到工程体系中。

在中后台前端开发中,
最稳的方式永远不是“让 AI 自由发挥”,
而是让它在清晰的边界内工作。

如果只能记住一句话: 不要让 AI 直接写页面,让它写“变化”,其余交给工程。


如果你觉得这篇文章对你有启发,欢迎点赞或收藏 👍

昨天 — 2025年12月26日技术

Flutter组件封装:视频播放组件全局封装

作者 SoaringHeart
2025年12月26日 20:52

一、需求来源

最近要开发一个在线视频播放的功能,每次实时获取播放,有些卡顿和初始化慢的问题,就随手优化一下。

二、使用示例

1、数据源

class VideoDetailsProvider extends ChangeNotifier {

    /// 当前播放中的
    final _videoModelController = StreamController<VideoDetailModel?>.broadcast();

    /// 当前播放中 Stream<VideoDetailModel>
    Stream<VideoDetailModel?> get videoModelStream => _videoModelController.stream;


    /// 点击(model不为空时播放,为空时关闭)
    sinkModelForVideoPlayer({required VideoDetailModel? model}) {
      _videoModelController.add(model);
    }

}

2、显示播放器组件

StreamBuilder<VideoDetailModel?>(
  stream: provider.videoModelStream,
  builder: (context, snapshot) {
    if (snapshot.data == null) {
      return const SizedBox();
    }
    final model = snapshot.data;
    if (model?.isVideo != true) {
      return const SizedBox();
    }

    return SafeArea(
      top: true,
      bottom: false,
      left: false,
      right: false,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.black,
        ),
        child: AppVideoPlayer(
          key: Key(model?.url ?? ""),
          url: model?.url ?? "",
          onFullScreen: (value) async {
            if (!value) {
              await Future.delayed(const Duration(milliseconds: 300));//等待旋转完成
              SystemChromeExt.changeDeviceOrientation(isPortrait: true);
            }
          },
          onClose: () {
            DLog.d("close");
            provider.sinkModelForVideoPlayer(model: null);
          },
        ),
      ),
    );
  },
)

三、源码

1、AppVideoPlayer.dart

//
//  AppVideoPlayer.dart
//  flutter_templet_project
//
//  Created by shang on 2025/12/12 18:11.
//  Copyright © 2025/12/12 shang. All rights reserved.
//

import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/AppVideoPlayer/AppVideoPlayerService.dart';
import 'package:flutter_templet_project/extension/extension_local.dart';
import 'package:video_player/video_player.dart';

/// 播放器
class AppVideoPlayer extends StatefulWidget {
  const AppVideoPlayer({
    super.key,
    this.controller,
    required this.url,
    this.autoPlay = true,
    this.looping = false,
    this.aspectRatio = 16 / 9,
    this.isPortrait = true,
    this.fullScreenVN,
    this.onFullScreen,
    this.onClose,
  });

  final AppVideoPlayerController? controller;

  final String url;
  final bool autoPlay;
  final bool looping;
  final double aspectRatio;

  /// 设备方向
  final bool isPortrait;

  final ValueNotifier<bool>? fullScreenVN;

  final void Function(bool isFullScreen)? onFullScreen;

  final VoidCallback? onClose;

  @override
  State<AppVideoPlayer> createState() => _AppVideoPlayerState();
}

class _AppVideoPlayerState extends State<AppVideoPlayer> with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
  VideoPlayerController? _videoController;
  ChewieController? _chewieController;

  Duration position = Duration.zero;

  @override
  void dispose() {
    widget.controller?._detach(this);
    WidgetsBinding.instance.removeObserver(this);
    _onClose();
    // 不销毁 VideoPlayerController,让全局复用
    // _chewieController?.dispose();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    widget.controller?._attach(this);
    WidgetsBinding.instance.addObserver(this);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      initPlayer();
    });
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.inactive:
      case AppLifecycleState.hidden:
      case AppLifecycleState.paused:
        {
          _chewieController?.pause();
        }
        break;
      case AppLifecycleState.detached:
        break;
      case AppLifecycleState.resumed:
        {
          _chewieController?.play();
        }
        break;
    }
  }

  Future<void> initPlayer() async {
    // DLog.d(widget.url.split("/").last);
    assert(widget.url.startsWith("http"), "url 错误");

    _videoController = await AppVideoPlayerService.instance.getController(widget.url);

    _chewieController?.dispose();
    _chewieController = ChewieController(
      videoPlayerController: _videoController!,
      autoPlay: widget.autoPlay,
      looping: widget.looping,
      aspectRatio: widget.aspectRatio,
      autoInitialize: true,
      allowFullScreen: true,
      allowMuting: false,
      showControlsOnInitialize: false,
      // customControls: const AppVideoControls(),
    );

    _chewieController!.addListener(() {
      final isFullScreen = _chewieController!.isFullScreen;
      widget.fullScreenVN?.value = isFullScreen;
      widget.onFullScreen?.call(isFullScreen);
      if (isFullScreen) {
        DLog.d("进入全屏");
      } else {
        DLog.d("退出全屏");
      }
    });
    if (mounted) {
      setState(() {});
    }
  }

  Future<void> _onClose() async {
    if (_videoController?.value.isPlaying == true) {
      _videoController?.pause();
    }
  }

  @override
  void didUpdateWidget(covariant AppVideoPlayer oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.url != widget.url) {
      initPlayer();
    }
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();

    /// 🔥屏幕横竖切换时 rebuild Chewie,但不 rebuild video
    DLog.d([MediaQuery.of(context).orientation]);
    // setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    if (widget.url.startsWith("http") != true) {
      return const SizedBox();
    }

    if (_chewieController == null) {
      return const Center(child: CircularProgressIndicator());
    }
    // return Chewie(controller: _chewieController!);
    return Stack(
      children: [
        Positioned.fill(
          child: Chewie(
            controller: _chewieController!,
          ),
        ),
        Positioned(
          right: 20,
          top: 10,
          child: Container(
            // decoration: BoxDecoration(
            //   color: Colors.red,
            //   border: Border.all(color: Colors.blue),
            // ),
            child: buildCloseBtn(onTap: widget.onClose),
          ),
        ),
      ],
    );
  }

  Widget buildCloseBtn({VoidCallback? onTap}) {
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: () {
        DLog.d("buildCloseBtn");
        _onClose();
        if (onTap != null) {
          onTap();
          return;
        }
        Navigator.pop(context);
      },
      child: Container(
        // padding: EdgeInsets.all(6),
        // decoration: BoxDecoration(
        //   color: Colors.black54,
        //   shape: BoxShape.circle,
        //   border: Border.all(color: Colors.blue),
        // ),
        child: const Icon(Icons.close, color: Colors.white, size: 24),
      ),
    );
  }

  @override
  bool get wantKeepAlive => true;
}

class AppVideoPlayerController {
  _AppVideoPlayerState? _anchor;

  void _attach(_AppVideoPlayerState anchor) {
    _anchor = anchor;
  }

  void _detach(_AppVideoPlayerState anchor) {
    if (_anchor == anchor) {
      _anchor = null;
    }
  }

  VideoPlayerController? get videoController {
    assert(_anchor != null);
    return _anchor!._videoController;
  }

  ChewieController? get chewieController {
    assert(_anchor != null);
    return _anchor!._chewieController;
  }
}

2、AppVideoPlayerService.dart

//
//  AppVideoPlayerService.dart
//  flutter_templet_project
//
//  Created by shang on 2025/12/12 18:10.
//  Copyright © 2025/12/12 shang. All rights reserved.
//

import 'package:flutter_templet_project/extension/extension_local.dart';
import 'package:quiver/collection.dart';
import 'package:video_player/video_player.dart';

/// 视频播放控制器全局管理
class AppVideoPlayerService {
  AppVideoPlayerService._();
  static final AppVideoPlayerService _instance = AppVideoPlayerService._();
  factory AppVideoPlayerService() => _instance;
  static AppVideoPlayerService get instance => _instance;

  /// 播放器字典
  LruMap<String, VideoPlayerController> get controllerMap => _controllerMap;
  // final _controllerMap = <String, VideoPlayerController>{};
  final _controllerMap = LruMap<String, VideoPlayerController>(maximumSize: 10);

  /// 最新使用播放器
  VideoPlayerController? current;

  /// 有缓存控制器
  bool hasCtrl({required String url}) => _controllerMap[url] != null;

  /// 是网络视频
  static bool isVideo(String? url) {
    if (url?.isNotEmpty != true) {
      return false;
    }

    final videoUri = Uri.tryParse(url!);
    if (videoUri == null) {
      return false;
    }

    final videoExt = ['.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm'];
    final ext = url.toLowerCase();
    final result = videoExt.any((e) => ext.endsWith(e));
    return result;
  }

  /// 获取 VideoPlayerController
  Future<VideoPlayerController?> getController(String url, {bool isLog = false}) async {
    assert(url.startsWith("http"), "必须是视频链接,请检查链接是否合法");
    final vc = _controllerMap[url];
    if (vc != null) {
      current = vc;
      if (isLog) {
        DLog.d(["缓存: ${vc.hashCode}"]);
      }
      return vc;
    }

    final videoUri = Uri.tryParse(url);
    if (videoUri == null) {
      return null;
    }

    final ctrl = VideoPlayerController.networkUrl(videoUri);
    await ctrl.initialize();
    _controllerMap[url] = ctrl;
    current = ctrl;
    if (isLog) {
      DLog.d(["新建: ${_controllerMap[url].hashCode}"]);
    }
    return ctrl;
  }

  /// 播放
  Future<void> play({required String url, bool onlyOne = true}) async {
    await getController(url);
    for (final e in _controllerMap.entries) {
      if (e.key == url) {
        if (!e.value.value.isPlaying) {
          e.value.play();
        } else {
          e.value.pause();
        }
      } else {
        if (onlyOne) {
          e.value.pause();
        }
      }
    }
  }

  /// 暂停所有视频
  void pauseAll() {
    for (final e in _controllerMap.entries) {
      e.value.pause();
    }
  }

  void dispose(String url) {
    _controllerMap[url]?.dispose();
    _controllerMap.remove(url);
  }

  void disposeAll() {
    for (final c in _controllerMap.values) {
      c.dispose();
    }
    _controllerMap.clear();
  }
}

最后、总结

1、播放视频组件和视频列表是分开的,通过监听 VideoPlayerController 保持状态(播放,暂停)一致性,类似网络视频小窗口播放。

2、通过 AppVideoPlayerService 实现全局 VideoPlayerController 缓存,提高初始化加载速度。

3、通过 quiver 的 LruMap 实现最大缓存数控制,防止内存爆炸。

github

我接入了微信小说小程序官方阅读器

作者 jack_po
2025年12月26日 18:31

概述

微信官方为小说类小程序提供了专用的阅读器插件,所有小说类目的小程序都必须使用该组件。

快速开始

1. 添加插件配置

在小程序的 app.json 文件中添加插件配置:

{
  "plugins": {
    "novel-plugin": {
      "version": "latest",
      "provider": "wx293c4b6097a8a4d0"
    }
  }
}

2. 初始化插件

app.js 中初始化插件并监听页面加载事件:

// app.js
const novelPlugin = requirePlugin('novel-plugin')

App({
  onLaunch() {
    // 监听阅读器页面加载事件
    novelPlugin.onPageLoad(onNovelPluginLoad)
  },
})

function onNovelPluginLoad(data) {
  // data.id - 阅读器实例ID
  const novelManager = novelPlugin.getNovelManager(data.id)
  
  // 设置目录状态
  novelManager.setContents({
    contents: [
      { index: 0, status: 0 }, // 第一章:免费
      { index: 1, status: 2 }, // 第二章:未解锁
      { index: 2, status: 1 }, // 第三章:已解锁
    ]
  })

  // 监听用户行为
  novelManager.onUserTriggerEvent(res => {
    console.log('用户操作:', res.event_id, res)
  })
}

3. 跳转到阅读页面

// 跳转到阅读器页面
wx.navigateTo({
  url: 'plugin-private://wx293c4b6097a8a4d0/pages/novel/index?bookId=书籍ID'
})

核心功能详解

页面跳转参数

跳转到阅读页面时可以传入以下参数:

参数 必填 说明
bookId 书籍ID
chapterIndex 跳转章节下标(从0开始)
fontSize 字体大小(0-9)
turnPageWay 翻页方式:SWIPE/MOVE/SCROLL
backgroundConfigIndex 背景色序号(1-5)
isNightMode 是否夜间模式
showListenButton 是否显示听书按钮

章节解锁功能

1. 创建解锁组件

首先创建一个自定义组件 charge-dialog

<!-- charge-dialog.wxml -->
<view class="charge-dialog">
  <text>解锁第 {{ chapterIndex + 1 }} 章</text>
  <button bindtap="unlock">立即解锁</button>
</view>
// charge-dialog.js
const novelPlugin = requirePlugin('novel-plugin')

Component({
  properties: {
    novelManagerId: Number,
    bookId: String,
    chapterIndex: Number,
    chapterId: String
  },

  methods: {
    unlock() {
      const novelManager = novelPlugin.getNovelManager(this.properties.novelManagerId)
      
      // 执行解锁逻辑...
      
      // 解锁完成后通知阅读器
      novelManager.paymentCompleted()
    }
  }
})

2. 注册组件

app.json 中注册组件:

{
  "plugins": {
    "novel-plugin": {
      "version": "latest",
      "provider": "wx293c4b6097a8a4d0",
      "genericsImplementation": {
        "novel": {
          "charge-dialog": "components/charge-dialog/charge-dialog"
        }
      }
    }
  }
}

消息推送配置

阅读器通过消息推送验证章节解锁状态,需要在服务器端配置消息接收:

// 服务器接收消息示例
{
  "ToUserName": "小程序原始ID",
  "FromUserName": "用户openid",
  "CreateTime": 时间戳,
  "MsgType": "event",
  "Event": "wxa_novel_chapter_permission",
  "BookId": "书籍ID",
  "ChapterIndex": 章节下标,
  "ChapterId": "章节ID",
  "Source": 1 // 1表示用户实际阅读
}

// 响应格式
{
  "ErrCode": 0,
  "ErrMsg": "",
  "ChapterPerms": [{
    "StartChapterIndex": 0,
    "EndChapterIndex": 2,
    "Perm": 1 // 0-免费 1-已解锁 2-未解锁
  }]
}

高级功能

1. 自定义解锁方式

// 设置不同的解锁方式
novelManager.setChargeWay({
  globalConfig: {
    mode: 2, // 1:默认 2:广告解锁 3:自定义解锁
    buttonText: "解锁"
  },
  chapterConfigs: [
    {
      chapterIndex: 10,
      mode: 3,
      buttonText: "VIP解锁"
    }
  ]
})

// 监听自定义解锁事件
novelManager.onUserClickCustomUnlock(res => {
  console.log('自定义解锁章节:', res.chapterIndex)
})

2. 插入自定义段落

// 在正文中插入自定义内容
novelManager.setParagraphBlock({
  chapterConfigs: [{
    chapterIndex: 0,
    blocks: [{
      height: 100,    // 高度
      position: 1,    // 位置(0:标题前,1:第一段前)
      ext: "自定义数据",
      key: "unique-id"
    }]
  }]
})

3. 广告插入

// 插入广告
novelManager.setAdBlock({
  chapterConfigs: [{
    chapterIndex: 0,
    blocks: [{
      type: 1,        // 1:强制观看 2:banner广告 3:书签广告
      position: 10,   // 广告位置
      duration: 6,    // 强制观看时长(秒)
      unitId: "广告单元ID"
    }]
  }]
})

实用API参考

获取阅读器信息

// 获取当前阅读器实例
const novelManager = novelPlugin.getCurrentNovelManager()

// 获取书籍ID
const bookId = novelManager.getBookId()

// 获取插件信息
const pluginInfo = novelManager.getPluginInfo()

导航控制

// 设置关闭行为
novelManager.setClosePluginInfo({
  url: '/pages/index/index',
  mode: 'redirectTo'
})

// 设置返回行为
novelManager.setLeaveReaderInfo({
  url: '/pages/bookshelf/index',
  mode: 'switchTab'
})

分享配置

novelManager.setShareParams({
  title: '书籍标题',
  imageUrl: '封面图片',
  args: {
    from: 'share',
    time: '2024'
  }
})

书架功能

// 设置书架状态
novelManager.setBookshelfStatus({
  bookshelfStatus: 1 // 0:未添加 1:已添加
})

// 监听书架点击
novelManager.onClickBookshelf(res => {
  // 处理书架逻辑
  novelManager.setBookshelfStatus({
    bookshelfStatus: res.bookshelfStatus ? 1 : 0
  })
})

事件监听

阅读器提供丰富的事件监听:

novelManager.onUserTriggerEvent(res => {
  switch(res.event_id) {
    case 'start_read':          // 开始阅读
    case 'change_chapter':      // 切换章节
    case 'change_fontsize':     // 调整字号
    case 'click_listen':        // 点击听书
    case 'audio_start':         // 音频开始
    // ... 更多事件
  }
})

让大语言模型拥有“记忆”:多轮对话与 LangChain 实践指南

作者 wwwwW
2025年12月26日 18:09

让大语言模型拥有“记忆”:多轮对话与 LangChain 实践指南

在当前人工智能应用开发中,大语言模型(LLM)因其强大的自然语言理解与生成能力,被广泛应用于聊天机器人、智能客服、个人助理等场景。然而,一个常见的问题是:LLM 本身是无状态的——每一次 API 调用都独立于前一次,就像每次 HTTP 请求一样,模型无法“记住”你之前说过什么。

那么,如何让 LLM 拥有“记忆”,实现真正的多轮对话体验?本文将从原理出发,结合 LangChain 框架的实践代码,深入浅出地讲解这一关键技术。


一、为什么 LLM 默认没有记忆?

大语言模型(如 DeepSeek、GPT、Claude 等)在设计上遵循“输入-输出”模式。当你调用其 API 时,仅传递当前的问题或指令,模型不会自动保留之前的交互内容。例如:

ts
编辑
const res1 = await model.invoke('我叫王源,喜欢喝白兰地');
const res2 = await model.invoke('我叫什么名字?');

第二次调用时,模型完全不知道“王源”是谁,因此很可能回答:“我不知道你的名字。”
这是因为两次调用之间没有任何上下文传递。


二、实现“记忆”的核心思路:维护对话历史

要让 LLM 表现出“有记忆”的行为,最直接的方法是:在每次请求中显式传入完整的对话历史。通常以 messages 数组的形式组织:

json
编辑
[  {"role": "user", "content": "我叫王源,喜欢喝白兰地"},  {"role": "assistant", "content": "很高兴认识你,王源!白兰地是很优雅的选择。"},  {"role": "user", "content": "你知道我是谁吗?"}]

模型通过分析整个对话上下文,就能准确回答:“你是王源,喜欢喝白兰地。”

但这种方法存在明显问题:随着对话轮次增加,输入 token 数量不断膨胀,不仅增加计算成本,还可能超出模型的最大上下文长度限制(如 32768 tokens)。这就像滚雪球,越滚越大。


三、LangChain 提供的解决方案:模块化记忆管理

为了解决上述问题,LangChain 等 AI 应用框架提供了专门的 Memory 模块,帮助开发者高效管理对话历史,并支持多种存储策略(内存、数据库、向量化摘要等)。

以下是一个使用 @langchain/deepseekRunnableWithMessageHistory 的完整示例:

1. 初始化模型与提示模板

ts
编辑
import { ChatDeepSeek } from '@langchain/deepseek';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableWithMessageHistory } from '@langchain/core/runnables';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';

const model = new ChatDeepSeek({
  model: 'deepseek-chat',
  temperature: 0,
});

// 定义包含历史记录占位符的提示模板
const prompt = ChatPromptTemplate.fromMessages([
  ['system', '你是一个有记忆的助手'],
  ['placeholder', '{history}'], // 历史消息将插入此处
  ['human', '{input}']
]);

2. 构建带记忆的可运行链

ts
编辑
const runnable = prompt.pipe(model);

// 创建内存中的对话历史存储
const messageHistory = new InMemoryChatMessageHistory();

// 封装成支持会话记忆的链
const chain = new RunnableWithMessageHistory({
  runnable,
  getMessageHistory: async () => messageHistory,
  inputMessagesKey: 'input',
  historyMessagesKey: 'history',
});

3. 执行多轮对话

ts
编辑
// 第一轮:用户自我介绍
const res1 = await chain.invoke(
  { input: '我叫王源,喜欢喝白兰地' },
  { configurable: { sessionId: 'makefriend' } }
);
console.log(res1.content); // “你好,王源!白兰地确实很经典。”

// 第二轮:提问名字
const res2 = await chain.invoke(
  { input: '我叫什么名字?' },
  { configurable: { sessionId: 'makefriend' } }
);
console.log(res2.content); // “你叫王源。”

✅ 关键点:sessionId 用于区分不同用户的会话。同一个 sessionId 下的所有交互共享同一段历史。


四、进阶思考:如何优化长对话的记忆效率?

虽然内存存储简单易用,但在生产环境中,面对海量用户和长期对话,我们需要更高效的策略:

  1. 滑动窗口记忆:只保留最近 N 轮对话。
  2. 摘要压缩:定期将历史对话总结成一段摘要,替代原始记录。
  3. 向量数据库 + 语义检索:将关键信息存入向量库,按需检索相关上下文(适用于知识密集型对话)。
  4. 混合记忆:结合短期(最近几轮)+ 长期(摘要/数据库)记忆。

LangChain 已支持多种 Memory 类型,如 BufferWindowMemorySummaryMemoryVectorStoreRetrieverMemory 等,可根据场景灵活选择。


五、结语

让 LLM 拥有“记忆”,本质上是将无状态的模型调用转化为有状态的会话系统。通过维护对话历史并合理控制上下文长度,我们可以在成本与体验之间取得平衡。

LangChain 等框架极大简化了这一过程,使开发者能专注于业务逻辑,而非底层状态管理。未来,随着上下文窗口的扩大和记忆机制的智能化(如自动遗忘、重点记忆),AI 助手将越来越像一个真正“记得你”的朋友。

正如我们在代码中看到的那样——当模型说出“你叫王源”时,那一刻,它仿佛真的记住了你。

鸿蒙分布式KVStore冲突解决机制:原理、实现与工程化实践

作者 Nick不懂
2025年12月26日 18:07

鸿蒙分布式KVStore冲突解决机制:原理、实现与工程化实践

一、核心背景与问题定义

分布式KVStore是鸿蒙(HarmonyOS/OpenHarmony)实现跨设备数据协同的核心组件,适用于应用配置、用户状态等轻量级数据的跨设备同步场景。其基于最终一致性模型设计,在多设备并发写入同一Key时,必然产生数据冲突。本文聚焦 SingleKVStore(单版本模式) 的冲突解决机制,明确核心问题边界:当组网内多个设备对同一Key执行写入操作时,如何保证数据最终一致性,同时避免业务关键数据丢失。

二、冲突产生的底层逻辑

1. 冲突触发条件

  • 数据维度:同一SingleKVStore实例中,不同设备对同一Key执行写入(覆盖/更新)操作;

  • 网络维度:设备间网络中断后恢复连接,或多设备同时在线时并发写入;

  • 系统维度:同步过程中数据传输延迟、设备时钟偏差(影响时间戳判断)。

2. 默认冲突解决策略:LWW(Last Write Wins)

鸿蒙默认采用“最后写入者获胜”策略,核心判定依据为数据的写入时间戳(系统时间)或版本号:

  • 判定逻辑:同步时对比同一Key的时间戳,保留时间戳更新的写入数据;

  • 适用场景:配置类数据(如主题设置、字体大小),这类数据对“最新状态”的需求优先于“全量合并”;

  • 局限性:无法处理需要结构化合并的场景(如待办清单数组、多字段对象),且设备时钟偏差可能导致错误的优先级判定。

三、自定义冲突解决:实战实现(Stage模型+ArkTS)

1. 前置准备:权限与依赖

需在module.json5中声明分布式数据操作权限,确保跨设备数据同步能力正常启用:


{

  "module": {

    "requestPermissions": [

      {

        "name": "ohos.permission.DISTRIBUTED_DATASYNC",

        "reason": "跨设备数据同步需要",

        "usedScene": { "ability": ["com.demo.kvstore.DemoAbility"], "when": "always" }

      },

      {

        "name": "ohos.permission.GET_DISTRIBUTED_DEVICE_INFO",

        "reason": "获取组网设备信息需要",

        "usedScene": { "ability": ["com.demo.kvstore.DemoAbility"], "when": "always" }

      }

    ]

  }

}

依赖导入(API 10+,需同步升级SDK至对应版本):


import distributedData from '@ohos.data.distributedData';

import { BusinessError } from '@ohos.base';

import deviceManager from '@ohos.distributedHardware.deviceManager';

2. 核心实现步骤

步骤1:初始化分布式KVStore(指定单版本模式)

class DistributedKVManager {

  private kvStore: distributedData.SingleKVStore | null = null;

  private readonly STORE_NAME = 'demo_business_store'; // 跨设备统一存储名称

  private readonly SECURITY_LEVEL = distributedData.SecurityLevel.S1; // 基础安全等级(非加密)

  


  // 初始化KVStore,确保跨设备共享同一存储实例

  async init(): Promise<boolean> {

    try {

      const options: distributedData.Options = {

        createIfMissing: true,

        encrypt: false,

        securityLevel: this.SECURITY_LEVEL

      };

      // 获取SingleKVStore实例(单版本模式,支持跨设备同步)

      this.kvStore = await distributedData.getSingleKVStore(this.STORE_NAME, options);

      console.info('DistributedKVStore初始化成功');

      this.registerConflictListener(); // 注册冲突监听

      return true;

    } catch (error) {

      const err = error as BusinessError;

      console.error(`KVStore初始化失败:code=${err.code}, message=${err.message}`);

      return false;

    }

  }

}

步骤2:自定义冲突解决逻辑(以待办清单合并为例)

针对结构化数据(如待办清单数组),实现“数组去重合并”的自定义策略,替代默认LWW策略:


private async registerConflictListener() {

  if (!this.kvStore) return;

  


  // 监听所有数据变更(本地+远端),通过业务逻辑识别冲突

  this.kvStore.on('dataChange', distributedData.SubscribeType.SUBSCRIBE_TYPE_ALL, async (data) => {

    for (const entry of data.updateEntries) {

      const key = entry.key;

      const remoteValue = entry.value; // 远端同步过来的新数据

      const localValue = await this.kvStore!.get(key); // 本地当前数据

  


      // 1. 数据格式校验(约定value为{ ver: number, payload: any }结构)

      if (!this.validateDataFormat(localValue) || !this.validateDataFormat(remoteValue)) {

        console.warn(`数据格式非法,采用LWW策略:key=${key}`);

        return;

      }

  


      // 2. 判定冲突:本地与远端版本号不同时视为冲突

      if (localValue.ver !== remoteValue.ver) {

        console.info(`检测到冲突:key=${key},本地版本=${localValue.ver},远端版本=${remoteValue.ver}`);

        const mergedValue = this.mergeTodoList(localValue.payload, remoteValue.payload);

        const newVer = Math.max(localValue.ver, remoteValue.ver) + 1; // 生成新版本号

  


        // 3. 写入合并后的数据(避免循环同步,需原子操作)

        await this.kvStore!.put(key, { ver: newVer, payload: mergedValue });

        await this.kvStore!.flush(); // 强制刷盘,确保同步可靠性

      }

    }

  });

}

  


// 校验数据格式(业务自定义)

private validateDataFormat(data: any): boolean {

  return typeof data === 'object' && data !== null && 'ver' in data && 'payload' in data;

}

  


// 待办清单合并逻辑:去重并保留所有有效条目

private mergeTodoList(localTodo: Array<{ id: string; content: string; completed: boolean }>, 

                      remoteTodo: Array<{ id: string; content: string; completed: boolean }>): Array<any> {

  const todoMap = new Map<string, any>();

  // 先加入本地条目

  localTodo.forEach(todo => todoMap.set(todo.id, todo));

  // 加入远端条目(远端已完成状态优先,避免本地未同步的完成状态丢失)

  remoteTodo.forEach(todo => {

    const existTodo = todoMap.get(todo.id);

    if (existTodo) {

      todoMap.set(todo.id, { ...existTodo, completed: todo.completed });

    } else {

      todoMap.set(todo.id, todo);

    }

  });

  return Array.from(todoMap.values());

}

步骤3:主动同步触发(控制同步时机)

通过sync方法主动触发跨设备数据同步,支持指定同步模式和目标设备:


// 触发同步:向组网内所有设备推送本地数据并拉取远端数据

async syncData(): Promise<boolean> {

  if (!this.kvStore) return false;

  try {

    const syncOptions: distributedData.SyncOptions = {

      syncMode: distributedData.SyncMode.PUSH_PULL, // 推拉模式(双向同步)

      deviceIds: [], // 空数组表示同步至所有组网设备

      delayMs: 100 // 延迟100ms同步,避免频繁写入导致的抖动

    };

    await this.kvStore.sync(syncOptions);

    console.info('数据同步触发成功');

    return true;

  } catch (error) {

    const err = error as BusinessError;

    console.error(`同步失败:code=${err.code}, message=${err.message}`);

    return false;

  }

}

四、工程化最佳实践

1. 冲突规避:存储结构设计原则

  • 拆分Key粒度:将复杂对象拆分为多个独立Key(如todo_list_202506todo_config),减少同一Key的并发写入;

  • 设备维度隔离:非共享数据使用DeviceKVStore(按设备分片存储),天然避免跨设备冲突;

  • 版本号强制递增:约定所有写入操作必须生成新的版本号(如基于时间戳+设备ID),确保冲突判定准确性。

2. 性能优化:减少无效同步

  • 批量同步聚合同步请求:短时间内多次写入后,延迟100-300ms触发同步(通过delayMs配置),减少同步次数;

  • 过滤无效变更:在dataChange监听中,对比数据内容是否真的变化,避免因版本号误判导致的重复合并;

  • 控制数据规模:SingleKVStore建议单Key数据不超过10KB,总条目数不超过1000条,超出场景切换至分布式数据库。

3. 可靠性保障:异常处理与校验

  • 幂等性设计:合并逻辑确保多次执行结果一致,避免同步重试导致的数据重复;

  • 数据备份:关键业务数据定期备份至本地文件,避免KVStore同步异常导致的数据丢失;

  • 冲突日志:记录冲突发生时间、Key、本地/远端数据内容,便于问题排查。

五、常见问题与解决方案

1. 冲突监听不触发

  • 排查方向1:权限未授予(需动态申请DISTRIBUTED_DATASYNC权限,尤其是API 11+版本);

  • 排查方向2:订阅类型错误(需使用SUBSCRIBE_TYPE_ALL监听本地和远端变更);

  • 排查方向3:KVStore实例未正确初始化(确保getSingleKVStore调用成功后再注册监听)。

2. 合并后数据再次冲突

  • 解决方案:写入合并数据时生成全局唯一版本号(如Date.now() + 设备ID后缀),避免不同设备生成相同版本号;

  • 补充措施:同步后触发flush强制刷盘,确保数据持久化后再参与下一轮同步。

3. 设备时钟偏差导致LWW策略失效

  • 解决方案:替换时间戳为“版本号+设备优先级”的判定逻辑(如手机优先级高于手表,相同版本号时保留手机数据);

  • 实现方式:在数据结构中增加devicePriority字段,冲突时优先保留优先级高的设备数据。

六、核心总结

分布式KVStore的冲突解决核心在于“先规避、后解决”:通过合理的Key粒度设计和存储模式选择,减少冲突发生概率;针对无法规避的冲突,基于业务场景实现自定义合并逻辑(如数组合并、字段优先级合并),替代默认LWW策略。开发过程中需重点关注版本号管理、同步时机控制和异常日志记录,确保跨设备数据一致性的同时,保障业务数据可靠性。

别再让大模型“胡说八道”了!LangChain 的 JsonOutputParser 教你驯服 AI 输出

2025年12月26日 17:45
当 AI 开始“自由发挥” 你有没有遇到过这样的场景? 你辛辛苦苦写了一堆提示词(prompt),满怀期待地调用大模型,结果它回你一段: 而你真正想要的,只是一个干净、结构化的 JSON!

VueCropper加载OBS图片跨域问题

2025年12月26日 17:30

问题场景

在集成 VueCropper 图片裁剪组件时,遇到一个典型的跨域问题:加载存储在 OBS 上的图片时,浏览器会对同一张图片发起两次请求,最终第二次请求触发跨域错误。以下是问题的完整排查过程与现象梳理:

  1. 请求行为异常:同一张 OBS 图片被浏览器发起两次请求,第一次请求正常响应,第二次请求直接抛出跨域相关错误(如 CORS policy: No 'Access-Control-Allow-Origin' header);
  2. 初步排查方向:初期优先怀疑 OBS 服务器跨域配置缺失,反馈运维核查后,确认 OBS 已正确配置跨域允许规则(含允许当前前端域名、支持 GET 方法等);
  3. 关键测试突破:通过浏览器开发者工具测试发现,开启「停用缓存」功能后,跨域错误消失。据此锁定问题根源与浏览器缓存机制相关;
  4. 核心差异定位:对比两次请求的详细信息,发现跨域标识配置不一致——第一次加载图片未设置 crossOrigin 标识,第二次加载时则添加了该标识;
  5. 问题逻辑闭环:第一次请求因无 crossOrigin 标识,OBS 服务器识别为非跨域请求,未返回跨域响应头;第二次请求虽开启 crossOrigin,但因图片 URL 未变,浏览器直接复用缓存的无跨域响应头资源,导致跨域校验失败。

问题根源

为验证上述排查结论,通过原生 JavaScript 代码复现了问题场景,最终确认问题根源为 跨域策略与浏览器缓存冲突

window.addEventListener('DOMContentLoaded', () => {
    const image = new Image();
    // 第一次加载:未设置 crossOrigin 标识
    image.src = 'https://xxx.png'; 
    image.addEventListener('load', () => {
        console.log('图片加载完成');
        const image2 = new Image();
        // 第二次加载:设置跨域标识(与第一次不一致)
        image2.crossOrigin = "anonymous";
        // 加载同一张图片,触发跨域错误
        image2.src = image.src; 
        image2.addEventListener('load', () => {
            console.log('图片2加载完成');
        });
    });
});

该问题的核心矛盾是「同一张图片的两次请求采用不一致的跨域策略」,结合浏览器缓存机制与 OBS 服务器的跨域响应规则,具体错误逻辑可拆解为两步:

  1. 非跨域模式缓存:第一次加载未设置 crossOrigin,浏览器以「非跨域模式」发起请求(请求头 Sec-Fetch-Mode = no-cors);OBS 服务器识别该模式后,不返回 Access-Control-Allow-Origin 等跨域响应头,浏览器则将这份「无跨域响应头的图片资源」缓存至本地;
  2. 跨域模式缓存冲突:第二次加载设置 crossOrigin: "anonymous",浏览器需以「跨域模式」请求;但因图片 URL 未变,浏览器直接复用本地缓存的无跨域响应头资源,跨域校验无法通过,最终触发错误。

关键结论:同一张图片的所有请求,跨域标识必须完全统一(要么所有请求都设 crossOrigin,要么都不设),否则会因缓存机制导致跨域策略冲突。

解决办法

1. 核心通用方案:统一跨域标识(推荐首选)

思路:统一所有加载该图片的 Image 实例的跨域配置(均设置或均不设置 crossOrigin),确保两次请求的跨域策略一致,从根源避免缓存与跨域规则的冲突。

核心注意点:crossOrigin 必须在 src 赋值 之前 设置。若先赋值 src,浏览器会提前以默认模式发起请求,仍会触发跨域冲突。

正确代码示例:

window.addEventListener('DOMContentLoaded', () => {
  // 第一次加载:提前设置 crossOrigin,统一跨域策略
  const image = new Image();
  image.crossOrigin = "anonymous"; 
  image.src = 'https://xxx.png';

  image.addEventListener('load', () => {
      console.log('图片加载完成');
      // 第二次加载:保持 crossOrigin 配置一致
      const image2 = new Image();
      image2.crossOrigin = "anonymous"; 
      image2.src = image.src;

      image2.addEventListener('load', () => {
          console.log('图片2加载完成');
      });
  });

  // 添加错误监听,便于问题排查
  image.addEventListener('error', (e) => console.error('图片1加载失败:', e));
  image2.addEventListener('error', (e) => console.error('图片2加载失败:', e));
});

适用场景:绝大多数前端场景(包括 VueCropper 等依赖 canvas 的裁剪组件场景),且图片存储端(OBS/CDN)已配置跨域允许规则。

2. 绕过缓存方案:让第二次请求视为「新资源」

思路:若历史代码无法批量修改 crossOrigin 配置,可通过让第二次请求「避开缓存」,迫使浏览器重新向 OBS 服务器发起请求(而非复用旧缓存),从而避免跨域策略冲突。

推荐方案:给图片 URL 添加随机参数(如时间戳),使浏览器识别为「新资源」,触发全新请求:

image.addEventListener('load', () => {
  const image2 = new Image();
  image2.crossOrigin = "anonymous";
  // 追加时间戳参数,避开浏览器缓存
  image2.src = `${image.src}&t=${Date.now()}`; 
  image2.addEventListener('load', () => console.log('图片2加载完成'));
});

适用场景:历史代码架构复杂,无法批量统一 crossOrigin 配置,需快速临时修复跨域问题。

缺点:完全失去缓存优势,每次请求均需重新下载图片,会增加带宽消耗并延长页面加载时间,仅建议临时使用。

3. 后端/存储端方案:从根源消除跨域

思路:通过后端代理或域名绑定,将「跨域图片请求」转为「同源请求」,从根源上消除跨域问题,无需前端额外配置 crossOrigin

具体做法:

  1. 同源代理(推荐):前端请求自身后端的代理接口,由后端代为拉取 OBS 图片并返回给前端。此时前端接收的图片为同源资源,无需任何跨域配置。

示例(Node.js/Express 代理):

// 后端代理代码(Node.js/Express)
const express = require('express');
const axios = require('axios');
const app = express();

// 图片代理接口:接收前端传递的 OBS 图片 URL
app.get('/proxy-image', async (req, res) => {
  const imgUrl = req.query.url;
  try {
      // 后端请求 OBS 图片,以流形式返回给前端
      const response = await axios.get(imgUrl, { responseType: 'stream' });
      response.data.pipe(res);
  } catch (err) {
      res.status(404).send('图片加载失败');
  }
});

app.listen(3000, () => console.log('代理服务启动在 3000 端口'));

前端代码:

// 前端代码:请求后端代理接口,无跨域问题
const image = new Image();
image.src = `http://localhost:3000/proxy-image?url=${encodeURIComponent('https://xxx.png')}`;
image.addEventListener('load', () => console.log('图片加载完成'));

适用场景:前端需大量处理跨域图片(如批量裁剪、像素操作);追求稳定可靠的跨域解决方案,不希望依赖前端代码配置。

优点:彻底解决跨域问题,前端无需任何跨域相关配置;缓存机制可正常生效,不影响加载性能;安全性更高(避免设置 * 允许所有域名跨域)。

在 React 里优雅地 “隐藏 iframe 滚动条”

2025年12月26日 17:27

前端有一个经典问题:

你在宿主页面怎么写 CSS,都管不到 iframe 内部的滚动条。

所以正确的前端方案不是 “给外层容器加 overflow”,而是:尽量在 iframe 自己层面兜底 + 同源时向 iframe 内注入 CSS

本文只聚焦前端实现,不展开前后端传参链路。


1. 为什么你明明设置了,滚动条还是在?

因为滚动条来自 iframe 内部 document

  • 外层 divoverflow-hidden 只能裁剪 “iframe 这个盒子” 是否溢出
  • iframe 里面的页面是否出现滚动条,取决于 iframe 内部的 html/body 或某个容器的 overflow
  • 宿主页面的 CSS 不会跨 document 生效(iframe 天生隔离)

2. 我们最终用了 “两层方案”,解决现实世界的不确定性

实现集中在 src/components/Search/ViewExtensionIframe.tsx:1-88

2.1 第一层:scrolling="no" 作为低成本兜底

<iframe scrolling={hideScrollbar ? "no" : "auto"} />

它不是现代标准,但在某些 WebView/嵌入环境里仍能减少滚动条出现的概率。

它的价值在于:不依赖同源,哪怕你进不去 iframe,也能 “碰一碰运气”。

2.2 第二层(主力):同源时注入一段隐藏滚动条的 CSS

核心是这段逻辑(同文件 applyHideScrollbarToIframe):

  • src/components/Search/ViewExtensionIframe.tsx:5-39

做法很直接:

  1. 拿到 iframe.contentDocument
  2. 往里面塞一个带固定 id<style>
  3. 开关关闭时把这个 <style> 删掉

注入的 CSS 同时覆盖主流引擎:

* {
  scrollbar-width: none;      /* Firefox */
  -ms-overflow-style: none;   /* 老 IE/Edge 风格 */
}
*::-webkit-scrollbar {        /* Chrome/Safari */
  width: 0px;
  height: 0px;
}

为什么用 *

  • 扩展页面的滚动容器不一定是 body,可能是任意 div overflow-auto
  • * 能最大概率“全场隐藏”,更通用

为什么要固定 id

  • 防止重复注入(多次 onLoad / 状态变化)
  • 关闭时能精确移除,保证可逆

3. 为什么要 “onLoad + useEffect” 双触发?

这是最容易漏、也最影响体验的一点。

  • useEffect:响应 hideScrollbar 变化(开关切换时立刻生效)
  • onLoad:保证“首次加载完成后一定注入成功”

原因:iframe 的加载时序不可控。你在 React 渲染完时,iframe 可能还没 ready,contentDocument 为空;等到 onLoad 才能 100% 确认 document 存在。

对应代码:

  • src/components/Search/ViewExtensionIframe.tsx:52-55
  • src/components/Search/ViewExtensionIframe.tsx:78-84

4. 这个方案的边界与坑(提前写清楚,后面少掉头发)

4.1 “隐藏滚动条” 不等于 “不能滚”

我们只隐藏 scrollbar 的视觉表现,滚动行为仍存在(滚轮/触摸板/键盘都能滚)。
这在沉浸式页面很舒服,但在长页面里也可能让用户不知道 “还能滚”。

4.2 跨域 iframe:注入会失败,但不会炸

如果 iframe 加载跨域页面,访问 contentDocument 会触发同源限制。当前实现用 try/catch 静默吞掉异常,结果是:

  • CSS 注入失败
  • 只剩 scrolling="no" 兜底,效果不保证

这不是 bug,是浏览器安全模型决定的。

4.3 全局 * 的副作用

它会把 iframe 内所有滚动条都干掉,包括某些组件内部滚动区域、代码块滚动等。
目前我们选择“通用性优先”,但如果未来某些扩展需要保留局部滚动条,就要改为更精确的选择器策略。


5. 一句话总结

在前端想稳定控制 iframe 滚动条,最靠谱的思路是:

  • 先用 iframe 自身属性做兜底
  • 再在同源条件下对 iframe 内部 document 注入 CSS
  • 用固定 style id 保证幂等与可逆
  • 用 onLoad + effect 解决时序问题

这就是 hideScrollbar 在前端真正解决的问题:不是写没写 CSS,而是有没有把 CSS 写到“对的世界里”。

LangChain Memory 实战指南:让大模型记住你每一句话,轻松打造“有记忆”的AI助手

作者 栀秋666
2025年12月26日 17:15

引言

你有没有遇到过这样的尴尬?
向 AI 提问:“我叫张三”,下一秒再问“我叫什么名字?”——它居然说:“抱歉,我不记得了。” 😅

别怪模型笨,这是所有 LLM 的“先天缺陷”:无状态调用

但今天,我们用 LangChain Memory 给它装上“大脑”,实现真正意义上的多轮对话记忆!

本文将带你从零构建一个会“记事”的智能助手,并深入剖析底层原理、性能瓶颈与生产级优化方案。


🧠 一、为什么你的 AI 总是“金鱼脑”?

1.1 大模型的“失忆症”真相

我们知道,大语言模型(LLM)本质上是一个“黑箱函数”:

response = model(prompt)

每次调用都是独立的 HTTP 请求,没有任何上下文保留机制 —— 就像每次见面都重新认识一遍。

举个例子:

// 第一次对话
await model.invoke("我叫陈昊,喜欢喝白兰地");
// 输出:很高兴认识你,陈昊!看来你喜欢喝白兰地呢~

console.log('------ 分割线 ------');

// 第二次对话
await model.invoke("我叫什么名字?");
// 输出:呃……不太清楚,能告诉我吗?

👉 结果令人崩溃:前脚刚自我介绍,后脚就忘了!

这在实际项目中是不可接受的。无论是客服机器人、教育助手还是个性化推荐系统,都需要记住用户的历史行为和偏好


1.2 解决思路:把“记忆”塞进 Prompt

既然模型不会自己记,那就我们来帮它记

核心思想非常简单:

✅ 每次请求前,把之前的对话历史拼接到当前 prompt 中
✅ 让模型“看到”完整的聊天记录,从而做出连贯回应

这就像是给模型戴上了一副“记忆眼镜”。

而 LangChain 的 Memory 模块,正是对这一过程的高度封装与自动化。


⚙️ 二、LangChain Memory 核心原理拆解

LangChain 提供了一套优雅的 API,让我们可以用几行代码实现“有记忆”的对话系统。

先看最终效果:

User: 我叫陈昊,喜欢喝白兰地
AI: 你好,陈昊!白兰地可是很有品味的选择哦~

User: 我喜欢喝什么酒?
AI: 你说你喜欢喝白兰地呀~是不是准备开一瓶庆祝一下?😉

✅ 成功记住用户名字和饮酒偏好!

下面我们就一步步实现这个功能。


2.1 核心组件一览

组件 作用
ChatMessageHistory 存储对话历史(内存/文件/数据库)
ChatPromptTemplate 定义提示词模板,预留 {history} 占位符
RunnableWithMessageHistory 自动注入历史 + 管理会话生命周期
sessionId 区分不同用户的会话,避免串台

2.2 完整代码实战

// 文件名:memory-demo.js
import { ChatDeepSeek } from "@langchain/deepseek";
import 'dotenv/config';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';

// 1. 初始化模型
const model = new ChatDeepSeek({
    model: 'deepseek-reasoner',
    temperature: 0.3,
});

// 2. 构建带历史的 Prompt 模板
const prompt = ChatPromptTemplate.fromMessages([
    ['system', '你是一个温暖且有记忆的助手,请根据对话历史回答问题'],
    ['placeholder', '{history}'],   // ← 历史消息自动插入这里
    ['human', '{input}']            // ← 当前输入
]);

// 3. 创建可运行链(含调试输出)
const runnable = prompt
    .pipe(input => {
        console.log('\n🔍 最终发送给模型的完整上下文:');
        console.log(JSON.stringify(input, null, 2));
        return input;
    })
    .pipe(model);

// 4. 初始化内存历史存储
const messageHistory = new InMemoryChatMessageHistory();

// 5. 创建带记忆的链
const chain = new RunnableWithMessageHistory({
    runnable,
    getMessageHistory: () => messageHistory,
    inputMessagesKey: 'input',
    historyMessagesKey: 'history'
});

// 6. 开始对话测试
async function testConversation() {
    // 第一轮:告知信息
    const res1 = await chain.invoke(
        { input: "我叫陈昊,喜欢喝白兰地" },
        { configurable: { sessionId: "user_001" } }
    );
    console.log("🤖 回应1:", res1.content);

    // 第二轮:提问历史
    const res2 = await chain.invoke(
        { input: "我叫什么名字?我喜欢喝什么酒?" },
        { configurable: { sessionId: "user_001" } }
    );
    console.log("🤖 回应2:", res2.content);
}

testConversation();

📌 运行结果示例

🤖 回应1: 你好,陈昊!听说你喜欢喝白兰地,真是个有品位的人呢~

🤖 回应2: 你叫陈昊,而且你说你喜欢喝白兰地哦~要不要来点搭配小吃?

🎉 成功!模型不仅记住了名字,还能综合多个信息进行推理回答!


2.3 关键机制图解

              +---------------------+
              |   用户新输入         |
              +----------+----------+
                         |
       +-----------------v------------------+
       |   ChatPromptTemplate              |
       |                                     |
       |   System: 你是有记忆的助手         |
       |   History: [之前的所有对话] ←───────+←─ 从 messageHistory 读取
       |   Human: 我叫什么名字?             |
       +-----------------+------------------+
                         |
                调用模型 → LLM
                         |
           返回响应 ←────+
                         |
       +-----------------v------------------+
       |  自动保存本次交互                  |
       |  user: 我叫什么名字?               |
       |  assistant: 你叫陈昊...            |
       |  写入 InMemoryChatMessageHistory   |
       +------------------------------------+

整个流程全自动闭环,开发者只需关注业务逻辑。


⚠️ 三、真实场景下的三大挑战与破解之道

虽然上面的例子很美好,但在生产环境中你会立刻面临三个“灵魂拷问”:


❌ 挑战1:Token “滚雪球”爆炸增长!

随着对话轮数增加,历史消息越积越多,导致:

  • 单次请求 Token 数飙升
  • 成本翻倍 💸
  • 响应变慢 ⏳
  • 可能超出模型最大长度限制(如 8192)
✅ 解法:选择合适的 Memory 类型
Memory 类型 特点 适用场景
BufferWindowMemory 只保留最近 N 轮 通用对话、短程记忆
ConversationSummaryMemory 自动生成一句话总结 长周期对话、节省 Token
EntityMemory 提取关键实体(人名/偏好) 推荐系统、CRM 助手
示例:使用窗口记忆(保留最近3轮)
import { BufferWindowMemory } from "langchain/memory";

const memory = new BufferWindowMemory({
    k: 3,
    memoryKey: "history"
});

📌 推荐组合:短期细节靠窗口 + 长期特征靠总结


❌ 挑战2:重启服务后历史全丢?!

InMemoryChatMessageHistory 是临时存储,服务一重启,记忆清零。

✅ 解法:持久化到数据库或文件
方案一:本地文件存储(轻量级)
import { FileChatMessageHistory } from "@langchain/core/chat_history";

const getMessageHistory = async (sessionId) => {
    return new FileChatMessageHistory({
        filePath: `./history/${sessionId}.json`
    });
};
方案二:Redis / MongoDB(高并发推荐)
npm install @langchain/redis
import { RedisChatMessageHistory } from "@langchain/redis";

const getMessageHistory = async (sessionId) => {
    return new RedisChatMessageHistory({
        sessionId,
        client: redisClient // 已连接的 Redis 客户端
    });
};

💡 生产环境强烈建议使用 Redis:高性能、支持过期策略、分布式部署无忧。


❌ 挑战3:多人同时聊天会不会串消息?

当然会!如果所有用户共用同一个 messageHistory,就会出现 A 用户看到 B 用户的对话。

✅ 解法:用 sessionId 隔离会话
await chain.invoke(
    { input: "我饿了" },
    { configurable: { sessionId: "user_123" } }  // 每个用户唯一 ID
)

await chain.invoke(
    { input: "我饿了" },
    { configurable: { sessionId: "user_456" } }  // 不同用户,不同历史
)

✅ 安全隔离,互不干扰!


🛠️ 四、Memory 的典型应用场景(附案例灵感)

场景 如何使用 Memory
客服机器人 记住订单号、投诉进度、用户情绪变化
教育辅导 追踪学习章节、错题记录、掌握程度
电商导购 记住预算、品牌偏好、尺码需求
编程助手 保持代码上下文、函数定义、项目结构
心理咨询 理解用户情绪演变、关键事件回顾

💡 创新玩法:结合 SummaryMemory + VectorDB,实现“长期人格记忆”——让 AI 记住你是内向还是外向、喜欢幽默还是严谨。


📘 五、高频面试题(LangChain 方向)

  1. LLM 为什么需要 Memory?它的本质是什么?
  2. Buffer vs Summary Memory 的区别?什么时候用哪种?
  3. 如何设计一个支持百万用户在线的记忆系统架构?
  4. 如何防止敏感信息被 Memory 记录?(安全考量)

📝 结语:让 AI 真正“懂你”,从一次对话记忆开始

“智能不是回答问题的能力,而是理解上下文的艺术。”

LangChain Memory 虽然只是整个 LLM 应用中的一个小模块,但它却是通往拟人化交互的关键一步。

从“无状态”到“有记忆”,我们不只是在写代码,更是在塑造一种新的沟通方式。

未来属于那些能让 AI 记住你、理解你、陪伴你的产品。

而现在,你已经有了打造它的钥匙。


从 iframe 到 Shadow DOM:一次关于「隔离」的前端边界思考

作者 shanLion
2025年12月26日 17:02

一、问题背景:我到底想解决什么?

在复杂前端系统中,我们经常会遇到这样的需求:

  • 页面需要嵌入第三方内容 / 子系统

  • 希望 样式、脚本互不影响

  • 同时又要保证:

    • 正常渲染
    • 合理交互
    • 可控的安全边界

常见方案是 iframe,但一旦深入使用,就会立刻遇到一系列问题:

  • Cookie 是否会被注入?
  • JS 能否互相访问?
  • 样式是否会污染?
  • sandbox 一加,页面怎么直接不显示了?
  • 不加 allow-same-origin 又为什么什么都“坏了”?

这篇文章,就是围绕这些真实问题展开。


二、iframe 的本质:浏览器级别的“硬隔离”

1️⃣ iframe 是什么?

从浏览器角度看,iframe 并不是一个普通 DOM,而是:

一个完整的、独立的浏览上下文(Browsing Context)

它拥有自己的:

  • DOM 树
  • JS 执行环境
  • CSS 作用域
  • Cookie / Storage(是否共享取决于策略)

这也是它“隔离性强”的根本原因。


2️⃣ iframe 天生适合做什么?

  • 微前端子应用
  • 第三方内容嵌入
  • 不可信页面展示
  • 强安全边界场景

它解决的是“不信任”的问题,而不是“优雅”的问题。


三、sandbox:安全从这里开始,也从这里失控

1️⃣ sandbox 到底干了什么?

当你给 iframe 加上:

<iframe sandbox></iframe>

你相当于对它说:

“你什么都不能干。”

包括但不限于:

  • ❌ JS 不执行
  • ❌ 表单提交被禁
  • ❌ 同源身份被剥夺
  • ❌ Cookie / localStorage 全部隔离

2️⃣ 为什么加了 sandbox,页面直接空白?

因为很多页面:

  • 依赖 JS 渲染
  • 依赖同源读取资源
  • 依赖 Cookie 维持状态

一旦 sandbox 默认限制生效,页面逻辑上还能加载,但功能全废


3️⃣ allow-scripts + allow-same-origin 为什么危险?

sandbox="allow-scripts allow-same-origin"

这是一个经典陷阱组合

原因是:

  • allow-scripts:允许 JS 执行
  • allow-same-origin:恢复同源身份

⚠️ 一旦两者同时存在:

iframe 内的 JS 可以认为自己是“正常页面”

从规范角度,它已经具备了逃逸 sandbox 的能力

这也是 MDN 明确标注的安全风险。


四、那我只是不想 Cookie 被注入,怎么办?

❌ 错误直觉

“去掉 allow-same-origin 就好了”

结果是:

  • JS 取不到任何资源
  • 页面渲染失败
  • iframe 内容直接消失

✅ 正确理解

Cookie 注入的本质是:

  • 同源 + 凭证传递

控制 Cookie 的正确方式是:

  • SameSite
  • HttpOnly
  • Secure
  • 服务端鉴权策略

而不是指望 iframe sandbox 去“顺便解决”。


五、Shadow DOM:另一种完全不同的“隔离”

1️⃣ Shadow DOM 隔离的是什么?

Shadow DOM 隔离的是:

  • 样式作用域
  • DOM 结构可见性

但它:

  • ❌ 不隔离 JS
  • ❌ 不隔离 Cookie
  • ❌ 不隔离安全上下文

它解决的是:

组件级的可维护性问题

而不是安全问题。


2️⃣ iframe vs Shadow DOM 对比

维度 iframe Shadow DOM
样式隔离 ✅ 强 ✅ 中
JS 隔离 ✅ 强
安全边界
通信成本
性能 较重
使用复杂度

一句话总结:

iframe 是安全隔离工具
Shadow DOM 是工程隔离工具

❌
❌