Replace profile feature with agent prompts

This commit is contained in:
tianma committed 2026-07-01 12:06:23 +08:00
1 parent 74b623c63a
commit 7c8fe96446
9 files changed
+155 -756

No files matched your search

+1 -1
View File
@@ -98,7 +98,7 @@ packages/shared 共享类型、Zod schema、TipTap / Markdown 内容转换
scripts Wrangler 封装、密码 hash、CLI、MCP stdio bridge、Evernote ENEX 导入
migrations D1 数据库迁移
docs OpenAPI schema、迁移指南等文档
wrangler.toml Cloudflare Workers、Assets、D1、R2、Workers AI 配置
wrangler.toml Cloudflare Workers、Assets、D1、R2 配置
```
## 内容格式
-389
View File
@@ -26,8 +26,6 @@ import {
type Resource,
type ResourceListItem,
type ResourceStorageSummary,
type ProfileSnapshot,
type ProfileSection,
type TagSummary,
type TiptapDoc,
} from "@edgeever/shared";
@@ -41,12 +39,10 @@ import openApiSpec from "../../../docs/openapi.json";
type Bindings = {
DB: D1Database;
RESOURCES: R2Bucket;
AI?: Ai;
EDGE_EVER_AUTH_USERNAME?: string;
EDGE_EVER_AUTH_PASSWORD_HASH?: string;
EDGE_EVER_SESSION_TTL_DAYS?: string;
EDGE_EVER_R2_BUCKET_NAME?: string;
EDGE_EVER_PROFILE_MODEL?: string;
};
type AuthContext = {
@@ -188,24 +184,6 @@ type ResourceStatsRow = {
attachment_count: number;
};
type ProfileSnapshotRow = {
id: string;
profile_json: string;
source_memo_ids_json: string;
memo_count: number;
model: string;
generated_at: string;
created_at: string;
};
type ProfileSourceMemoRow = {
id: string;
title: string | null;
tags_json: string;
updated_at: string;
content_text: string;
};
type AppContext = Context<{ Bindings: Bindings; Variables: { auth: AuthContext } }>;
const SESSION_COOKIE = "edgeever_session";
@@ -216,10 +194,6 @@ const PASSWORD_SALT_BYTES = 16;
const SESSION_TOKEN_BYTES = 32;
const DEFAULT_SESSION_TTL_DAYS = 30;
const DEFAULT_R2_BUCKET_NAME = "edgeever-resources";
const DEFAULT_PROFILE_MODEL = "@cf/zai-org/glm-5.2";
const PROFILE_MEMO_LIMIT = 200;
const PROFILE_MEMO_TEXT_LIMIT = 1200;
const PROFILE_CHUNK_CHAR_LIMIT = 12000;
const MAX_IMAGE_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_ATTACHMENT_UPLOAD_BYTES = 100 * 1024 * 1024;
const REVISION_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000;
@@ -234,8 +208,6 @@ const ALL_TOKEN_SCOPES = [
"write:resources",
"read:tags",
"write:tags",
"read:profile",
"write:profile",
] as const;
type TokenScope = (typeof ALL_TOKEN_SCOPES)[number];
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
@@ -611,41 +583,6 @@ app.delete("/api/v1/tags/:tag", async (c) => {
return c.json({ ok: true, updated });
});
app.get("/api/v1/profile", async (c) => {
const denied = requireScopes(c, "read:profile");
if (denied) {
return denied;
}
return c.json({ profile: await getLatestProfileSnapshot(c.env.DB) });
});
app.post("/api/v1/profile/generate", async (c) => {
const denied = requireScopes(c, "write:profile");
if (denied) {
return denied;
}
if (!c.env.AI) {
return apiError(c, "ai_not_configured", "Workers AI binding is not configured for this deployment.", 501);
}
const actor = getAuditActor(c);
try {
const profile = await generateProfileSnapshot(c.env.DB, c.env.AI, c.env.EDGE_EVER_PROFILE_MODEL, actor);
return c.json({ profile }, 201);
} catch (error) {
if (error instanceof AppError) {
return apiError(c, error.code, error.message, error.status);
}
throw error;
}
});
app.get("/api/v1/memos", async (c) => {
const denied = requireScopes(c, "read:memos");
@@ -2590,332 +2527,6 @@ const mapTagSummary = (row: TagSummaryRow): TagSummary => ({
updatedAt: row.updated_at,
});
const mapProfileSnapshot = (row: ProfileSnapshotRow): ProfileSnapshot => {
const profile = parseProfileJson(row.profile_json);
return {
id: row.id,
summary: profile.summary,
sections: profile.sections,
sourceMemoIds: parseJsonArray(row.source_memo_ids_json),
memoCount: row.memo_count,
model: row.model,
generatedAt: row.generated_at,
createdAt: row.created_at,
};
};
const getLatestProfileSnapshot = async (db: D1Database): Promise<ProfileSnapshot | null> => {
const row = await db
.prepare(
`SELECT id, profile_json, source_memo_ids_json, memo_count, model, generated_at, created_at
FROM profile_snapshots
ORDER BY created_at DESC
LIMIT 1`
)
.first<ProfileSnapshotRow>();
return row ? mapProfileSnapshot(row) : null;
};
const generateProfileSnapshot = async (
db: D1Database,
ai: Ai,
modelOverride: string | undefined,
actor: AuditActor
): Promise<ProfileSnapshot> => {
const memos = await listProfileSourceMemos(db);
if (memos.length === 0) {
throw new AppError("no_profile_sources", "At least one non-empty memo is required to generate a profile.", 400);
}
const model = modelOverride?.trim() || DEFAULT_PROFILE_MODEL;
const chunks = chunkProfileMemos(memos);
const chunkSummaries: string[] = [];
for (let index = 0; index < chunks.length; index += 1) {
const summary = await runAiText(ai, model, [
{
role: "system",
content:
"你是一个谨慎的个人知识管理分析助手。只基于用户笔记做事实归纳,不做心理诊断,不使用夸张或确定性人格判断。",
},
{
role: "user",
content:
"请把下面这组笔记压缩成画像证据摘要。保留主题、项目、偏好、能力、反复出现的问题和可引用的 memo_id。输出中文,控制在 900 字以内。\n\n" +
chunks[index],
},
]);
chunkSummaries.push(summary);
}
const profileText = await runAiText(ai, model, [
{
role: "system",
content:
"你是 EdgeEver 的人物画像生成器。必须输出严格 JSON,不要 Markdown。画像应可追溯、克制、尊重用户隐私。避免医学、心理诊断、职业定性和无法从笔记支持的结论。",
},
{
role: "user",
content:
`请基于这些证据摘要生成人物画像 JSON。可引用的 memo_id 只能来自证据摘要。JSON schema:
{
"summary": "一段 80-160 字中文总览",
"sections": [
{
"title": "关注主题",
"insights": [
{
"label": "短标签",
"description": "基于笔记的克制描述",
"confidence": "low|medium|high",
"evidence": { "memoIds": ["memo_x"], "note": "为什么引用这些笔记" }
}
]
}
]
}
请包含 4-6 个 section,优先使用这些 section 名称:关注主题、项目脉络、能力画像、偏好与原则、行为模式、近期动向。每个 section 2-5 条 insight。不要虚构 memo_id。\n\n证据摘要:\n` +
chunkSummaries.map((summary, index) => `【证据摘要 ${index + 1}】\n${summary}`).join("\n\n"),
},
]);
const profile = normalizeProfileOutput(profileText, memos.map((memo) => memo.id));
const id = createId("profile");
const now = isoNow();
const sourceMemoIds = Array.from(new Set(profile.sections.flatMap((section) => section.insights.flatMap((insight) => insight.evidence.memoIds))));
const finalSourceMemoIds = sourceMemoIds.length > 0 ? sourceMemoIds : memos.map((memo) => memo.id);
await db.batch([
db
.prepare(
`INSERT INTO profile_snapshots (
id, profile_json, source_memo_ids_json, memo_count, model, generated_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.bind(id, JSON.stringify(profile), JSON.stringify(finalSourceMemoIds), memos.length, model, now, now),
auditStatement(db, actor.actorType, actor.actorId, "profile.generate", "profile", id, {
memoCount: memos.length,
model,
}),
]);
const row = await db
.prepare(
`SELECT id, profile_json, source_memo_ids_json, memo_count, model, generated_at, created_at
FROM profile_snapshots
WHERE id = ?`
)
.bind(id)
.first<ProfileSnapshotRow>();
if (!row) {
throw new AppError("not_found", "Profile snapshot not found after generation.", 404);
}
return mapProfileSnapshot(row);
};
const listProfileSourceMemos = async (db: D1Database): Promise<ProfileSourceMemoRow[]> => {
const rows = await db
.prepare(
`SELECT m.id, m.title, m.tags_json, m.updated_at, c.content_text
FROM memos m
INNER JOIN memo_contents c ON c.memo_id = m.id
WHERE m.is_deleted = 0
AND length(trim(c.content_text)) >= 20
AND m.created_by <> 'system'
ORDER BY m.updated_at DESC
LIMIT ?`
)
.bind(PROFILE_MEMO_LIMIT)
.all<ProfileSourceMemoRow>();
return rows.results;
};
const chunkProfileMemos = (memos: ProfileSourceMemoRow[]) => {
const chunks: string[] = [];
let current = "";
for (const memo of memos) {
const tags = parseJsonArray(memo.tags_json);
const text = memo.content_text.replace(/\s+/g, " ").trim().slice(0, PROFILE_MEMO_TEXT_LIMIT);
const entry = [
`memo_id: ${memo.id}`,
`title: ${memo.title || DEFAULT_MEMO_TITLE}`,
`updated_at: ${memo.updated_at}`,
`tags: ${tags.join(", ") || "无"}`,
`content: ${text}`,
].join("\n");
const next = current ? `${current}\n\n---\n\n${entry}` : entry;
if (next.length > PROFILE_CHUNK_CHAR_LIMIT && current) {
chunks.push(current);
current = entry;
} else {
current = next;
}
}
if (current) {
chunks.push(current);
}
return chunks;
};
const runAiText = async (
ai: Ai,
model: string,
messages: { role: "system" | "user"; content: string }[]
): Promise<string> => {
let result: unknown;
try {
result = await ai.run(model, {
messages,
max_tokens: 1800,
temperature: 0.2,
});
} catch (error) {
throw new AppError(
"ai_request_failed",
error instanceof Error ? `Workers AI request failed: ${error.message}` : "Workers AI request failed.",
502
);
}
const response = extractAiText(result);
if (!response) {
throw new AppError("ai_response_invalid", "Workers AI returned an empty response.", 502);
}
return response;
};
const extractAiText = (value: unknown) => {
if (typeof value === "string") {
return value.trim();
}
if (!value || typeof value !== "object") {
return "";
}
const record = value as Record<string, unknown>;
if (typeof record.response === "string") {
return record.response.trim();
}
if (typeof record.result === "string") {
return record.result.trim();
}
return "";
};
const normalizeProfileOutput = (text: string, allowedMemoIds: string[]) => {
const parsed = parseJsonObject(extractJsonObject(text));
const allowed = new Set(allowedMemoIds);
const summary = typeof parsed.summary === "string" ? parsed.summary.trim().slice(0, 360) : "";
const rawSections = Array.isArray(parsed.sections) ? parsed.sections : [];
const sections: ProfileSection[] = rawSections
.map((section) => normalizeProfileSection(section, allowed))
.filter((section): section is ProfileSection => Boolean(section));
if (!summary || sections.length === 0) {
throw new AppError("ai_response_invalid", "Workers AI returned a profile that could not be parsed.", 502);
}
return {
summary,
sections: sections.slice(0, 6),
};
};
const normalizeProfileSection = (value: unknown, allowedMemoIds: Set<string>): ProfileSection | null => {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const title = typeof record.title === "string" ? record.title.trim().slice(0, 40) : "";
const rawInsights = Array.isArray(record.insights) ? record.insights : [];
const insights = rawInsights
.map((insight) => normalizeProfileInsight(insight, allowedMemoIds))
.filter((insight): insight is ProfileSection["insights"][number] => Boolean(insight))
.slice(0, 5);
if (!title || insights.length === 0) {
return null;
}
return { title, insights };
};
const normalizeProfileInsight = (value: unknown, allowedMemoIds: Set<string>): ProfileSection["insights"][number] | null => {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const evidence = record.evidence && typeof record.evidence === "object" ? (record.evidence as Record<string, unknown>) : {};
const memoIds = (Array.isArray(evidence.memoIds) ? evidence.memoIds : [])
.filter((memoId): memoId is string => typeof memoId === "string" && allowedMemoIds.has(memoId))
.slice(0, 8);
const confidence = record.confidence === "low" || record.confidence === "high" ? record.confidence : "medium";
const label = typeof record.label === "string" ? record.label.trim().slice(0, 60) : "";
const description = typeof record.description === "string" ? record.description.trim().slice(0, 360) : "";
if (!label || !description) {
return null;
}
return {
label,
description,
confidence,
evidence: {
memoIds,
note: typeof evidence.note === "string" ? evidence.note.trim().slice(0, 160) : undefined,
},
};
};
const parseProfileJson = (json: string): Pick<ProfileSnapshot, "summary" | "sections"> => {
const value = parseJsonObject(json);
const summary = typeof value.summary === "string" ? value.summary : "";
const sections = Array.isArray(value.sections) ? (value.sections as ProfileSection[]) : [];
return { summary, sections };
};
const extractJsonObject = (text: string) => {
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
const candidate = fenced?.[1] ?? text;
const start = candidate.indexOf("{");
const end = candidate.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) {
return candidate;
}
return candidate.slice(start, end + 1);
};
const parseJsonObject = (json: string): Record<string, unknown> => {
try {
const value = JSON.parse(json);
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
} catch {
return {};
}
};
const getApiTokenRow = async (db: D1Database, id: string): Promise<ApiTokenRow | null> =>
db
.prepare(
+153 -170
View File
@@ -1,14 +1,14 @@
import { useEffect, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Brain, ChevronLeft, Copy, HelpCircle, Image, KeyRound, LogOut, Plus, RefreshCw, ShieldCheck, Sparkles, Trash2, UploadCloud, User } from "lucide-react";
import type { ApiToken, AuthUser, ProfileSnapshot } from "@edgeever/shared";
import { ChevronDown, ChevronLeft, Copy, HelpCircle, Image, KeyRound, LogOut, Plus, ShieldCheck, Sparkles, Trash2, UploadCloud, User } from "lucide-react";
import type { ApiToken, AuthUser } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ApiRequestError, api } from "@/lib/api";
import { api } from "@/lib/api";
import { EVERNOTE_MIGRATION_PATH } from "@/lib/routes";
import { cn, formatDateTime } from "@/lib/utils";
import { AppConfirmDialog } from "./dialogs/ConfirmDialogs";
@@ -22,8 +22,6 @@ const ALL_TOKEN_SCOPES = [
"write:resources",
"read:tags",
"write:tags",
"read:profile",
"write:profile",
];
const TOKEN_SCOPE_LABELS: Record<string, string> = {
@@ -35,8 +33,6 @@ const TOKEN_SCOPE_LABELS: Record<string, string> = {
"write:resources": "上传与修改附件",
"read:tags": "读取标签",
"write:tags": "创建与修改标签",
"read:profile": "读取人物画像",
"write:profile": "生成人物画像",
};
const getTokenScopeLabel = (scope: string) => TOKEN_SCOPE_LABELS[scope] ?? scope;
@@ -134,125 +130,100 @@ const PreferenceCard = ({ imageCompressionEnabled, onImageCompressionChange }: P
</Card>
);
interface PersonaProfileCardProps {
profile: ProfileSnapshot | null;
isLoading: boolean;
isGenerating: boolean;
errorMessage?: string;
onGenerate: () => void;
}
const ADVANCED_PROMPTS = [
{
title: "人物画像",
prompt:
"请通过 EdgeEver MCP 读取我的笔记,基于真实笔记内容为我整理一份人物画像。请只根据笔记中的证据判断,不要做心理诊断,不要夸张定性。输出包括:长期关注的主题、做事偏好、能力线索、反复出现的问题、近期动向,并在每条结论后列出相关笔记标题或 memo id。",
},
{
title: "近期复盘",
prompt:
"请通过 EdgeEver MCP 读取我最近更新的笔记,帮我做一次近期复盘。请总结我最近在推进什么、卡在哪里、有哪些反复出现的想法、哪些事情值得继续跟进,最后给出一份简短的下一步行动清单。",
},
{
title: "项目整理",
prompt:
"请通过 EdgeEver MCP 读取我的笔记,找出其中和项目、产品、代码、计划相关的内容。请按项目归类,整理每个项目的目标、当前进展、待办事项、风险和相关笔记来源。不要改动我的笔记,只输出整理结果。",
},
{
title: "知识地图",
prompt:
"请通过 EdgeEver MCP 读取我的笔记,为我整理一份知识地图。请找出主要知识领域、每个领域下的关键概念、相关笔记、我已经掌握的部分和还需要补齐的问题。输出结构要适合后续继续学习和写作。",
},
{
title: "标签建议",
prompt:
"请通过 EdgeEver MCP 读取我的笔记和现有标签,帮我设计一套更清晰的标签体系。请指出重复、过细、过宽或命名不一致的标签,并给出合并、重命名和新增标签建议。先不要修改笔记,等我确认后再执行。",
},
] as const;
const confidenceLabel: Record<ProfileSnapshot["sections"][number]["insights"][number]["confidence"], string> = {
low: "线索较弱",
medium: "有一定依据",
high: "依据较多",
};
const AdvancedPlayCard = () => {
const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
const getProfileErrorMessage = (error: unknown) => {
if (!error) {
return undefined;
}
if (error instanceof ApiRequestError) {
if (error.code === "ai_request_failed") {
return "生成失败了,请稍后再试。";
const handleCopyPrompt = async (title: string, prompt: string) => {
if (!(await copyTextToClipboard(prompt))) {
return;
}
if (error.code === "ai_not_configured") {
return "生成功能暂时不可用。";
}
setCopiedPrompt(title);
window.setTimeout(() => {
setCopiedPrompt((current) => (current === title ? null : current));
}, 1600);
};
if (error.code === "no_profile_sources") {
return "还没有足够的有效笔记用于生成人物画像。";
}
}
return error instanceof Error ? error.message : "人物画像请求失败。";
};
const PersonaProfileCard = ({ profile, isLoading, isGenerating, errorMessage, onGenerate }: PersonaProfileCardProps) => (
<Card className="w-full min-w-0 overflow-hidden shadow-none">
<CardHeader className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2 text-sm">
<Brain className="h-4 w-4 text-emerald-700" />
人物画像
</CardTitle>
<CardDescription className="mt-1 text-xs leading-4">
基于现有笔记手动生成,结果会缓存为快照。
</CardDescription>
</div>
<Button
size="sm"
variant="solid"
className="h-8 w-full whitespace-nowrap bg-emerald-600 text-white hover:bg-emerald-700 sm:w-auto"
return (
<Card className="w-full min-w-0 overflow-hidden shadow-none">
<CardHeader className="p-4">
<button
className="flex w-full min-w-0 items-start justify-between gap-3 text-left"
type="button"
disabled={isGenerating || isLoading}
onClick={onGenerate}
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
{isGenerating ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{profile ? "重新生成" : "生成画像"}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 p-4 pt-0">
{errorMessage && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium leading-5 text-amber-900">
{errorMessage}
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-slate-400">
正在读取画像快照...
</div>
) : profile ? (
<div className="space-y-4">
<div className="rounded-lg border border-emerald-100 bg-emerald-50/60 p-3">
<p className="text-sm font-medium leading-6 text-slate-800">{profile.summary}</p>
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold text-emerald-800">
<span className="rounded-md bg-white px-2 py-1">分析笔记 {profile.memoCount} 条</span>
<span className="rounded-md bg-white px-2 py-1">{formatDateTime(profile.generatedAt)}</span>
<span className="max-w-full truncate rounded-md bg-white px-2 py-1" title={profile.model}>
{profile.model}
</span>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-2">
{profile.sections.map((section) => (
<div key={section.title} className="rounded-lg border border-slate-200 bg-white p-3">
<h3 className="text-sm font-bold text-slate-900">{section.title}</h3>
<div className="mt-3 space-y-3">
{section.insights.map((insight) => (
<div key={`${section.title}-${insight.label}`} className="border-t border-slate-100 pt-3 first:border-t-0 first:pt-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-sm font-semibold text-slate-800">{insight.label}</div>
<span className="shrink-0 rounded-md bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-500">
{confidenceLabel[insight.confidence]}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-slate-600">{insight.description}</p>
{insight.evidence.memoIds.length > 0 && (
<div className="mt-2 text-[11px] font-medium text-slate-400">
来源 {insight.evidence.memoIds.length} 条笔记
</div>
)}
</div>
))}
</div>
<span className="min-w-0">
<CardTitle className="flex items-center gap-2 text-sm">
<Sparkles className="h-4 w-4 text-emerald-700" />
进阶玩法
</CardTitle>
<CardDescription className="mt-1 text-xs leading-4">
搭配AI Agent的进阶玩法。
</CardDescription>
</span>
<ChevronDown
className={cn(
"mt-0.5 h-4 w-4 shrink-0 text-slate-400 transition-transform",
expanded ? "rotate-180" : "rotate-0"
)}
/>
</button>
</CardHeader>
{expanded && (
<CardContent className="grid gap-3 p-4 pt-0">
{ADVANCED_PROMPTS.map((item) => (
<div key={item.title} className="rounded-lg border border-slate-200 bg-white p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm font-bold text-slate-900">{item.title}</div>
<Button
size="sm"
variant="outline"
className="h-8 w-full justify-center bg-white px-3 text-xs sm:w-auto"
type="button"
onClick={() => void handleCopyPrompt(item.title, item.prompt)}
>
<Copy className="h-3.5 w-3.5" />
{copiedPrompt === item.title ? "已复制" : "复制 Prompt"}
</Button>
</div>
))}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-200 px-4 py-8 text-center">
<div className="text-sm font-semibold text-slate-700">还没有人物画像快照</div>
</div>
<p className="mt-2 text-xs leading-5 text-slate-500">{item.prompt}</p>
</div>
))}
</CardContent>
)}
</CardContent>
</Card>
);
</Card>
);
};
interface EvernoteImportGuideCardProps {
onShowGuide?: () => void;
@@ -454,40 +425,72 @@ interface ScopePickerProps {
}
const ScopePicker = ({ availableScopes, selectedScopes, onToggleScope }: ScopePickerProps) => (
<div className="space-y-2">
<span className="block text-xs font-semibold text-slate-500">Token 权限范围</span>
<div className="grid gap-2 sm:grid-cols-2">
{availableScopes.map((scope) => {
const checked = selectedScopes.has(scope);
const checkboxId = `token-scope-${scope.replace(/[^a-z0-9]+/gi, "-")}`;
return (
<label
key={scope}
htmlFor={checkboxId}
className={cn(
"flex min-h-10 cursor-pointer items-center gap-3 rounded-lg border px-3 py-2 transition-colors",
checked
? "border-emerald-500/30 bg-emerald-50/70 text-emerald-800"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
)}
>
<Checkbox
id={checkboxId}
checked={checked}
onCheckedChange={() => onToggleScope(scope)}
className="border-emerald-300"
/>
<span className="min-w-0 truncate text-xs font-semibold" title={scope}>
{getTokenScopeLabel(scope)}
</span>
</label>
);
})}
</div>
</div>
<ScopePickerContent
availableScopes={availableScopes}
selectedScopes={selectedScopes}
onToggleScope={onToggleScope}
/>
);
const ScopePickerContent = ({ availableScopes, selectedScopes, onToggleScope }: ScopePickerProps) => {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-slate-200 bg-white">
<button
className="flex min-h-11 w-full items-center justify-between gap-3 px-3 py-2 text-left"
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
<span className="min-w-0">
<span className="block text-xs font-semibold text-slate-700">Token 权限范围</span>
<span className="mt-0.5 block text-[11px] font-medium text-slate-400">
已选择 {selectedScopes.size}/{availableScopes.length}
</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-slate-400 transition-transform",
expanded ? "rotate-180" : "rotate-0"
)}
/>
</button>
{expanded && (
<div className="grid gap-2 border-t border-slate-100 p-3 sm:grid-cols-2">
{availableScopes.map((scope) => {
const checked = selectedScopes.has(scope);
const checkboxId = `token-scope-${scope.replace(/[^a-z0-9]+/gi, "-")}`;
return (
<label
key={scope}
htmlFor={checkboxId}
className={cn(
"flex min-h-10 cursor-pointer items-center gap-3 rounded-lg border px-3 py-2 transition-colors",
checked
? "border-emerald-500/30 bg-emerald-50/70 text-emerald-800"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
)}
>
<Checkbox
id={checkboxId}
checked={checked}
onCheckedChange={() => onToggleScope(scope)}
className="border-emerald-300"
/>
<span className="min-w-0 truncate text-xs font-semibold" title={scope}>
{getTokenScopeLabel(scope)}
</span>
</label>
);
})}
</div>
)}
</div>
);
};
interface TokenListProps {
tokens: ApiToken[];
isLoading: boolean;
@@ -732,14 +735,8 @@ export const SettingsPane = ({
queryFn: () => api.listApiTokens(),
});
const profileQuery = useQuery({
queryKey: ["profile"],
queryFn: () => api.getProfile(),
});
const availableScopes = tokensQuery.data?.availableScopes ?? ALL_TOKEN_SCOPES;
const tokens = tokensQuery.data?.apiTokens ?? [];
const profile = profileQuery.data?.profile ?? null;
useEffect(() => {
if (scopeDefaultsSynced || !tokensQuery.data?.availableScopes) {
@@ -767,14 +764,6 @@ export const SettingsPane = ({
},
});
const generateProfileMutation = useMutation({
mutationFn: api.generateProfile,
onSuccess: async (data) => {
queryClient.setQueryData(["profile"], data);
await queryClient.invalidateQueries({ queryKey: ["profile"] });
},
});
const toggleScope = (scope: string) => {
setSelectedScopes((current) => {
const next = new Set(current);
@@ -829,13 +818,7 @@ export const SettingsPane = ({
imageCompressionEnabled={imageCompressionEnabled}
onImageCompressionChange={onImageCompressionChange}
/>
<PersonaProfileCard
profile={profile}
isLoading={profileQuery.isLoading}
isGenerating={generateProfileMutation.isPending}
errorMessage={getProfileErrorMessage(generateProfileMutation.error ?? profileQuery.error)}
onGenerate={() => generateProfileMutation.mutate()}
/>
<AdvancedPlayCard />
<EvernoteImportGuideCard onShowGuide={onShowGuide} />
<TokenCard
name={name}
-13
View File
@@ -6,7 +6,6 @@ import type {
MemoRevision,
MemoSummary,
Notebook,
ProfileSnapshot,
Resource,
ResourceListItem,
ResourceStorageSummary,
@@ -35,10 +34,6 @@ type ListTagsResponse = {
tags: TagSummary[];
};
type ProfileResponse = {
profile: ProfileSnapshot | null;
};
type ListApiTokensResponse = {
apiTokens: ApiToken[];
availableScopes: string[];
@@ -146,14 +141,6 @@ export const api = {
method: "DELETE",
}),
getProfile: () => request<ProfileResponse>("/api/v1/profile"),
generateProfile: () =>
request<ProfileResponse>("/api/v1/profile/generate", {
method: "POST",
body: JSON.stringify({}),
}),
listApiTokens: () => request<ListApiTokensResponse>("/api/v1/api-tokens"),
createApiToken: (payload: { name: string; scopes: string[]; expiresAt?: string | null }) =>
-140
View File
@@ -273,43 +273,6 @@
}
}
},
"/api/v1/profile": {
"get": {
"summary": "Read latest generated profile snapshot",
"responses": {
"200": {
"description": "Latest profile snapshot",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProfileResponse"
}
}
}
}
}
}
},
"/api/v1/profile/generate": {
"post": {
"summary": "Generate a profile snapshot with Workers AI",
"responses": {
"201": {
"description": "Generated profile snapshot",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProfileResponse"
}
}
}
},
"501": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/api-tokens": {
"get": {
"summary": "List API tokens",
@@ -1396,109 +1359,6 @@
},
"required": ["name"]
},
"ProfileResponse": {
"type": "object",
"properties": {
"profile": {
"oneOf": [
{
"$ref": "#/components/schemas/ProfileSnapshot"
},
{
"type": "null"
}
]
}
},
"required": ["profile"]
},
"ProfileSnapshot": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"summary": {
"type": "string"
},
"sections": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ProfileSection"
}
},
"sourceMemoIds": {
"type": "array",
"items": {
"type": "string"
}
},
"memoCount": {
"type": "integer"
},
"model": {
"type": "string"
},
"generatedAt": {
"type": "string",
"format": "date-time"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": ["id", "summary", "sections", "sourceMemoIds", "memoCount", "model", "generatedAt", "createdAt"]
},
"ProfileSection": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"insights": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ProfileInsight"
}
}
},
"required": ["title", "insights"]
},
"ProfileInsight": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"description": {
"type": "string"
},
"confidence": {
"type": "string",
"enum": ["low", "medium", "high"]
},
"evidence": {
"$ref": "#/components/schemas/ProfileEvidence"
}
},
"required": ["label", "description", "confidence", "evidence"]
},
"ProfileEvidence": {
"type": "object",
"properties": {
"memoIds": {
"type": "array",
"items": {
"type": "string"
}
},
"note": {
"type": "string"
}
},
"required": ["memoIds"]
},
"ApiToken": {
"type": "object",
"properties": {
-12
View File
@@ -1,12 +0,0 @@
CREATE TABLE profile_snapshots (
id TEXT PRIMARY KEY,
profile_json TEXT NOT NULL CHECK (json_valid(profile_json)),
source_memo_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(source_memo_ids_json)),
memo_count INTEGER NOT NULL DEFAULT 0 CHECK (memo_count >= 0),
model TEXT NOT NULL,
generated_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_profile_snapshots_created
ON profile_snapshots(created_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS profile_snapshots;
-28
View File
@@ -104,34 +104,6 @@ export type TagSummary = {
updatedAt: string | null;
};
export type ProfileEvidence = {
memoIds: string[];
note?: string;
};
export type ProfileInsight = {
label: string;
description: string;
confidence: "low" | "medium" | "high";
evidence: ProfileEvidence;
};
export type ProfileSection = {
title: string;
insights: ProfileInsight[];
};
export type ProfileSnapshot = {
id: string;
summary: string;
sections: ProfileSection[];
sourceMemoIds: string[];
memoCount: number;
model: string;
generatedAt: string;
createdAt: string;
};
export type AuthUser = {
id: string;
username: string;
-3
View File
@@ -20,8 +20,5 @@ binding = "RESOURCES"
bucket_name = "edgeever-resources"
preview_bucket_name = "edgeever-resources-preview"
[ai]
binding = "AI"
[observability]
enabled = true