This commit is contained in:
2026-07-29 11:42:41 +08:00
commit 48b7391365
71 changed files with 28910 additions and 0 deletions

120
src/app/api/chat/route.ts Normal file
View File

@ -0,0 +1,120 @@
import { apiError } from "@/lib/api";
import { startChatRun } from "@/lib/chat-runs";
import {
createChatRunWithMessages,
getActiveChatRun,
getConversation,
getConversationContinuation,
getDefaultConversation,
getSkill,
listMessages,
} from "@/lib/db";
import { hasDeepSeekApiKey } from "@/lib/deepseek";
import type { ChatMessage, Skill } from "@/lib/types";
import { createId } from "@/lib/utils";
import { chatRequestSchema } from "@/lib/validation";
export const runtime = "nodejs";
export const maxDuration = 120;
export async function GET() {
try {
const conversation = getDefaultConversation();
const continuation = getConversationContinuation(conversation.id);
return Response.json({
conversation,
messages: listMessages(conversation.id),
activeRun: getActiveChatRun(conversation.id),
retainedSkillIds: continuation.skillIds,
continuation,
apiConfigured: hasDeepSeekApiKey(),
});
} catch (error) {
return apiError(error, "无法读取聊天记录");
}
}
export async function POST(request: Request) {
try {
const input = chatRequestSchema.parse(await request.json());
if (!getConversation(input.conversationId)) {
return Response.json({ error: "会话不存在" }, { status: 404 });
}
const existingMessages = listMessages(input.conversationId);
const continuation = getConversationContinuation(
input.conversationId,
);
const selectedSkills = [...new Set(input.selectedSkillIds)]
.map((id) => getSkill(id))
.filter(
(skill): skill is Skill => skill?.status === "active",
);
const userMessageId = createId("message");
const assistantMessageId = createId("message");
const runId = createId("run");
const now = new Date().toISOString();
const creation = createChatRunWithMessages({
conversationId: input.conversationId,
userMessage: {
id: userMessageId,
role: "user",
content: input.message,
selectedSkillIds: selectedSkills.map((skill) => skill.id),
usedSkillIds: [],
status: "complete",
createdAt: now,
},
assistantMessage: {
id: assistantMessageId,
role: "assistant",
content: "",
selectedSkillIds: selectedSkills.map((skill) => skill.id),
usedSkillIds: [],
status: "streaming",
createdAt: new Date(Date.now() + 1).toISOString(),
},
run: {
id: runId,
clientRequestId: input.clientRequestId,
userMessageId,
assistantMessageId,
},
});
if (creation.outcome === "existing") {
return Response.json({ run: creation.run });
}
if (creation.outcome === "active_conflict") {
return Response.json(
{ error: "当前已有一轮回答正在进行", run: creation.run },
{ status: 409 },
);
}
const run = creation.run;
const history = existingMessages
.filter((message) => message.status !== "error")
.slice(-20)
.map((message) => ({
role: message.role,
content: message.content,
})) satisfies Array<{
role: ChatMessage["role"];
content: string;
}>;
startChatRun({
runId,
conversationId: input.conversationId,
userMessage: input.message,
selectedSkills,
continuation,
history,
});
return Response.json({ run }, { status: 202 });
} catch (error) {
return apiError(error, "无法发送消息");
}
}

View File

@ -0,0 +1,22 @@
import { stopChatRun } from "@/lib/chat-runs";
export const runtime = "nodejs";
export async function POST(
request: Request,
context: RouteContext<"/api/chat/runs/[id]/stop">,
) {
const { id } = await context.params;
const payload = (await request.json().catch(() => null)) as {
visibleContent?: unknown;
} | null;
const visibleContent =
typeof payload?.visibleContent === "string"
? payload.visibleContent.slice(0, 200_000)
: undefined;
const result = await stopChatRun(id, { visibleContent });
if (!result.found) {
return Response.json({ error: "回答任务不存在" }, { status: 404 });
}
return Response.json(result);
}

View File

@ -0,0 +1,51 @@
import { getChatRun, listChatRunEvents } from "@/lib/db";
import { encodeSse, sseResponse } from "@/lib/sse";
export const runtime = "nodejs";
export const maxDuration = 120;
const terminalStatuses = new Set(["complete", "stopped", "error"]);
export async function GET(
request: Request,
context: RouteContext<"/api/chat/runs/[id]/stream">,
) {
const { id } = await context.params;
if (!getChatRun(id)) {
return Response.json({ error: "回答任务不存在" }, { status: 404 });
}
const url = new URL(request.url);
let cursor = Math.max(0, Number(url.searchParams.get("after") ?? "0") || 0);
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
while (!request.signal.aborted) {
const events = listChatRunEvents(id, cursor);
for (const event of events) {
controller.enqueue(encodeSse(event));
cursor = Math.max(cursor, event.seq ?? cursor);
}
const run = getChatRun(id);
if (!run || terminalStatuses.has(run.status)) {
controller.close();
return;
}
await new Promise((resolve) => setTimeout(resolve, 140));
}
} catch {
try {
controller.close();
} catch {
// The browser disconnected between polling cycles.
}
}
},
});
return sseResponse(stream);
}

View File

@ -0,0 +1,32 @@
import { apiError } from "@/lib/api";
import {
getDefaultConversation,
renameConversation,
resetConversation,
} from "@/lib/db";
import { renameConversationSchema } from "@/lib/validation";
export const runtime = "nodejs";
export async function PATCH(request: Request) {
try {
const input = renameConversationSchema.parse(await request.json());
const conversation = getDefaultConversation();
const updated = renameConversation(conversation.id, input.title);
return Response.json({ conversation: updated });
} catch (error) {
return apiError(error, "无法修改会话标题");
}
}
export async function DELETE() {
try {
const conversation = getDefaultConversation();
return Response.json({
conversation: resetConversation(conversation.id),
});
} catch (error) {
return apiError(error, "无法重置会话");
}
}

View File

@ -0,0 +1,96 @@
import { apiError } from "@/lib/api";
import { deleteSkill, getSkill, saveSkill } from "@/lib/db";
import {
getSkillCapabilityViolations,
skillCapabilityErrorMessage,
} from "@/lib/skill-capabilities";
import {
getSkillNodeQualityIssues,
skillNodeQualityErrorMessage,
} from "@/lib/skill-node-quality";
import { saveSkillSchema } from "@/lib/validation";
export const runtime = "nodejs";
export async function GET(
_request: Request,
context: RouteContext<"/api/skills/[id]">,
) {
try {
const { id } = await context.params;
const skill = getSkill(id);
if (!skill) {
return Response.json({ error: "Skill 不存在" }, { status: 404 });
}
return Response.json({ skill });
} catch (error) {
return apiError(error, "无法读取 Skill");
}
}
export async function PUT(
request: Request,
context: RouteContext<"/api/skills/[id]">,
) {
try {
const { id } = await context.params;
if (!getSkill(id)) {
return Response.json({ error: "Skill 不存在" }, { status: 404 });
}
const input = saveSkillSchema.parse({
...(await request.json()),
id,
});
const capabilityViolations = getSkillCapabilityViolations(input);
if (capabilityViolations.length > 0) {
return Response.json(
{ error: skillCapabilityErrorMessage(capabilityViolations) },
{ status: 409 },
);
}
if (
(input.status ?? "active") === "active" &&
input.nodes.some((node) => !node.completed)
) {
return Response.json(
{ error: "五个节点全部完成后才能保存 Skill" },
{ status: 409 },
);
}
if ((input.status ?? "active") === "active") {
const qualityIssues = getSkillNodeQualityIssues(input.nodes);
if (qualityIssues.length > 0) {
return Response.json(
{ error: skillNodeQualityErrorMessage(qualityIssues) },
{ status: 409 },
);
}
}
return Response.json({ skill: saveSkill(input) });
} catch (error) {
return apiError(error, "无法更新 Skill");
}
}
export async function DELETE(
_request: Request,
context: RouteContext<"/api/skills/[id]">,
) {
try {
const { id } = await context.params;
const deleted = deleteSkill(id);
if (!deleted) {
return Response.json({ error: "Skill 不存在" }, { status: 404 });
}
return new Response(null, { status: 204 });
} catch (error) {
return apiError(error, "无法删除 Skill");
}
}

View File

@ -0,0 +1,15 @@
import { stopBuilderRun } from "@/lib/builder-runs";
export const runtime = "nodejs";
export async function POST(
_request: Request,
context: RouteContext<"/api/skills/builder/[id]/stop">,
) {
const { id } = await context.params;
if (!stopBuilderRun(id)) {
return Response.json({ error: "节点任务不存在或已过期" }, { status: 404 });
}
return Response.json({ stopped: true });
}

View File

@ -0,0 +1,60 @@
import { apiError } from "@/lib/api";
import {
getBuilderRunEvents,
getOrStartBuilderRun,
} from "@/lib/builder-runs";
import { encodeSse, sseResponse } from "@/lib/sse";
import { builderRequestSchema } from "@/lib/validation";
export const runtime = "nodejs";
export const maxDuration = 120;
const terminalStatuses = new Set(["complete", "stopped", "error"]);
export async function POST(request: Request) {
try {
const input = builderRequestSchema.parse(await request.json());
const run = getOrStartBuilderRun(input);
let cursor = input.afterSeq;
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
while (!request.signal.aborted) {
const snapshot = getBuilderRunEvents(run.id, cursor);
if (!snapshot) {
controller.enqueue(
encodeSse({ type: "error", message: "节点任务已过期" }),
);
controller.close();
return;
}
for (const event of snapshot.events) {
controller.enqueue(encodeSse(event));
cursor = Math.max(cursor, event.seq ?? cursor);
}
if (terminalStatuses.has(snapshot.status)) {
controller.close();
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
} catch {
try {
controller.close();
} catch {
// A disconnected editor can reconnect with the last event seq.
}
}
},
});
return sseResponse(stream);
} catch (error) {
return apiError(error, "无法处理 Skill 对话");
}
}

View File

@ -0,0 +1,15 @@
import { apiError } from "@/lib/api";
import { generateBuilderSuggestions } from "@/lib/deepseek";
import { builderSuggestionsRequestSchema } from "@/lib/validation";
export const runtime = "nodejs";
export const maxDuration = 30;
export async function POST(request: Request) {
try {
const input = builderSuggestionsRequestSchema.parse(await request.json());
return Response.json(await generateBuilderSuggestions(input));
} catch (error) {
return apiError(error, "无法生成节点建议");
}
}

View File

@ -0,0 +1,58 @@
import { apiError } from "@/lib/api";
import { listSkills, saveSkill } from "@/lib/db";
import {
getSkillCapabilityViolations,
skillCapabilityErrorMessage,
} from "@/lib/skill-capabilities";
import {
getSkillNodeQualityIssues,
skillNodeQualityErrorMessage,
} from "@/lib/skill-node-quality";
import { saveSkillSchema } from "@/lib/validation";
export const runtime = "nodejs";
export async function GET() {
try {
return Response.json({ skills: listSkills() });
} catch (error) {
return apiError(error, "无法读取 Skill");
}
}
export async function POST(request: Request) {
try {
const input = saveSkillSchema.parse(await request.json());
const capabilityViolations = getSkillCapabilityViolations(input);
if (capabilityViolations.length > 0) {
return Response.json(
{ error: skillCapabilityErrorMessage(capabilityViolations) },
{ status: 409 },
);
}
if (
(input.status ?? "active") === "active" &&
input.nodes.some((node) => !node.completed)
) {
return Response.json(
{ error: "五个节点全部完成后才能保存 Skill" },
{ status: 409 },
);
}
if ((input.status ?? "active") === "active") {
const qualityIssues = getSkillNodeQualityIssues(input.nodes);
if (qualityIssues.length > 0) {
return Response.json(
{ error: skillNodeQualityErrorMessage(qualityIssues) },
{ status: 409 },
);
}
}
const skill = saveSkill(input);
return Response.json({ skill }, { status: 201 });
} catch (error) {
return apiError(error, "无法保存 Skill");
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

277
src/app/globals.css Normal file
View File

@ -0,0 +1,277 @@
@import "tailwindcss";
@import "tw-shimmer";
:root {
--radius: 0.625rem;
--background: #fafafa;
--foreground: #18181b;
--card: #ffffff;
--card-foreground: #18181b;
--popover: #ffffff;
--popover-foreground: #18181b;
--primary: #18181b;
--primary-foreground: #fafafa;
--secondary: #f4f4f5;
--secondary-foreground: #27272a;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--destructive: #dc2626;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #a1a1aa;
--canvas: var(--background);
--surface: #ffffff;
--ink: var(--foreground);
--slate: var(--muted-foreground);
--line: var(--border);
--cobalt: #18181b;
--cobalt-soft: #f4f4f5;
--mint: #16a34a;
--mint-dark: #166534;
--mint-soft: #f0fdf4;
--coral: var(--destructive);
--coral-soft: #fef2f2;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-canvas: var(--canvas);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-line: var(--line);
--color-cobalt: var(--cobalt);
--color-cobalt-soft: var(--cobalt-soft);
--color-mint: var(--mint);
--color-mint-dark: var(--mint-dark);
--color-mint-soft: var(--mint-soft);
--color-coral: var(--coral);
--color-coral-soft: var(--coral-soft);
--font-sans:
"Manrope Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI",
sans-serif;
--font-display:
"Manrope Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI",
sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
@keyframes collapsible-down {
from {
height: 0;
}
to {
height: var(
--radix-collapsible-content-height,
var(--collapsible-panel-height, auto)
);
}
}
@keyframes collapsible-up {
from {
height: var(
--radix-collapsible-content-height,
var(--collapsible-panel-height, auto)
);
}
to {
height: 0;
}
}
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background: var(--canvas);
color: var(--ink);
font-family:
"Manrope Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI",
sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
}
::selection {
background: rgba(59, 130, 246, 0.28);
color: inherit;
}
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: rgba(113, 113, 122, 0.2);
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(113, 113, 122, 0.34);
background-clip: padding-box;
}
.markdown {
overflow-wrap: anywhere;
}
.markdown > :first-child {
margin-top: 0;
}
.markdown > :last-child {
margin-bottom: 0;
}
.markdown p {
margin: 0.62em 0;
}
.markdown h1,
.markdown h2,
.markdown h3 {
margin: 1.15em 0 0.42em;
font-family:
"Manrope Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI",
sans-serif;
font-weight: 800;
letter-spacing: -0.025em;
line-height: 1.35;
}
.markdown h1 {
font-size: 1.3em;
}
.markdown h2 {
font-size: 1.16em;
}
.markdown h3 {
font-size: 1.04em;
}
.markdown ul,
.markdown ol {
margin: 0.65em 0;
padding-left: 1.3em;
}
.markdown li {
margin: 0.22em 0;
}
.markdown li::marker {
color: var(--muted-foreground);
}
.markdown blockquote {
margin: 0.8em 0;
border-left: 2px solid var(--border);
border-radius: 0 8px 8px 0;
background: var(--muted);
padding: 0.5em 0.8em;
color: var(--muted-foreground);
}
.markdown code {
border-radius: 5px;
background: #eef1f5;
padding: 0.14em 0.36em;
font-family: "IBM Plex Mono", Consolas, monospace;
font-size: 0.88em;
}
.markdown pre {
max-width: 100%;
overflow-x: auto;
border-radius: 12px;
background: #18181b;
padding: 1em;
color: #eef2fb;
}
.markdown pre code {
background: transparent;
padding: 0;
color: inherit;
}
.markdown table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
font-size: 0.92em;
}
.markdown th,
.markdown td {
border: 1px solid var(--line);
padding: 0.45em 0.7em;
text-align: left;
}
.markdown th {
background: var(--canvas);
font-weight: 750;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@custom-variant data-open (&:where([data-state="open"], [data-open]:not([data-open="false"])));
@custom-variant data-closed (&:where([data-state="closed"], [data-closed]:not([data-closed="false"])));

27
src/app/layout.tsx Normal file
View File

@ -0,0 +1,27 @@
import type { Metadata } from "next";
import "@fontsource-variable/manrope";
import "@fontsource/ibm-plex-mono/500.css";
import "@fontsource/ibm-plex-mono/600.css";
import "./globals.css";
export const metadata: Metadata = {
title: {
default: "Skill Loom · AI Skill 工作台",
template: "%s · Skill Loom",
},
description:
"组合、创建并在单轮 AI 对话中智能调用可复用 Skill 的工作台。",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="h-full">
<body className="min-h-full">{children}</body>
</html>
);
}

6
src/app/page.tsx Normal file
View File

@ -0,0 +1,6 @@
import { ChatWorkspace } from "@/components/chat-workspace";
export default function Home() {
return <ChatWorkspace />;
}

View File

@ -0,0 +1,9 @@
import { SkillBuilder } from "@/components/skill-builder";
export default async function EditSkillPage(
props: PageProps<"/skills/[id]">,
) {
const { id } = await props.params;
return <SkillBuilder skillId={id} />;
}

View File

@ -0,0 +1,6 @@
import { SkillBuilder } from "@/components/skill-builder";
export default function NewSkillPage() {
return <SkillBuilder />;
}