AI コーディングエージェントのデスクトップ相棒
Claude Code、Codex、Gemini などのすべてのセッションを、ひとつのネイティブアプリで閲覧・検索・インスペクト。
给我来个markdown渲染压测,要包含svg图片等等,每一种都要包含,不要落盘文件,直接会话里面给我响应就行
{
"command": "printf '%s' '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"160\" height=\"60\"><rect width=\"160\" height=\"60\" rx=\"10\" fill=\"#16a34a\"/><circle cx=\"32\" cy=\"30\" r=\"14\" fill=\"#ffffff\"/><text x=\"96\" y=\"38\" font-size=\"22\" font-family=\"sans-serif\" fill=\"#ffffff\" text-anchor=\"middle\">OK</text></svg>' | base64",
"description": "Base64-encode a small SVG for a data URI"
} {
"command": "printf '%s' '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"240\" height=\"80\" viewBox=\"0 0 240 80\"><defs><linearGradient id=\"g\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"><stop offset=\"0\" stop-color=\"#6366f1\"/><stop offset=\"1\" stop-color=\"#ec4899\"/></linearGradient></defs><rect width=\"240\" height=\"80\" rx=\"12\" fill=\"url(#g)\"/><path d=\"M20 55 L60 25 L100 45 L140 15 L180 40 L220 20\" fill=\"none\" stroke=\"#fff\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>' | base64",
"description": "Base64-encode a gradient chart SVG"
} PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNjAiIGhlaWdodD0iNjAiPjxyZWN0IHdpZHRoPSIxNjAiIGhlaWdodD0iNjAiIHJ4PSIxMCIgZmlsbD0iIzE2YTM0YSIvPjxjaXJjbGUgY3g9IjMyIiBjeT0iMzAiIHI9IjE0IiBmaWxsPSIjZmZmZmZmIi8+PHRleHQgeD0iOTYiIHk9IjM4IiBmb250LXNpemU9IjIyIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZmlsbD0iI2ZmZmZmZiIgdGV4dC1hbmNob3I9Im1pZGRsZSI+T0s8L3RleHQ+PC9zdmc+
PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNDAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCAyNDAgODAiPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzYzNjZmMSIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2VjNDg5OSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSIyNDAiIGhlaWdodD0iODAiIHJ4PSIxMiIgZmlsbD0idXJsKCNnKSIvPjxwYXRoIGQ9Ik0yMCA1NSBMNjAgMjUgTDEwMCA0NSBMMTQwIDE1IEwxODAgNDAgTDIyMCAyMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==
Markdown 渲染压测 v1
目标:把常见/罕见语法全打一遍,观察终端渲染器的降级行为。 约束:不落盘,全部在本条消息里。
0. 目录(锚点/链接测试)
1. 标题层级
H1 一级标题
H2 二级标题
H3 三级标题
H4 四级标题
H5 五级标题
H6 六级标题
Setext H1
Setext H2
2. 行内样式
粗体、斜体、粗斜体、删除线、行内代码、粗体里嵌 代码、斜体里嵌 粗体。
下划线(HTML):带下划线的文字 高亮:高亮文本 上标:E = mc2,xn 下标:H2O,ai 键盘:按 ⌘ + Shift + P 打开命令面板 缩写:API 小字:这是 small 标签
转义测试:*不是斜体* `不是代码` # 不是标题 [不是链接]
硬换行(行尾两空格):
第二行,应该紧贴上一行但换行。
反斜杠换行:
同样换行。
3. 列表全家桶
无序:
- 苹果
- 香蕉
- 芭蕉
- 大蕉
- 更深一层
- 樱桃
有序:
- 第一步
- 第二步
- 子步骤 2.1
- 子步骤 2.2
- 第三步
有序(非 1 起始):
- 从 7 开始
- 然后是 8
- 然后是 9
混合嵌套:
- 外层有序
- 中层无序
- 内层有序
- 最内层无序
code
- 最内层无序
- 内层有序
- 中层无序
- 回到外层
任务列表:
- 已完成项
- 未完成项
- 带
代码的完成项- 嵌套未完成
- 嵌套已完成
定义列表(非 GFM,测降级):
术语 A : 术语 A 的定义说明。
术语 B : 术语 B 的第一条定义。 : 术语 B 的第二条定义。
松散列表(项间空行):
-
第一段
-
第二段
-
第三段
列表项内多段:
-
项目开头
缩进的第二段,属于同一个列表项。
// 列表项里的代码块 const x = 1; -
下一个项目
4. 表格
基础表:
| 列一 | 列二 | 列三 |
|---|---|---|
| a | b | c |
| d | e | f |
对齐表:
| 左对齐 | 居中 | 右对齐 |
|---|---|---|
| left | center | right |
| 长一点的内容 | 中 | 12345 |
code | bold | italic |
宽表(测横向滚动 / 截断):
| ID | 名称 | 类型 | 状态 | 创建时间 | 更新时间 | 负责人 | 描述 |
|---|---|---|---|---|---|---|---|
| 1001 | 用户认证服务 | microservice | healthy | 2024-01-15T08:30:00Z | 2026-09-22T11:45:12Z | alice | 负责 OAuth2 / JWT 签发与校验,依赖 Redis 做 token 黑名单 |
| 1002 | 订单聚合 | batch | degraded | 2024-03-02T00:00:00Z | 2026-09-21T23:59:59Z | bob | 每小时聚合一次订单快照,写入 ClickHouse |
| 1003 | 推送网关 | gateway | down | 2025-11-11T11:11:11Z | 2026-09-22T09:00:00Z | carol | 长连接推送,WebSocket + MQTT 双协议 |
表格内转义竖线:
| 表达式 | 结果 |
|---|---|
a | b | 按位或 |
x || y | 逻辑或 |
表格内换行(HTML <br>):
| 字段 | 说明 |
|---|---|
| name | 第一行 第二行 |
| desc | 含 代码和粗体 |
空单元格与不齐整的列:
| A | B | C |
|---|---|---|
| 1 | 3 | |
| 2 |
5. 代码
行内:npm run build、Array.prototype.map、<div class="x">、a || b、$PATH。
缩进式代码块:
function legacy() {
return "indented code block";
}
围栏(无语言):
plain text, no highlight
保留 空格
Tab 缩进
JavaScript:
// 注释:计算斐波那契
const fib = (n, memo = new Map()) => {
if (n < 2) return n;
if (memo.has(n)) return memo.get(n);
const v = fib(n - 1, memo) + fib(n - 2, memo);
memo.set(n, v);
return v;
};
class Counter {
#count = 0; // 私有字段
static ORIGIN = "0x00";
get value() { return this.#count; }
increment(by = 1) { this.#count += by; return this; }
}
console.log(`fib(40) = ${fib(40)}`);
export default Counter;
TypeScript:
interface User<T extends string = string> {
id: number;
name: T;
roles: ReadonlyArray<"admin" | "user">;
meta?: Record<string, unknown>;
}
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function fetchUser(id: number): Promise<Result<User>> {
try {
const res = await fetch(`/api/users/${id}`);
return { ok: true, value: (await res.json()) as User };
} catch (error) {
return { ok: false, error: error as Error };
}
}
Python:
from dataclasses import dataclass, field
from typing import Iterator
@dataclass(slots=True)
class Point:
x: float
y: float
tags: list[str] = field(default_factory=list)
def __iter__(self) -> Iterator[float]:
yield self.x
yield self.y
def primes(limit: int) -> list[int]:
"""埃拉托斯特尼筛法"""
sieve = bytearray([1]) * (limit + 1)
sieve[0:2] = b"\x00\x00"
for i in range(2, int(limit ** 0.5) + 1):
if sieve[i]:
sieve[i * i :: i] = bytearray(len(sieve[i * i :: i]))
return [i for i, ok in enumerate(sieve) if ok]
if __name__ == "__main__":
print(f"{len(primes(1_000_000))} primes below 1e6")
Bash:
#!/usr/bin/env bash
set -euo pipefail
readonly ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
trap 'echo "failed at line $LINENO" >&2' ERR
for f in "$ROOT"/src/**/*.ts; do
[[ -f "$f" ]] || continue
printf '%s\t%s\n' "$(wc -l < "$f")" "${f##*/}"
done | sort -rn | head -n 10
Go:
package main
import (
"context"
"errors"
"fmt"
"time"
)
var ErrTimeout = errors.New("timeout")
func worker(ctx context.Context, jobs <-chan int, out chan<- int) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case j, ok := <-jobs:
if !ok {
return nil
}
select {
case out <- j * j:
case <-time.After(100 * time.Millisecond):
return fmt.Errorf("job %d: %w", j, ErrTimeout)
}
}
}
}
Rust:
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
enum Shape { Circle(f64), Rect { w: f64, h: f64 } }
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rect { w, h } => w * h,
}
}
}
fn main() {
let shapes = vec![Shape::Circle(1.0), Shape::Rect { w: 3.0, h: 4.0 }];
let total: f64 = shapes.iter().map(Shape::area).sum();
println!("total = {total:.3}");
}
SQL:
WITH ranked AS (
SELECT
user_id,
order_id,
amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days'
)
SELECT u.name, r.order_id, r.amount
FROM ranked r
JOIN users u ON u.id = r.user_id
WHERE r.rn = 1
ORDER BY r.amount DESC
LIMIT 10;
JSON / YAML / TOML:
{
"name": "acme",
"version": "2.4.1",
"deps": { "react": "^19.0.0" },
"scripts": { "build": "tsc -b && vite build" },
"keywords": ["log", "cli", "分析"],
"private": true
}
apiVersion: apps/v1
kind: Deployment
metadata:
name: acme-api
labels: { app: acme, tier: backend }
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: registry.example.com/acme:2.4.1
resources:
limits: { cpu: "1", memory: 512Mi }
env:
- name: LOG_LEVEL
value: "info"
[package]
name = "acme"
version = "2.4.1"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
[profile.release]
lto = true
Diff:
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -12,7 +12,9 @@ impl Parser {
- pub fn parse(&self, s: &str) -> Option<Ast> {
- self.inner(s).ok()
+ pub fn parse(&self, s: &str) -> Result<Ast, ParseError> {
+ self.inner(s).map_err(|e| e.with_span(self.span(s)))
}
}
超长行(测横向滚动 / 自动换行):
const veryLongLine = ["alpha","bravo","charlie","delta","echo","foxtrot","golf","hotel","india","juliet","kilo","lima","mike","november","oscar","papa","quebec","romeo","sierra","tango","uniform","victor","whiskey","xray","yankee","zulu"].map(x => x.toUpperCase()).filter(x => x.length > 4).join(" | ");
无语言但带标题/元信息的围栏:
2026-09-22T10:00:01Z INFO server started on :8080
2026-09-22T10:00:02Z WARN cache miss ratio 0.42
2026-09-22T10:00:03Z ERROR failed to connect to redis: dial tcp 10.0.0.7:6379: i/o timeout
四反引号围栏(内含三反引号):
```js
console.log("嵌套围栏");
```
6. 引用
单层引用。 第二行,同一段。
引用里的 格式、
代码、链接。
第一层
第二层
第三层
第四层,还能更深吗
引用里的标题
- 引用里的列表
- 第二项
// 引用里的代码块
A B 1 2
[!NOTE] GFM 警报:提示信息。
[!TIP] GFM 警报:技巧。
[!IMPORTANT] GFM 警报:重要。
[!WARNING] GFM 警报:警告。
[!CAUTION] GFM 警报:危险。
引用 + 列表 + 代码混合:
- 有序项
项内引用
更深引用
7. 图片与 SVG
7.1 远程图片(需要网络,可能不加载)

7.2 内联 SVG(原始 <svg> 标签,多数终端渲染器会忽略或原样显示)
7.3 SVG Data URI(base64)
7.4 SVG Data URI(utf8 未编码,测降级)

7.5 带 title 的图片

7.6 引用式图片 + 引用式链接

7.7 徽章( shields.io 风格)
7.8 HTML <img> 带尺寸
7.9 ASCII 图(终极降级方案)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │─────▶│ Gateway │─────▶│ Service │
└─────────────┘ └──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Redis │ │ PostgreSQL │
└─────────────┘ └─────────────┘
8. 数学公式
行内:质能方程 E = mc2,以及 ∑i=1n i = n(n+1)2。
块级:
∫−∞∞ e−x2dx = √π
∇ ⋅ E= ρε0∇ ⋅ B= 0∇ × E= −∂ B∂ t∇ × B= μ0 J + μ0ε0 ∂ E∂ t
矩阵:
A = (a11a12a21a22), det(A) = a11a22 − a12a21
非公式的美元符号(测误判):这件商品 $9.99,那件 $19.99,一共 $29.98。
9. 图表
9.1 Mermaid 流程图
9.2 Mermaid 时序图
9.3 Mermaid 甘特图
9.4 Mermaid 类图
9.5 Mermaid 饼图 / 状态图
9.6 纯文本柱状图
Q1 ████████████░░░░░░░░ 60%
Q2 ██████████████████░░ 90%
Q3 ██████░░░░░░░░░░░░░░ 30%
Q4 ████████████████████ 100%
10. HTML 内联
第二行
折叠内容:
- 列表项一
- 列表项二
console.log("折叠里的代码");
| A | B |
|---|---|
| 1 | 2 |
HTML 表格:
| 名称 | 值 |
|---|---|
| alpha | 1 |
| beta | 2 |
HTML 注释(应完全不可见):
其他标签:inline code via html、b、i、s、引用标签
、引用来源、术语定义、、漢字、进度条 、
11. 边界与变态用例
11.1 超长不可断字符串(URL)
11.2 自动链接
裸链接:https://github.com/anthropics/claude-code 尖括号:https://github.com/anthropics/claude-code 邮箱:noreply@example.com 自定义文本:Claude Code 仓库
11.3 引用式链接
11.4 脚注
11.5 Emoji 与 Unicode
表情:😀 🎉 🚀 ✅ ❌ ⚠️ 🔥 💡 📊 🧪 🐛 短代码::smile: :tada: :rocket: :white_check_mark: :x: ZWJ 序列:👨👩👧👦 👩💻 🧑🚀 🏳️🌈 肤色修饰:👍🏻 👍🏽 👍🏿 国旗:🇨🇳 🇺🇸 🇯🇵 🇩🇪 🇫🇷 符号:→ ← ↑ ↓ ⇒ ⇔ ∀ ∃ ∈ ∉ ⊂ ⊃ ∪ ∩ ∑ ∏ √ ∞ ≠ ≤ ≥ ± × ÷ ° µ Ω 框线:┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼ ═ ║ ╔ ╗ ╚ ╝ 块元素:░ ▒ ▓ █ ▄ ▀ ▌ ▐ 中文标点:「引号」、『双引号』、《书名号》、——破折号……省略号。 全角:ABC123,全角空格【 】。 零宽字符:a\u200Bb(中间有零宽空格) 组合字符:é(e + U+0301)、ñ(n + U+0303)、Ω(Ω + U+0301) 数学字母:𝔸𝔹ℂ 𝓪𝓫𝓬 𝐀𝐁𝐂 𝟏𝟐𝟑 罕见 CJK:龘靐齉爩鱻麤龗灪 RTL:مرحبا بالعالم / שלום עולם 混合 BiDi:这段是中文 English 123 العربية 混合排版。
11.6 超深嵌套列表(测缩进溢出)
- L1
- L2
- L3
- L4
- L5
- L6
- L7
- L8
- L9
- L10 到底了
- L9
- L8
- L7
- L6
- L5
- L4
- L3
- L2
11.7 连续分隔线变体
11.8 空元素与空白
11.9 特殊字符攻击(不应破坏渲染)
<script>alert(1)</script> 应该原样显示。
<img src=x onerror=alert(1)> 转义后应原样显示。
| 表格里的 | 竖线 | 第二列 |
|---|---|
| `a | b` |
11.10 超长表格(50 行,测虚拟滚动/性能)
| # | 名称 | 状态 |
|---|---|---|
| 1 | item-001 | ok |
| 2 | item-002 | ok |
| 3 | item-003 | warn |
| 4 | item-004 | ok |
| 5 | item-005 | fail |
| 6 | item-006 | ok |
| 7 | item-007 | ok |
| 8 | item-008 | warn |
| 9 | item-009 | ok |
| 10 | item-010 | ok |
| 11 | item-011 | ok |
| 12 | item-012 | fail |
| 13 | item-013 | ok |
| 14 | item-014 | ok |
| 15 | item-015 | warn |
| 16 | item-016 | ok |
| 17 | item-017 | ok |
| 18 | item-018 | ok |
| 19 | item-019 | fail |
| 20 | item-020 | ok |
| 21 | item-021 | ok |
| 22 | item-022 | ok |
| 23 | item-023 | warn |
| 24 | item-024 | ok |
| 25 | item-025 | ok |
| 26 | item-026 | ok |
| 27 | item-027 | fail |
| 28 | item-028 | ok |
| 29 | item-029 | ok |
| 30 | item-030 | warn |
| 31 | item-031 | ok |
| 32 | item-032 | ok |
| 33 | item-033 | ok |
| 34 | item-034 | fail |
| 35 | item-035 | ok |
| 36 | item-036 | ok |
| 37 | item-037 | ok |
| 38 | item-038 | warn |
| 39 | item-039 | ok |
| 40 | item-040 | ok |
| 41 | item-041 | ok |
| 42 | item-042 | fail |
| 43 | item-043 | ok |
| 44 | item-044 | ok |
| 45 | item-045 | warn |
| 46 | item-046 | ok |
| 47 | item-047 | ok |
| 48 | item-048 | ok |
| 49 | item-049 | fail |
| 50 | item-050 | ok |
压测完成
| 类别 | 覆盖项 | 常见降级 |
|---|---|---|
| 标题 | ATX 1–6 + Setext | 通常完整 |
| 行内 | 粗/斜/删/码/上下标/kbd/mark | <mark> <u> 常被忽略 |
| 列表 | 有序/无序/任务/定义/深嵌套 | 定义列表常降级为普通段落 |
| 表格 | 对齐/宽表/HTML 表 | 宽表横向截断 |
| 代码 | 围栏/缩进/嵌套围栏/diff/超长行 | 语法高亮常缺失 |
| 引用 | 多层嵌套 + GFM 警报 | 警报常降级为普通引用 |
| 图片 | 远程/内联 SVG/base64/utf8/HTML img | SVG 与图片多数终端不渲染 |
| 数学 | 行内/块级/矩阵 | 多数终端原样输出 LaTeX |
| 图表 | Mermaid 六种 + ASCII | 多数终端原样输出源码 |
| HTML | details/summary/kbd/ruby/progress | 折叠块常失效 |
| 边界 | 零宽/组合字符/ZWJ/RTL/超长串 | 字宽计算错位 |
以上全部在会话内输出,未写入任何文件。
Footnotes
The cache in src/cache/store.ts is a module-level Map, so every
request handled by the same worker shares it. Since Tuesday's deploy we keep seeing one
tenant's rows come back for another tenant under load. Can you scope it per request without
losing the warm start?
What is actually shared
The store is built once, the first time the module is imported, and the runtime keeps that instance for the life of the process. The key is derived from the query alone, so two requests that run the same query land on the same entry no matter who asked.
// src/cache/store.ts
const cache = new Map<string, Row[]>(); // one instance per process
export async function getRows(tenantId: string, query: Query) {
const key = hash(query); // tenantId never reaches the key
const hit = cache.get(key);
if (hit) return hit;
const rows = await db.rows(tenantId, query);
cache.set(key, rows);
return rows;
}A module-level cache is shared by every concurrent request in the process. The warm start comes from the process, not from the module — so moving the store onto the request costs nothing.
Plan
- Create the store lazily on the request context instead of at import time.
-
Fold
tenantIdinto the cache key so entries can never cross tenants. - Keep the process-level store as a read-through tier, keyed by tenant and query.
- Add a regression test that interleaves two tenants on the same query.
{
"description": "Find every place the cache is read",
"prompt": "Search src/cache and src/api for reads of the module-level cache map and report each call site."
} src/cache/store.ts:12 module-level Map, written and read by getRows() src/api/projects.ts:41 getRows(tenantId, query) — tenantId from the session src/api/search.ts:63 getRows(tenantId, query) — query built from the request body
{
"file_path": "src/cache/scope.ts",
"old_string": "return req.ctx.cache;",
"new_string": "return (req.ctx.cache ??= new Map<string, Row[]>());"
} export function scopeFor(req: Request): Map<string, Row[]> {
const existing = req.ctx.cache;
if (existing) return existing;
return (req.ctx.cache = new Map<string, Row[]>());
} {
"command": "npm test -- src/cache"
} $ npm test -- src/cache ✓ src/cache/store.test.ts (9) ✓ src/cache/scope.test.ts (3) Tests 12 passed (12)
Both suites are green, including the new interleave test. The store now lives on the request context, so a warm process still reuses work inside a single request but never shares it across tenants.
公共网关这周被同一个 IP 刷挂了两次,日志里一秒几百个请求。想先按 IP 限流,后面可能还要按 API key 分档。用现成的库还是自己写一个中间件?
{
"command": "rg -n \"Use\\(|middleware\" internal/http/router.go",
"description": "List the middleware registered on the HTTP router"
} 18: r.Use(RequestID) 19: r.Use(Recover(log)) 20: r.Use(AccessLog(log)) 21: r.Mount("/v1", v1Routes(cfg)) 结论
自己写中间件,不引库。按 IP 限流用令牌桶,计数放 Redis——网关是 3 副本,计数留在进程内存里等于把阈值放大三倍。
取 IP 有两个坑
- 网关前面还有一层 LB,
r.RemoteAddr拿到的是 LB 的地址,得看X-Forwarded-For; X-Forwarded-For是客户端可以伪造的,所以只信最后一跳:从右往左找第一个不在可信网段里的地址,找不到就退回RemoteAddr。
中间件
func RateLimit(store *redis.Client, cfg LimitConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := "rl:ip:" + clientIP(r)
ok, retryAfter := allow(r.Context(), store, key, cfg)
if !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())))
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
还没定的两件事
- 按 API key 分档:key 从
Authorization头里取,桶容量走配置,与 IP 桶分开计数,别共用一个 key 前缀; - 超限之后:现在只回 429,不带剩余额度。如果客户端是重试风暴,响应里最好带上
X-RateLimit-Remaining,让它自己退避。
Redis 用 EVALSHA 跑令牌桶,取桶与扣减在一次 Lua 里完成,否则两个副本同时读到「还剩 1 个」会一起放行。
仪表盘首屏要 4 秒多,本地库只有几百行数据,我怀疑是 N+1。怎么快速确认是哪一段?
{
"command": "psql \"$DATABASE_URL\" -c \"select calls, round(total_exec_time) as ms, left(query, 60) from pg_stat_statements order by calls desc limit 5\"",
"description": "List the queries with the most calls from pg_stat_statements"
} 14208 | 38120 | select * from projects where owner_id = $1
定位
先打开 pg_stat_statements,按调用次数排前五:
select calls, round(total_exec_time) as ms, left(query, 60)
from pg_stat_statements order by calls desc limit 5;
select * from projects where owner_id = $1 调了 14208 次,而首屏一共只渲染 24 个项目——每个项目一条,N+1 坐实了。
修法
把循环里的单条查询换成一次 IN,再在内存里 join:
const ids = rows.map((r) => r.ownerId);
const owners = await db.query('select * from owners where id = any($1)', [ids]);
const byId = new Map(owners.map((o) => [o.id, o]));
一起改的三处
src/dashboard/load.ts里的循环查询——改成批量取;- 同一份 owner 数据在三个组件里各查一次,用
DataLoader在请求级做去重(按 id 缓存,别跨请求复用,否则会把 A 的数据发给 B); - 加一条回归断言:单次渲染的 SQL 条数不超过 12 条,超了就失败。
改完首屏 4.2s → 0.6s,SQL 条数 240 → 9。剩下的 9 条里有 4 条是权限校验,可以缓存,先没动。
moment.js 让首屏包大了 70 多 KB,想换成 date-fns。项目里其实只用到几个 API,怎么改最省事?
先盘一遍用到的地方
rg -o "moment\([^)]*\)\.\w+" src/ | sed 's/.*\.//' | sort | uniq -c
出来只有 6 种用法:格式化、加减天数、比较先后、取当月首日、时区转换、相对时间。没有用 locale 的复杂分支,替换面很小。
对照表
| moment | date-fns |
|---|---|
moment().format('YYYY-MM-DD') | format(new Date(), 'yyyy-MM-dd') |
moment(x).add(7, 'days') | addDays(x, 7) |
moment(x).isAfter(y) | isAfter(x, y) |
moment(x).startOf('month') | startOfMonth(x) |
moment(x).fromNow() | formatDistanceToNow(x, { addSuffix: true }) |
两个会静默出错的地方
- 格式串的 token 大小写不一样:
YYYY→yyyy、DD→dd。直接全局替换,DD(一年中的第几天)会顶掉dd(几号),页面上不报错,日期就是错的; - moment 对象可变,date-fns 全是纯函数。
const end = start.add(7, 'days')这种写法在 moment 里改的是start本身,换成addDays后start不动了——如果原来的代码依赖这个副作用,行为会变。
时区那块先留着 moment-timezone 的两个调用,date-fns-tz 的 API 形状差得比较多,单独一个 PR 改。
入门文档现在三千多字,新人反馈读到第三步就卡住了。想重写一版,怎么组织比较好?
建议的结构
现在的文档是「先讲清楚再动手」,新人读到第三步就断了。改成边做边读,四段就够:
- 一句话说清它是干什么的——不要从架构讲起,架构留到第四段;
- 跑起来——三条命令,复制粘贴就能看到结果,中间不解释原理;
- 改一处再看结果——让读者第一次感受到反馈闭环,这一步比前面两步都重要;
- 接下来去哪——把原来的架构说明拆成链接,想深入的人自己点。
第 2 段照这个写
git clone <repo> && cd <repo>
npm install
npm run dev # 打开 http://localhost:4321
三条命令各自只配一句话说明「它做了什么」,不写「为什么要装依赖」这种读者此刻不关心的话。
第 3 段挑一个最小改动
选一个改完立刻看得见的:比如把首页标题改掉,刷新页面就能看到。不要一上来就让读者改配置或加路由,那种改动要么不生效、要么生效在别的地方,新人分不清是自己错了还是文档错了。
删掉的东西
- 开头那段「本指南适合谁」——读者已经在读了,不用再介绍一遍;
- 环境要求里列了 6 种包管理器,只留 npm 一种,其余放脚注;
- 截图全部换掉:现在的截图还是旧版界面,颜色和布局都对不上。
把昨天迁移复盘的记录整理成一段纪要,要能直接发群里,别太长。
迁移复盘纪要(2 月 17 日)
结果:数据迁移完成,停机 42 分钟,比计划多 12 分钟。回滚预案没有启用。
做对了什么
- 提前一周做了全量演练,演练脚本与正式脚本是同一份,只换了目标库的连接串;
- 迁移前把写入流量切到只读,省掉了「迁移过程中新写入的数据对不上」这一类问题;
- 每一步都有校验点:行数、金额合计、最近 7 天的抽样比对,前一步不过就不走下一步。
踩了什么坑
- 索引建晚了:数据灌完才建索引,多花了 12 分钟——应该在只读切换前就把索引建好,灌数据的同时索引一起长;
- 连接池没调:迁移脚本用了应用的默认连接池(10 个),实际只有 2 个在干活,其余时间在等锁。单独给迁移脚本配 32 个连接能再快一截;
- 监控看错了面板:值班同学盯着应用的错误率,没注意迁移脚本自己的进度指标,中途有一次「看起来卡住了」的误判。
下次怎么改
- 迁移脚本的索引与数据分开两步,索引提前建;
- 迁移期间单独开一个面板,只放进度、剩余行数、当前速率三个指标;
- 停机窗口按演练耗时的 1.5 倍申请,这次按 1.2 倍申请,实际差了 12 分钟。
遗留
旧的只读副本还没下线,先留一周做比对,下周三回收。
每周五都要手动汇总各项目的进度,来回翻看板和 issue,能不能自动化?
{
"command": "node scripts/digest.mjs --since 2026-09-12 --until 2026-09-18 --dry-run",
"description": "Dry-run the weekly status digest for last week"
} {
"projects": 4,
"done": 23,
"blocked": 2,
"window": "2026-09-12..2026-09-18",
"files": 0
} 脚本
// scripts/digest.mjs
const { since, until, dryRun } = parseArgs(process.argv); // --since / --until,缺省是最近 7 天
const projects = await listProjects();
for (const p of projects) {
const [done, blocked] = await Promise.all([
issues({ project: p, state: 'closed', since, until }),
issues({ project: p, label: 'blocked' }),
]);
print(p.name, { done: done.length, blocked: blocked.map((i) => i.title) });
}
if (!dryRun) writeReport({ projects, since, until });
跑一次是 node scripts/digest.mjs --since <开始> --until <结束>,加 --dry-run 只打印不写文件;不传 --until 就是到今天为止。
定时的两种做法
- 系统定时任务:周五 16:00 跑一次,窗口取上周六到本周五,输出写到
reports/digest-<date>.md,简单但只在跑脚本的那台机器上生效; - 交给调度器:同一份脚本包一层,失败会重试、有历史记录,代价是要维护一个调度任务。
先按第一种来,脚本本身不依赖运行环境,以后要换随时能换。
输出模板
固定三段,抓不到的部分留空行让人补:
- 本周完成——按项目分组,每行一条 issue 标题;
- 阻塞——带
blocked标签的,写清楚卡在谁那里; - 下周计划——不自动生成,留给本人填。
模板固定下来之后,每周实际要动手的部分只剩第三段,这才是省时间的地方。
CI 从上周起要跑 22 分钟,本地全量测试只要 4 分钟。能拆成几个分片并行跑吗?
{
"command": "npx vitest run --reporter=json --outputFile=timing.json && node scripts/slowest.mjs timing.json",
"description": "Collect per-file test timings and rank the slowest"
} 4m12s total · 1284 tests · slowest: editor.test.ts 58s / router.test.ts 41s / parser.test.ts 33s / store.test.ts 29s 8 slowest files = 61% of total time
先看时间花在哪
本地跑一遍,把每个文件的耗时导出来:
npx vitest run --reporter=json --outputFile=timing.json
node scripts/slowest.mjs timing.json
一共 4 分 12 秒、1284 个用例,但最慢的 8 个文件就占了 61%——editor.test.ts 一个文件 58 秒。
这说明时间不是被「用例多」吃掉的,是被少数几个大文件吃掉的。
分片要按耗时切,不按文件数
按文件数均分是最容易踩的坑:4 片各 30 个文件,看着很公平,但最慢的那片还是带着 editor.test.ts,
整体时长由最慢的一片决定,省不下来。按累计耗时贪心装箱,4 片的预算是 63 秒 / 62 秒 / 61 秒 / 60 秒,
几乎齐平。
CI 侧用矩阵跑:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx vitest run --shard=${{ matrix.shard }}/4
并行前必须先排掉共享状态
分片是在同一台机器上并发跑的,下面两处会互相踩,得先改:
src/test/setup.ts里的临时目录写死在/tmp/app-test,四个分片同时往里写;改成按分片号加后缀;- 有三个用例依赖同一个固定端口起本地服务,改成
listen(0)让内核分配,再从address()取回实际端口。
改完跑了一遍并发压测(4 个分片同时跑 20 次),没有出现 flake。
结果
CI 从 22 分钟降到 7 分钟,其中装依赖与构建仍占 3 分钟——那部分靠分片省不了, 下一步如果还要压,得从缓存下手,那是另一件事。
本地一直好好的,推到 CI 就挂在 Node 版本上。怎么让两边环境对齐,别每次都在这里耗?
{
"command": "node -v && cat .nvmrc 2>/dev/null || echo 'no .nvmrc'; grep -n '\"engines\"' package.json || echo 'no engines'",
"description": "Compare the local Node version against the CI setup"
} v22.11.0 no .nvmrc no engines (CI 日志里的 setup 步骤用的是镜像默认的 v20.9.0)
现状
本地 v22.11.0,CI 用的是镜像默认的 v20.9.0,而仓库里一处都没声明过版本——
没有 .nvmrc,package.json 里也没有 engines。也就是说 CI 的版本是「碰巧」是 20,
哪天镜像换默认版本,它自己就漂了。
让版本只有一个来源
三处都指向同一个文件:
# .nvmrc
22.11.0
// package.json
"engines": { "node": ">=22.11.0" }
# CI
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
.nvmrc 给本地(nvm use 直接生效),engines 给「装错版本时至少有个警告」,
CI 的 node-version-file 读的是同一份——以后升版本只改 .nvmrc 一处。
顺手把包管理器也钉住
同一个漂移问题在 npm 上也成立:本地 10.x、CI 9.x,锁文件格式不同,npm ci 会直接失败。
package.json 里加一行 "packageManager": "npm@10.9.0" 就够,CI 不需要额外步骤。
还要补一道门
版本对齐之后,两边仍可能因为「本地装过、CI 没装」而结果不同。CI 上补一步
npm ci --ignore-scripts 之后跑一次 npm ls --all 的校验,装不全就直接失败,
比等测试跑一半报模块找不到要省时间。
结果
三处改完之后重跑,CI 上的失败消失;把 .nvmrc 改成 20 再跑一次,CI 如实复现了本地的失败——
说明两边现在读的确实是同一份版本声明。
auth 中间件里有三段几乎一样的 token 解析,想抽成一个公共函数。抽的时候要注意什么?
{
"command": "go test ./internal/auth/... -count=1",
"description": "Run the auth package tests without cache"
} ok internal/auth 0.412s (18 tests, 0 failures)
抽成什么样
三段重复的其实是同一件事:从 Authorization 头取出 token、解析、把结果放进 context。抽成一个 authenticate,三种调用方式各包一层薄的:
type Result struct {
Subject string
Scopes []string
}
func authenticate(next func(http.ResponseWriter, *http.Request, Result)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
res, err := parseBearer(r.Header.Get("Authorization"))
if err != nil {
writeAuthError(w, err) // 401 / 403 的判定只在这一处
return
}
next(w, r, res)
})
}
抽的时候要注意
- 错误语义别丢:现在三段里有两段把「没带 token」和「token 过期」都回 401,第三段回的是 403。合并前先确认哪种是对的,别顺手把差异抹平——客户端可能正靠这个区分「去登录」和「去申请权限」;
- 日志里的调用方标识:有一段会往日志里写
sub,另两段不写。合并后统一写,但字段名要与已有的对齐,否则看板的查询要跟着改; - 顺序:中间件在链上的位置不一样,其中一段挂在路由级、两段挂在全局。抽出来后位置保持原样,不要「顺便统一」。
测试先跑一遍再动手,改完再跑一遍,两次数目一致才算没漏。
线上偶发 401:并发请求同时刷新 token 时只有一个成功,其余拿着旧 refresh token 全被打回。这个怎么修?
根因
refresh token 是一次性轮换的:换新的同时旧的作废。并发场景下两个请求带着同一个 refresh token 过来,第一个换走了,第二个撞上「已作废」,于是回 401——客户端看到的就是偶发掉线。
客户端先收敛成单飞
同一时刻只允许一个刷新在飞,其余请求等它的结果:
let inflight: Promise<Tokens> | null = null;
export function refresh(): Promise<Tokens> {
inflight ??= doRefresh().finally(() => {
inflight = null;
});
return inflight;
}
关键在 finally 里清空:失败也要清,否则一次网络抖动会让后面所有请求都拿到同一个失败的 Promise。
服务端还得容忍重放
单飞只挡住了同一个进程内的并发。网络重试、多标签页、移动端切前后台都会造成同一个旧 token 被用两次,所以服务端要留一个宽限窗口:旧 token 在换掉后的一小段时间内再用一次,返回同一个新 token,而不是直接判失效。窗口取 30 秒就够——比任何一次重试的间隔都长,又短到不至于让被窃取的 token 一直能用。
两边都改完,压测里并发刷新的 401 从每千次 7 次降到 0。
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run test -- --coverage
- run: npm run build
env:
VITE_API_BASE: https://api.acme.dev
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="8" fill="#3d7af6" />
<path
d="M10 21.5 15.2 10h1.9l5.2 11.5h-2.6l-1.1-2.6h-5.1l-1.1 2.6H10Z
m4.4-4.7h3.5L16.1 13l-1.7 3.8Z"
fill="#ffffff"
/>
</svg>
#!/usr/bin/env bash
set -euo pipefail
PORT="${PORT:-5173}"
API_BASE="${API_BASE:-http://localhost:8787}"
if [ ! -d node_modules ]; then
echo "installing dependencies..."
npm ci
fi
echo "starting dev server on :$PORT (api: $API_BASE)"
VITE_API_BASE="$API_BASE" npm run dev -- --port "$PORT" --strictPort
<script setup lang="ts">
import { computed } from 'vue';
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md';
const props = withDefaults(
defineProps<{
variant?: Variant;
size?: Size;
disabled?: boolean;
loading?: boolean;
}>(),
{ variant: 'primary', size: 'md', disabled: false, loading: false },
);
const emit = defineEmits<{ (e: 'click', ev: MouseEvent): void }>();
const classes = computed(() => [
'btn',
`btn-${props.variant}`,
`btn-${props.size}`,
]);
function onClick(ev: MouseEvent) {
if (props.disabled || props.loading) return;
emit('click', ev);
}
</script>
<template>
<button
type="button"
:class="classes"
:disabled="disabled || loading"
@click="onClick"
>
<span v-if="loading" class="btn-spinner" aria-hidden="true" />
<slot />
</button>
</template>
<style scoped>
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid transparent;
border-radius: 6px;
font: inherit;
cursor: pointer;
}
.btn-md {
height: 32px;
padding: 0 14px;
font-size: 13px;
}
.btn-sm {
height: 26px;
padding: 0 10px;
font-size: 12px;
}
.btn-primary {
background: #3d7af6;
color: #ffffff;
}
.btn-secondary {
border-color: #e3e7ee;
background: #ffffff;
color: #1d1d1f;
}
.btn-ghost {
background: transparent;
color: #48484a;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-spinner {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
}
</style>
<script setup lang="ts">
import { computed, ref } from 'vue';
interface Column {
key: string;
label: string;
width?: number;
align?: 'left' | 'right';
}
const props = defineProps<{
rows: Record<string, unknown>[];
columns: Column[];
pageSize?: number;
}>();
const page = ref(0);
const pageSize = computed(() => props.pageSize ?? 25);
const pageCount = computed(() =>
Math.max(1, Math.ceil(props.rows.length / pageSize.value)),
);
const visibleRows = computed(() => {
const start = page.value * pageSize.value;
return props.rows.slice(start, start + pageSize.value);
});
function goTo(next: number) {
page.value = Math.min(Math.max(next, 0), pageCount.value - 1);
}
</script>
<template>
<div class="data-table">
<table>
<thead>
<tr>
<th
v-for="col in columns"
:key="col.key"
:style="{ width: col.width ? col.width + 'px' : undefined }"
>
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in visibleRows" :key="i">
<td
v-for="col in columns"
:key="col.key"
:style="{ textAlign: col.align }"
>
<slot :name="col.key" :row="row">{{ row[col.key] }}</slot>
</td>
</tr>
</tbody>
</table>
<footer v-if="pageCount > 1">
<span>{{ page + 1 }} / {{ pageCount }}</span>
<button type="button" :disabled="page === 0" @click="goTo(page - 1)">
Prev
</button>
<button
type="button"
:disabled="page >= pageCount - 1"
@click="goTo(page + 1)"
>
Next
</button>
</footer>
</div>
</template>
<style scoped>
.data-table table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table th,
.data-table td {
padding: 8px 10px;
border-bottom: 1px solid #eef1f6;
}
.data-table th {
font-weight: 600;
color: #48484a;
background: #f8fafc;
}
.data-table footer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
color: #6e6e73;
font-size: 12px;
}
</style>
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue';
const props = defineProps<{
open: boolean;
title?: string;
closeOnBackdrop?: boolean;
}>();
const emit = defineEmits<{ (e: 'close'): void }>();
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') emit('close');
}
function onBackdrop() {
if (props.closeOnBackdrop !== false) emit('close');
}
watch(
() => props.open,
(open) => {
if (open) document.addEventListener('keydown', onKeydown);
else document.removeEventListener('keydown', onKeydown);
},
{ immediate: true },
);
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown));
</script>
<template>
<Teleport to="body">
<div v-if="open" class="modal-backdrop" @click.self="onBackdrop">
<div class="modal" role="dialog" aria-modal="true">
<header>
<h2>{{ title }}</h2>
<button
type="button"
class="modal-close"
aria-label="Close"
@click="emit('close')"
>
×
</button>
</header>
<div class="modal-body">
<slot />
</div>
<footer>
<slot name="actions" />
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: rgb(15 23 42 / 0.45);
}
.modal {
width: min(480px, 92vw);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 18px 40px rgb(15 23 42 / 0.18);
}
.modal header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #eef1f6;
}
.modal h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.modal-body {
padding: 16px;
font-size: 13px;
color: #48484a;
}
.modal footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
}
</style>
import { computed, ref } from 'vue';
import type { SessionSummary } from '../types/session';
import { listSessions } from '../api/sessions';
// Sessions change rarely; a short TTL keeps the sidebar from refetching on nav.
const CACHE_TTL_MS = 30_000;
export function useSession() {
const sessions = ref<SessionSummary[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
let fetchedAt = 0;
async function refresh(force = false) {
if (!force && Date.now() - fetchedAt < CACHE_TTL_MS) return;
loading.value = true;
try {
sessions.value = await listSessions();
fetchedAt = Date.now();
error.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load sessions';
} finally {
loading.value = false;
}
}
const isEmpty = computed(() => !loading.value && sessions.value.length === 0);
return { sessions, loading, error, isEmpty, refresh };
}
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f8fafc;
--color-bg-hover: #f0f3f8;
--color-border: #e2e7ef;
--color-border-light: #edf1f7;
--color-text: #1d1d1f;
--color-text-secondary: #48484a;
--color-text-muted: #6e6e73;
--color-primary: #3d7af6;
--color-danger: #ff3b30;
--color-success: #3f9e63;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-full: 9999px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--text-xs: 12px;
--text-sm: 13px;
--text-md: 15px;
--font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
--transition-fast: 120ms ease;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #16181d;
--color-bg-secondary: #1c1f26;
--color-border: #2b2f38;
--color-text: #f2f4f8;
--color-text-secondary: #c3c8d2;
}
}
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import SessionSidebar from './components/SessionSidebar.vue';
import TopBar from './components/TopBar.vue';
import { useSession } from './hooks/useSession';
const route = useRoute();
const { sessions, loading, refresh } = useSession();
const activeId = computed(() => route.params.id as string | undefined);
onMounted(() => refresh());
</script>
<template>
<div class="app-shell">
<TopBar :active-id="activeId" />
<div class="app-body">
<SessionSidebar
:sessions="sessions"
:loading="loading"
:active-id="activeId"
/>
<main class="app-main">
<RouterView />
</main>
</div>
</div>
</template>
<style scoped>
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--color-bg-secondary);
}
.app-body {
display: flex;
flex: 1;
min-height: 0;
}
.app-main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 20px 24px;
background: var(--color-bg);
}
</style>
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import DashboardView from './views/DashboardView.vue';
import SessionView from './views/SessionView.vue';
import './styles/tokens.css';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'dashboard', component: DashboardView },
{ path: '/sessions/:id', name: 'session', component: SessionView },
],
});
createApp(App).use(router).mount('#app');
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>acme-web</title>
<meta name="description" content="Session dashboard for the Acme platform." />
</head>
<body>
<div id="app"></div>
<noscript>This dashboard needs JavaScript enabled.</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "acme-web",
"private": true,
"version": "0.8.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.vue",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vitest": "^1.5.0",
"vue-tsc": "^2.0.13"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"jsx": "preserve",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
// The gateway runs on :8787 in dev; keep the browser on a single origin.
server: {
proxy: {
'/api': {
target: 'http://localhost:8787',
changeOrigin: true,
},
},
},
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router'],
},
},
},
},
});
# acme-web
Session dashboard for the Acme platform. Vue 3 + Vite + TypeScript.
## Getting started
```bash
npm ci
./scripts/dev.sh
```
The dev server proxies `/api` to the gateway on `http://localhost:8787`.
## Layout
| Path | Purpose |
|---|---|
| `src/views` | Route-level components |
| `src/components` | Shared UI (buttons, tables, modals) |
| `src/hooks` | Composable state helpers |
| `src/styles/tokens.css` | Design tokens |
## Conventions
- Components are `<script setup lang="ts">` single-file components.
- Every composable returns plain refs — no classes.
- Design tokens are the only place raw colors live; components use `var(--…)`.
## Scripts
- `npm run dev` — start Vite
- `npm run build` — typecheck and build
- `npm run test` — run the Vitest suite
主要な AI コーディングエージェントの会話履歴に即座に対応
すべてを、ひとつのネイティブアプリに
あなたのデータは、マシンの外に出ません
アカウントも、テレメトリも、クラウドもありません。すべてのセッション、すべてのログ、すべてのバイトは、あなたのディスクに——あるべき場所に留まります。
ローカル SQLite 保存
すべてのセッションやコードは端末内にのみ保存され、クラウド連携は一切不要です。
テレメトリ・通信ゼロ
外部への追跡やデータ送信は一切行わず、すべてのネットワーク通信を透明に監視可能です。
完全オフライン対応
インターネット未接続や隔離環境でも、過去のセッションをミリ秒単位で瞬時に閲覧・検索できます。
SeshBuddy をダウンロード
macOS 版
macOS 12 以降 · Apple Silicon / Intel
Windows 版
Windows 10 以降(64 ビット)
macOS 初回起動とセキュリティについて
Apple 開発者公証を未取得のため、初回起動時に「壊れているため開けません」または「開発元を検証できない」と警告される場合があります。これは macOS の正常な保護機能です。以下のいずれかの方法で解除できます:
xattr -dr com.apple.quarantine /Applications/SeshBuddy.app 「システム設定」→「プライバシーとセキュリティ」を開き、画面下部の SeshBuddy の「このまま開く」をクリックしてください。