Replace profile feature with agent prompts
This commit is contained in:
1 parent
74b623c63a
commit
7c8fe96446
9 files changed
+155
-756
No files matched your search
@@ -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 配置
|
||||
```
|
||||
|
||||
## 内容格式
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -20,8 +20,5 @@ binding = "RESOURCES"
|
||||
bucket_name = "edgeever-resources"
|
||||
preview_bucket_name = "edgeever-resources-preview"
|
||||
|
||||
[ai]
|
||||
binding = "AI"
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
Reference in new issue
Block a user