Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2448815113 | ||
|
|
af990e1b32 | ||
|
|
61ecb8bb90 | ||
|
|
cc5498b961 | ||
|
|
bf24b73276 | ||
|
|
a643e6e4a3 | ||
|
|
20bd9cfe88 | ||
|
|
8ecc4c1110 | ||
|
|
b9f98ea4b6 | ||
|
|
d63cb77887 | ||
|
|
111b718526 | ||
|
|
6bd1de2669 |
@@ -26,3 +26,5 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
# typescript - конец
|
# typescript - конец
|
||||||
|
|
||||||
|
.vercel
|
||||||
|
|||||||
@@ -1,107 +1,167 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "t1",
|
"id": "t1",
|
||||||
"question": "На столе лежало 3 яблока. Ты взял 2 яблока. Сколько яблок осталось на столе? Ответь одним числом.",
|
"question": "There were 3 apples on the table. You took 2 apples. How many apples are left on the table?",
|
||||||
"answer": "1"
|
"answer": "1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t2",
|
"id": "t2",
|
||||||
"question": "Фермер имеет 17 кур. Все, кроме 9, умерли. Сколько кур осталось живыми? Ответь одним числом.",
|
"question": "A farmer has 17 chickens. All but 9 of them died. How many chickens are still alive?",
|
||||||
"answer": "9"
|
"answer": "9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t3",
|
"id": "t3",
|
||||||
"question": "Сколько месяцев в году имеют 28 дней? Ответь одним числом.",
|
"question": "How many months of the year have 28 days?",
|
||||||
"answer": "12"
|
"answer": "12"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t4",
|
"id": "t4",
|
||||||
"question": "Карандаш и ручка вместе стоят 1 рубль 10 копеек. Ручка стоит на 1 рубль дороже карандаша. Сколько стоит карандаш? Ответь числом в копейках.",
|
"question": "A pencil and a pen together cost $1.10. The pen costs $1 more than the pencil. How much does the pencil cost?",
|
||||||
"answer": "5"
|
"answer": "5 cents"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t5",
|
"id": "t5",
|
||||||
"question": "Если 5 машин за 5 минут делают 5 деталей, сколько деталей сделают 100 машин за 100 минут? Ответь одним числом.",
|
"question": "If 5 machines make 5 widgets in 5 minutes, how many widgets will 100 machines make in 100 minutes?",
|
||||||
"answer": "2000"
|
"answer": "2000"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t6",
|
"id": "t6",
|
||||||
"question": "В озере растут кувшинки. Каждый день их количество удваивается. Пруд полностью покрывается за 48 дней. За сколько дней покрывается половина пруда? Ответь одним числом.",
|
"question": "Water lilies grow in a lake. Their number doubles every day. The pond is completely covered in 48 days. In how many days is half of the pond covered?",
|
||||||
"answer": "47"
|
"answer": "47"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t7",
|
"id": "t7",
|
||||||
"question": "Числа от 1 до 9 включительно: сколько из них содержат букву «и» в русском названии? Ответь одним числом.",
|
"question": "Of the numbers from 1 to 9 inclusive, how many contain the letter \"e\" in their English name?",
|
||||||
"answer": "2"
|
"answer": "6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t8",
|
"id": "t8",
|
||||||
"question": "У тебя список из 12 чисел. Если удалить каждое второе число в списке, сколько чисел останется? Ответь одним числом.",
|
"question": "You have a list of 12 numbers. If you remove every second number from the list, how many numbers remain?",
|
||||||
"answer": "6"
|
"answer": "6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t9",
|
"id": "t9",
|
||||||
"question": "Монетку подбросили 3 раза. Какова вероятность, что выпадет орёл все 3 раза? Ответь обыкновенной дробью.",
|
"question": "A coin is tossed 3 times. What is the probability that heads comes up all 3 times?",
|
||||||
"answer": "1/8"
|
"answer": "1/8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t10",
|
"id": "t10",
|
||||||
"question": "У меня есть 10 рублей. Я потратил 3.50 на хлеб и 1.50 на молоко. Сколько сдачи осталось? Ответь числом в рублях.",
|
"question": "I have $10. I spent $3.50 on bread and $1.50 on milk. How much change is left?",
|
||||||
"answer": "5"
|
"answer": "$5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t11",
|
"id": "t11",
|
||||||
"question": "Поезд длиной 100 метров движется со скоростью 36 км/ч. За сколько секунд он полностью проедет мимо столба? Ответь одним числом.",
|
"question": "A train 100 meters long travels at 36 km/h. How many seconds does it take for the train to fully pass a pole?",
|
||||||
"answer": "10"
|
"answer": "10"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t12",
|
"id": "t12",
|
||||||
"question": "Если число увеличить на 30% и получить 78, чему было исходное число? Ответь одним числом.",
|
"question": "A number is increased by 30% and the result is 78. What was the original number?",
|
||||||
"answer": "60"
|
"answer": "60"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t13",
|
"id": "t13",
|
||||||
"question": "В комнате 4 угла. В каждом углу сидит кошка. Напротив каждой кошки сидят 3 кошки. Сколько всего кошек в комнате? Ответь одним числом.",
|
"question": "A room has 4 corners. In each corner sits a cat. Opposite each cat sit 3 cats. How many cats are in the room in total?",
|
||||||
"answer": "4"
|
"answer": "4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t14",
|
"id": "t14",
|
||||||
"question": "Периметр квадрата 28 см. Чему равна его площадь в квадратных сантиметрах? Ответь одним числом.",
|
"question": "The perimeter of a square is 28 cm. What is its area?",
|
||||||
"answer": "49"
|
"answer": "49"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t15",
|
"id": "t15",
|
||||||
"question": "Лена вдвое старше Миши. Сумма их возрастов 36 лет. Сколько лет Мише? Ответь одним числом.",
|
"question": "Lena is twice as old as Misha. The sum of their ages is 36. How old is Misha?",
|
||||||
"answer": "12"
|
"answer": "12"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t16",
|
"id": "t16",
|
||||||
"question": "В шкафу 10 белых и 10 чёрных носков вперемешку. Сколько носков надо достать вслепую, чтобы гарантированно получить пару одного цвета? Ответь одним числом.",
|
"question": "In a drawer there are 10 white and 10 black socks mixed together. How many socks must you take out blindfolded to be guaranteed a matching pair of one color?",
|
||||||
"answer": "3"
|
"answer": "3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t17",
|
"id": "t17",
|
||||||
"question": "Если 3 курицы несут 3 яйца за 3 дня, сколько яиц снесут 6 куриц за 6 дней? Ответь одним числом.",
|
"question": "If 3 hens lay 3 eggs in 3 days, how many eggs will 6 hens lay in 6 days?",
|
||||||
"answer": "12"
|
"answer": "12"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t18",
|
"id": "t18",
|
||||||
"question": "Восемь минус четыре, делённое на два (8 - 4/2). Чему равно выражение? Ответь одним числом.",
|
"question": "Eight minus four divided by two (8 - 4/2). What is the value of the expression?",
|
||||||
"answer": "6"
|
"answer": "6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t19",
|
"id": "t19",
|
||||||
"question": "На столе 7 свечей. 3 потухли. Сколько свечей осталось на столе? Ответь одним числом.",
|
"question": "There are 7 candles on a table. 3 of them go out. How many candles are left on the table?",
|
||||||
"answer": "7"
|
"answer": "7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t20",
|
"id": "t20",
|
||||||
"question": "У Вити 5 машинок, у Кати в 3 раза больше. Потом Катя подарила Вите столько, сколько у него было изначально. Сколько машинок стало у Кати? Ответь одним числом.",
|
"question": "Vitya has 5 toy cars, Katya has 3 times more. Then Katya gave Vitya as many cars as he had originally. How many cars does Katya have now?",
|
||||||
"answer": "10"
|
"answer": "10"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "t21",
|
"id": "t21",
|
||||||
"question": "Сумма трёх последовательных нечётных чисел равна 27. Чему равно наибольшее из них? Ответь одним числом.",
|
"question": "The sum of three consecutive odd numbers is 27. What is the largest of them?",
|
||||||
"answer": "11"
|
"answer": "11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t22",
|
||||||
|
"question": "There were 12 birds sitting on a tree. A hunter shot and brought down 3. How many birds are still sitting on the tree?",
|
||||||
|
"answer": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t23",
|
||||||
|
"question": "A brick weighs 1 kilogram plus half of its own weight. How much does the brick weigh?",
|
||||||
|
"answer": "2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t24",
|
||||||
|
"question": "A father is 3 times as old as his son. Together they are 40 years old. In how many years will the father be exactly twice as old as the son?",
|
||||||
|
"answer": "10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t25",
|
||||||
|
"question": "What positive number, when multiplied by itself, gives 144?",
|
||||||
|
"answer": "12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t26",
|
||||||
|
"question": "I have two $1 coins and five 50-cent coins in my pocket. How much money do I have in total?",
|
||||||
|
"answer": "$4.50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t27",
|
||||||
|
"question": "Three brothers each have one sister. How many children are in the family in total?",
|
||||||
|
"answer": "4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t28",
|
||||||
|
"question": "Two fathers and two sons went fishing, but there were only 3 people, and each had their own fishing rod. How is that possible?",
|
||||||
|
"answer": "They are grandfather, father and son - three generations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t29",
|
||||||
|
"question": "Which is heavier: a kilogram of iron or a kilogram of cotton wool? Explain.",
|
||||||
|
"answer": "They weigh the same - one kilogram each"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t30",
|
||||||
|
"question": "A shirt cost $40. It was discounted by 20%, and then by another 10% off the new price. How much does the shirt cost now?",
|
||||||
|
"answer": "$28.80"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t31",
|
||||||
|
"question": "Sasha is older than Misha but younger than Petya. Who is the youngest?",
|
||||||
|
"answer": "Misha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t32",
|
||||||
|
"question": "Explain why the number 0 is considered even.",
|
||||||
|
"answer": "0 is divisible by 2 without a remainder, so it is even"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t33",
|
||||||
|
"question": "A chocolate bar is divided into 8 equal parts and 3 parts are eaten. What percentage of the bar is left?",
|
||||||
|
"answer": "62.5%"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,28 +1,48 @@
|
|||||||
const ACCEPT = "application/json, text/event-stream";
|
const ACCEPT = "application/json, text/event-stream";
|
||||||
|
|
||||||
function parseMcpResponse(text) {
|
interface JsonRpcResponse {
|
||||||
|
result?: unknown;
|
||||||
|
error?: { code: number; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpTool {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type McpContentBlock = { type: string; text: string } | { type: string; [key: string]: unknown };
|
||||||
|
|
||||||
|
interface McpToolResult {
|
||||||
|
content?: McpContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMcpResponse(text: string): JsonRpcResponse {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) throw new Error("Empty MCP response");
|
if (!trimmed) throw new Error("Empty MCP response");
|
||||||
if (trimmed.startsWith("{")) {
|
if (trimmed.startsWith("{")) {
|
||||||
return JSON.parse(trimmed);
|
return JSON.parse(trimmed) as JsonRpcResponse;
|
||||||
}
|
}
|
||||||
if (trimmed.includes("event:") || trimmed.includes("data:")) {
|
if (trimmed.includes("event:") || trimmed.includes("data:")) {
|
||||||
let payload = "";
|
let payload = "";
|
||||||
for (const line of trimmed.split(/\r?\n/)) {
|
for (const line of trimmed.split(/\r?\n/)) {
|
||||||
if (line.startsWith("data:")) payload += line.slice(5).trim();
|
if (line.startsWith("data:")) payload += line.slice(5).trim();
|
||||||
}
|
}
|
||||||
if (payload.startsWith("{")) return JSON.parse(payload);
|
if (payload.startsWith("{")) return JSON.parse(payload) as JsonRpcResponse;
|
||||||
}
|
}
|
||||||
throw new Error(`Unrecognized MCP response format: ${trimmed.slice(0, 200)}`);
|
throw new Error(`Unrecognized MCP response format: ${trimmed.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class McpClient {
|
export class McpClient {
|
||||||
constructor(url) {
|
private url: string;
|
||||||
|
private seq: number;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.seq = 0;
|
this.seq = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async request(method, params = {}) {
|
async request(method: string, params: Record<string, unknown> = {}): Promise<JsonRpcResponse> {
|
||||||
this.seq += 1;
|
this.seq += 1;
|
||||||
const res = await fetch(this.url, {
|
const res = await fetch(this.url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -37,18 +57,18 @@ export class McpClient {
|
|||||||
return rpc;
|
return rpc;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTools() {
|
async listTools(): Promise<McpTool[]> {
|
||||||
const rpc = await this.request("tools/list");
|
const rpc = await this.request("tools/list");
|
||||||
return rpc.result?.tools ?? [];
|
return (rpc.result as { tools?: McpTool[] })?.tools ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async callTool(name, args = {}) {
|
async callTool(name: string, args: Record<string, unknown> = {}): Promise<McpToolResult> {
|
||||||
const rpc = await this.request("tools/call", { name, arguments: args });
|
const rpc = await this.request("tools/call", { name, arguments: args });
|
||||||
return rpc.result;
|
return rpc.result as McpToolResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function textContentFrom(result) {
|
export function textContentFrom(result: McpToolResult | null | undefined): string {
|
||||||
if (!result?.content) return "";
|
if (!result?.content) return "";
|
||||||
return result.content
|
return result.content
|
||||||
.map((b) => (b.type === "text" ? b.text : JSON.stringify(b)))
|
.map((b) => (b.type === "text" ? b.text : JSON.stringify(b)))
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
const DEFAULT_URL = "http://localhost:11434";
|
|
||||||
|
|
||||||
export class Ollama {
|
|
||||||
constructor({ url = DEFAULT_URL, timeoutMs = 600000 } = {}) {
|
|
||||||
this.url = url.replace(/\/$/, "");
|
|
||||||
this.timeoutMs = timeoutMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
async ping({ timeoutMs = 30000 } = {}) {
|
|
||||||
if (process.env.OLLAMA_SKIP_PING === "1") return { ok: true, model: process.env.OLLAMA_MODEL };
|
|
||||||
const res = await fetch(`${this.url}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
||||||
if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status}) at ${this.url}`);
|
|
||||||
const data = await res.json();
|
|
||||||
const models = (data.models ?? []).map((m) => m.name);
|
|
||||||
return { ok: true, models };
|
|
||||||
}
|
|
||||||
|
|
||||||
async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }) {
|
|
||||||
const body = {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
stream: false,
|
|
||||||
options: { temperature },
|
|
||||||
};
|
|
||||||
if (numCtx) body.options.num_ctx = numCtx;
|
|
||||||
if (tools && tools.length) body.tools = tools;
|
|
||||||
|
|
||||||
const res = await fetch(`${this.url}/api/chat`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(timeoutMs ?? this.timeoutMs),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const t = await res.text();
|
|
||||||
throw new Error(`Ollama chat HTTP ${res.status}: ${t.slice(0, 300)}`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
const message = data.message ?? {};
|
|
||||||
return {
|
|
||||||
role: message.role ?? "assistant",
|
|
||||||
content: message.content ?? "",
|
|
||||||
toolCalls: message.tool_calls ?? [],
|
|
||||||
promptEvalCount: data.prompt_eval_count ?? 0,
|
|
||||||
evalCount: data.eval_count ?? 0,
|
|
||||||
raw: data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listModels() {
|
|
||||||
const o = new Ollama();
|
|
||||||
const res = await fetch(`${o.url}/api/tags`);
|
|
||||||
if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status})`);
|
|
||||||
const data = await res.json();
|
|
||||||
return (data.models ?? []).map((m) => m.name);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
interface OllamaConstructorOpts {
|
||||||
|
url?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
idleTimeoutMs?: number | null;
|
||||||
|
maxTotalMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PingResult {
|
||||||
|
ok: boolean;
|
||||||
|
models: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OllamaToolCall = {
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OllamaToolDefinition = {
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: {
|
||||||
|
type: "object";
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OllamaMessage = {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
tool_calls?: OllamaToolCall[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OllamaChunk = {
|
||||||
|
token: string | null;
|
||||||
|
content: string;
|
||||||
|
type: "content" | "tool_calls";
|
||||||
|
toolCalls?: OllamaToolCall[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChatStreamOpts {
|
||||||
|
model: string;
|
||||||
|
messages: OllamaMessage[];
|
||||||
|
tools?: unknown[];
|
||||||
|
temperature?: number;
|
||||||
|
numCtx?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
maxTotalMs?: number;
|
||||||
|
onChunk?: ((chunk: OllamaChunk) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaChatResult {
|
||||||
|
role: "assistant";
|
||||||
|
content: string;
|
||||||
|
toolCalls: OllamaToolCall[];
|
||||||
|
promptEvalCount: number;
|
||||||
|
evalCount: number;
|
||||||
|
raw: { stream: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaError extends Error {
|
||||||
|
partialContent?: string;
|
||||||
|
partialToolCalls?: OllamaToolCall[];
|
||||||
|
partialPrompt?: number;
|
||||||
|
partialGen?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_URL = "http://localhost:11434";
|
||||||
|
|
||||||
|
export class Ollama {
|
||||||
|
private url: string;
|
||||||
|
private timeoutMs: number;
|
||||||
|
private idleTimeoutMs: number;
|
||||||
|
private maxTotalMs: number;
|
||||||
|
|
||||||
|
constructor({ url = DEFAULT_URL, timeoutMs = 600000, idleTimeoutMs = null, maxTotalMs = null }: OllamaConstructorOpts = {}) {
|
||||||
|
this.url = url.replace(/\/$/, "");
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
this.idleTimeoutMs = idleTimeoutMs ?? timeoutMs;
|
||||||
|
this.maxTotalMs = maxTotalMs ?? 5 * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping({ timeoutMs = 30000 } = {}): Promise<PingResult> {
|
||||||
|
if (process.env.OLLAMA_SKIP_PING === "1") return { ok: true, models: [process.env.OLLAMA_MODEL ?? ""] };
|
||||||
|
const res = await fetch(`${this.url}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
|
||||||
|
if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status}) at ${this.url}`);
|
||||||
|
const data = await res.json() as { models?: Array<{ name: string }> };
|
||||||
|
const models = (data.models ?? []).map((m) => m.name);
|
||||||
|
return { ok: true, models };
|
||||||
|
}
|
||||||
|
|
||||||
|
async chatStream({
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
temperature = 0,
|
||||||
|
numCtx = 8192,
|
||||||
|
timeoutMs,
|
||||||
|
maxTotalMs,
|
||||||
|
onChunk,
|
||||||
|
}: ChatStreamOpts): Promise<OllamaChatResult> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
options: { temperature },
|
||||||
|
};
|
||||||
|
if (numCtx) (body.options as Record<string, unknown>).num_ctx = numCtx;
|
||||||
|
if (tools && tools.length) body.tools = tools;
|
||||||
|
|
||||||
|
const limitMs = timeoutMs ?? this.idleTimeoutMs;
|
||||||
|
const totalCapMs = maxTotalMs ?? this.maxTotalMs;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
const res = await fetch(`${this.url}/api/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
throw new Error(`Ollama chat HTTP ${res.status}: ${t.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
if (!res.body || !res.body.getReader) {
|
||||||
|
throw new Error("Ollama streaming response has no body reader");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let content = "";
|
||||||
|
let toolCalls: OllamaToolCall[] = [];
|
||||||
|
let promptEvalCount = 0;
|
||||||
|
let evalCount = 0;
|
||||||
|
let lastActivity = Date.now();
|
||||||
|
|
||||||
|
const guardInterval = setInterval(() => {
|
||||||
|
if (Date.now() - startedAt > totalCapMs) {
|
||||||
|
const err = new Error(`Request exceeded hard cap of ${totalCapMs}ms; aborting`) as OllamaError;
|
||||||
|
err.name = "TimeoutError";
|
||||||
|
controller.abort(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - lastActivity > limitMs) {
|
||||||
|
const err = new Error(`No progress from Ollama for ${limitMs}ms; aborting`) as OllamaError;
|
||||||
|
err.name = "TimeoutError";
|
||||||
|
controller.abort(err);
|
||||||
|
}
|
||||||
|
}, Math.min(500, Math.max(100, Math.floor(Math.min(limitMs, totalCapMs) / 4))));
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||||
|
try {
|
||||||
|
chunk = await reader.read();
|
||||||
|
} catch (readErr) {
|
||||||
|
const e = new Error(
|
||||||
|
`Aborted mid-stream after ${content.length} chars (toolCalls=${toolCalls.length}): ${(readErr as Error).message}`
|
||||||
|
) as OllamaError;
|
||||||
|
e.name = "TimeoutError";
|
||||||
|
e.partialContent = content;
|
||||||
|
e.partialToolCalls = toolCalls;
|
||||||
|
e.partialPrompt = promptEvalCount;
|
||||||
|
e.partialGen = evalCount;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const { done, value } = chunk;
|
||||||
|
if (done) break;
|
||||||
|
lastActivity = Date.now();
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let idx: number;
|
||||||
|
while ((idx = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, idx).trim();
|
||||||
|
buffer = buffer.slice(idx + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
let obj: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (obj.prompt_eval_count != null) promptEvalCount = obj.prompt_eval_count as number;
|
||||||
|
if (obj.eval_count != null) evalCount = obj.eval_count as number;
|
||||||
|
const msg = (obj.message ?? {}) as Record<string, unknown>;
|
||||||
|
if (msg.content) {
|
||||||
|
content += msg.content as string;
|
||||||
|
onChunk?.({ token: msg.content as string, content, type: "content" });
|
||||||
|
}
|
||||||
|
if (msg.tool_calls && (msg.tool_calls as unknown[]).length) {
|
||||||
|
toolCalls = msg.tool_calls as OllamaToolCall[];
|
||||||
|
onChunk?.({ token: null, content, type: "tool_calls", toolCalls });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearInterval(guardInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: Array.isArray(toolCalls) ? toolCalls : [],
|
||||||
|
promptEvalCount,
|
||||||
|
evalCount,
|
||||||
|
raw: { stream: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }: ChatStreamOpts): Promise<OllamaChatResult> {
|
||||||
|
return this.chatStream({
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
temperature,
|
||||||
|
numCtx,
|
||||||
|
timeoutMs,
|
||||||
|
onChunk: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async warmup({ model, text = "say OK", timeoutMs = 60000 }: { model: string; text?: string; timeoutMs?: number }): Promise<Partial<OllamaChatResult> & { error?: string }> {
|
||||||
|
try {
|
||||||
|
return await this.chatStream({
|
||||||
|
model,
|
||||||
|
messages: [{ role: "user", content: text }],
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listModels(): Promise<string[]> {
|
||||||
|
const o = new Ollama();
|
||||||
|
const res = await fetch(`${o["url"]}/api/tags`);
|
||||||
|
if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status})`);
|
||||||
|
const data = await res.json() as { models?: Array<{ name: string }> };
|
||||||
|
return (data.models ?? []).map((m) => m.name);
|
||||||
|
}
|
||||||
@@ -3,12 +3,18 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "MCP rubber-duck evaluation harness",
|
"description": "MCP rubber-duck evaluation harness",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"run": "node src/run.mjs",
|
"run": "tsx src/run.ts",
|
||||||
"report-html": "node src/report-html.mjs"
|
"review": "tsx src/review.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/client": "^2.0.0"
|
"@duck/types": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"tsx": "^4",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,380 +0,0 @@
|
|||||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const SCENARIOS = ["control", "blind", "mentor"];
|
|
||||||
|
|
||||||
const SCENARIO_LABELS = {
|
|
||||||
control: "Без утки (контроль)",
|
|
||||||
blind: "Слепая утка",
|
|
||||||
mentor: "Утка-помощник",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SCENARIO_DESCS = {
|
|
||||||
control: "Модель решает задачу напрямую, без инструментов.",
|
|
||||||
blind: "Модель объясняет подход коллеге, вызывает quack, не зная заранее ответ.",
|
|
||||||
mentor: "Модель обязана выписать мысли и возможные ошибки, затем проверить себя уткой.",
|
|
||||||
};
|
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
return String(s ?? "")
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
}
|
|
||||||
|
|
||||||
function nl2br(s) {
|
|
||||||
return esc(s).replace(/\n/g, "<br>");
|
|
||||||
}
|
|
||||||
|
|
||||||
function pctColor(p) {
|
|
||||||
return p >= 70 ? "#10b981" : p >= 40 ? "#f59e0b" : "#ef4444";
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelShort(name) {
|
|
||||||
const base = String(name).split(/:/)[0];
|
|
||||||
const ver = String(name).split(/:/)[1] ? ":" + String(name).split(/:/)[1] : "";
|
|
||||||
return esc(base + ver);
|
|
||||||
}
|
|
||||||
|
|
||||||
function summaryTable(report) {
|
|
||||||
const perModel = report.aggregate.perModel;
|
|
||||||
const models = Object.keys(perModel);
|
|
||||||
const rows = models
|
|
||||||
.map((m) => {
|
|
||||||
const g = perModel[m];
|
|
||||||
const scen = report.models[m].scenarios;
|
|
||||||
const acc = (s) => scen[s].accuracy;
|
|
||||||
const row = `
|
|
||||||
<td class="pm-num" data-sc="control" style="color:${pctColor(acc("control"))}">${acc("control")}%</td>
|
|
||||||
<td class="pm-num" data-sc="blind" style="color:${pctColor(acc("blind"))}">${acc("blind")}%</td>
|
|
||||||
<td class="pm-num" data-sc="mentor" style="color:${pctColor(acc("mentor"))}">${acc("mentor")}%</td>
|
|
||||||
<td class="pm-num">${g.accuracy}%</td>
|
|
||||||
<td class="pm-num">${g.reviewed}<span class="muted"> / ${g.totalRows}</span></td>
|
|
||||||
<td class="pm-num">${g.duckUsed}</td>
|
|
||||||
<td class="pm-num">${g.toolCalls}</td>
|
|
||||||
<td class="pm-num">${g.avgGenTokens}</td>
|
|
||||||
`;
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td class="pm-name" data-model="${esc(m)}">${modelShort(m)}</td>
|
|
||||||
${row}
|
|
||||||
</tr>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return `
|
|
||||||
<div class="summary-shell">
|
|
||||||
<table class="summary">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Модель</th>
|
|
||||||
<th>Контроль</th>
|
|
||||||
<th>Слепая</th>
|
|
||||||
<th>Помощник</th>
|
|
||||||
<th>Средняя acc</th>
|
|
||||||
<th>Размечено</th>
|
|
||||||
<th>Уток</th>
|
|
||||||
<th>Вызовов</th>
|
|
||||||
<th>ср. токены out</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scenarioCard(key, agg) {
|
|
||||||
const pct = agg.accuracy;
|
|
||||||
const color = pctColor(pct);
|
|
||||||
const duckPct = agg.tasks ? Math.round((agg.duckUsed / agg.tasks) * 100) : 0;
|
|
||||||
const pendingBadge = agg.pending > 0 ? `<span class="pending-badge">неразмечено: ${agg.pending}</span>` : "";
|
|
||||||
const modelBars = Object.entries(agg.byModel)
|
|
||||||
.map(([m, g]) => {
|
|
||||||
const c = pctColor(g.accuracy);
|
|
||||||
const barW = g.reviewed ? g.accuracy : 0;
|
|
||||||
return `
|
|
||||||
<div class="agg-model">
|
|
||||||
<span class="agg-model-name">${modelShort(m)}${g.reviewed ? "" : " <span class='muted'>(?)</span>"}</span>
|
|
||||||
<div class="bar"><div class="bar-fill" style="width:${barW}%;background:${c}"></div></div>
|
|
||||||
<span class="agg-model-val" style="color:${c}">${g.accuracy}%</span>
|
|
||||||
</div>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return `
|
|
||||||
<div class="card scenario" data-scenario="${key}">
|
|
||||||
<div class="scenario-head">
|
|
||||||
<div>
|
|
||||||
<div class="scenario-title">${esc(SCENARIO_LABELS[key])}</div>
|
|
||||||
<div class="scenario-desc">${esc(SCENARIO_DESCS[key])}</div>
|
|
||||||
</div>
|
|
||||||
<div class="accuracy" style="color:${color}">${pct}%${pendingBadge}</div>
|
|
||||||
</div>
|
|
||||||
<div class="bar"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
|
|
||||||
<div class="metrics">
|
|
||||||
<div class="metric"><span class="m-label">Верных</span><span class="m-val">${agg.correct} <span class="m-mod">/ ${agg.reviewed}</span></span><span class="m-sub">размечено из ${agg.tasks}</span></div>
|
|
||||||
<div class="metric"><span class="m-label">Утка (MCP)</span><span class="m-val">${duckPct}%</span><span class="m-sub">в ${agg.duckUsed} из ${agg.tasks} задач</span></div>
|
|
||||||
<div class="metric"><span class="m-label">quack / задачу</span><span class="m-val">${agg.avgCalls}</span><span class="m-sub">вызовов в среднем</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="agg-models">${modelBars}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowHtml(r) {
|
|
||||||
const pending = r.correct === null || r.correct === undefined;
|
|
||||||
const cls = pending ? "pend" : r.correct ? "ok" : "no";
|
|
||||||
const result = pending ? "?" : r.correct ? "✓" : "✗";
|
|
||||||
const duck = r.duckUsed ? "🦆" : "—";
|
|
||||||
return `
|
|
||||||
<tr class="scenario-row ${cls}" data-scenario="${r.scenario}" data-model="${esc(r.model)}">
|
|
||||||
<td class="r-model">${modelShort(r.model)}</td>
|
|
||||||
<td class="r-id">${esc(r.id)}</td>
|
|
||||||
<td class="r-s">${esc(SCENARIO_LABELS[r.scenario])}</td>
|
|
||||||
<td class="r-result">${result}</td>
|
|
||||||
<td class="r-duck">${duck} <span class="muted">(${r.toolCalls})</span></td>
|
|
||||||
<td class="r-question">${nl2br(r.question)}</td>
|
|
||||||
<td class="r-expected">${esc(r.expected)}</td>
|
|
||||||
<td class="r-tokens">${r.promptTokens}<span class="muted"> / </span>${r.genTokens}</td>
|
|
||||||
<td class="r-answer">${nl2br(r.response)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptBlock(report) {
|
|
||||||
const p = report.prompts || {};
|
|
||||||
const items = SCENARIOS
|
|
||||||
.filter((k) => p[k])
|
|
||||||
.map(
|
|
||||||
(k) => `
|
|
||||||
<div class="prompt-item">
|
|
||||||
<div class="prompt-name">${esc(SCENARIO_LABELS[k])}</div>
|
|
||||||
<div class="prompt-text">${esc(p[k])}</div>
|
|
||||||
</div>`
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
if (!items) return "";
|
|
||||||
return `
|
|
||||||
<details class="prompts" open>
|
|
||||||
<summary>Промпты (то, что передаётся модели)</summary>
|
|
||||||
<div class="prompts-body">${items}</div>
|
|
||||||
</details>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildHtml(report) {
|
|
||||||
const perTask = Object.entries(report.models).flatMap(([model, rep]) =>
|
|
||||||
rep.perTask.map((r) => ({ ...r, model }))
|
|
||||||
);
|
|
||||||
const rows = perTask.map(rowHtml).join("");
|
|
||||||
const models = Object.keys(report.aggregate.perModel);
|
|
||||||
let totalPending = 0, totalReviewed = 0;
|
|
||||||
for (const rep of Object.values(report.models)) {
|
|
||||||
for (const s of SCENARIOS) {
|
|
||||||
if (rep.scenarios[s]) {
|
|
||||||
totalReviewed += rep.scenarios[s].reviewed || 0;
|
|
||||||
totalPending += rep.scenarios[s].pending || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const reviewNote = totalPending > 0
|
|
||||||
? `<div class="review-note">⚠ Не все ответы размечены: <b>${totalReviewed}</b> проверено, <b>${totalPending}</b> ожидают ревью (показаны символом «?»). Запустите <code>node src/review.mjs</code> для проставления меток.</div>`
|
|
||||||
: "";
|
|
||||||
const modelOptions = models
|
|
||||||
.map((m) => `<option value="${esc(m)}">${modelShort(m)}</option>`)
|
|
||||||
.join("");
|
|
||||||
const aggCards = SCENARIOS
|
|
||||||
.filter((k) => report.aggregate.perScenario[k])
|
|
||||||
.map((k) => scenarioCard(k, report.aggregate.perScenario[k]))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Rubber Duck MCP — отчёт тестирования</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg:#0f172a; --panel:#1e293b; --panel2:#273449; --text:#e2e8f0;
|
|
||||||
--muted:#94a3b8; --ok:#10b981; --no:#f87171; --border:#334155;
|
|
||||||
}
|
|
||||||
* { box-sizing:border-box; }
|
|
||||||
body { margin:0; font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
|
||||||
background:var(--bg); color:var(--text); line-height:1.5; }
|
|
||||||
.wrap { max-width:1280px; margin:0 auto; padding:24px 20px 60px; }
|
|
||||||
header h1 { margin:0 0 4px; font-size:22px; }
|
|
||||||
.meta { color:var(--muted); font-size:13px; }
|
|
||||||
.meta b { color:var(--text); font-weight:600; }
|
|
||||||
h2 { font-size:17px; margin:26px 0 12px; }
|
|
||||||
.summary-shell { background:var(--panel); border:1px solid var(--border); border-radius:12px; overflow:auto; }
|
|
||||||
table.summary { width:100%; border-collapse:collapse; font-size:13px; }
|
|
||||||
table.summary th { background:var(--panel2); text-align:left; padding:10px 14px;
|
|
||||||
font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); white-space:nowrap; }
|
|
||||||
table.summary td { padding:10px 14px; border-top:1px solid var(--border); }
|
|
||||||
.pm-name { font-weight:700; white-space:nowrap; }
|
|
||||||
.pm-num { text-align:right; font-variant-numeric:tabular-nums; }
|
|
||||||
.cards { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin:0 0 8px; }
|
|
||||||
@media (max-width:1000px){ .cards { grid-template-columns:1fr; } }
|
|
||||||
.card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px; }
|
|
||||||
.scenario-head { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
|
|
||||||
.scenario-title { font-size:16px; font-weight:700; }
|
|
||||||
.scenario-desc { color:var(--muted); font-size:12px; margin-top:4px; }
|
|
||||||
.accuracy { font-size:32px; font-weight:800; line-height:1; }
|
|
||||||
.pending-badge { display:block; font-size:11px; font-weight:600; color:var(--muted); margin-top:4px; }
|
|
||||||
.bar { height:8px; background:var(--panel2); border-radius:99px; margin:14px 0; overflow:hidden; }
|
|
||||||
.bar-fill { height:100%; border-radius:99px; transition:width .4s; }
|
|
||||||
.metrics { display:grid; grid-template-columns:repeat(2,1fr); gap:10px; }
|
|
||||||
.metric { background:var(--panel2); border-radius:8px; padding:10px 12px; }
|
|
||||||
.m-label { display:block; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.05em; white-space:nowrap; }
|
|
||||||
.m-val { display:block; font-size:20px; font-weight:800; margin-top:2px; }
|
|
||||||
.m-mod { font-size:14px; font-weight:600; color:var(--muted); }
|
|
||||||
.m-sub { display:block; color:var(--muted); font-size:11px; margin-top:2px; }
|
|
||||||
.agg-models { margin-top:14px; display:grid; gap:8px; }
|
|
||||||
.agg-model { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; font-size:12px; }
|
|
||||||
.agg-model .bar { margin:0; }
|
|
||||||
.agg-model-name { color:var(--muted); white-space:nowrap; }
|
|
||||||
.agg-model-val { font-weight:700; font-variant-numeric:tabular-nums; }
|
|
||||||
.prompts { margin:20px 0; background:var(--panel); border:1px solid var(--border); border-radius:12px; overflow:hidden; }
|
|
||||||
.review-note { margin:14px 0; padding:10px 14px; background:rgba(245,158,11,.12); border:1px solid rgba(245,158,11,.4);
|
|
||||||
border-radius:10px; color:#fbbf24; font-size:13px; }
|
|
||||||
.review-note code { background:var(--panel2); padding:1px 6px; border-radius:5px; }
|
|
||||||
.prompts summary { cursor:pointer; padding:12px 16px; font-weight:700; font-size:14px; list-style:none; }
|
|
||||||
.prompts summary::-webkit-details-marker { display:none; }
|
|
||||||
.prompts summary::before { content:"▸ "; color:var(--muted); }
|
|
||||||
.prompts[open] summary::before { content:"▾ "; }
|
|
||||||
.prompts-body { padding:0 16px 14px; display:grid; gap:12px; }
|
|
||||||
.prompt-item { background:var(--panel2); border-radius:8px; padding:10px 12px; }
|
|
||||||
.prompt-name { font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px; }
|
|
||||||
.prompt-text { white-space:pre-wrap; font-size:13px; line-height:1.5; }
|
|
||||||
.filters { margin:8px 0 16px; display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
|
||||||
.filters button { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
|
||||||
padding:7px 14px; border-radius:8px; cursor:pointer; font-size:13px; }
|
|
||||||
.filters button.active { background:#3b82f6; border-color:#3b82f6; color:#fff; }
|
|
||||||
.filters select { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
|
||||||
padding:7px 10px; border-radius:8px; font-size:13px; }
|
|
||||||
.tbl-shell { background:var(--panel); border:1px solid var(--border); border-radius:12px; overflow:auto; }
|
|
||||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
||||||
thead th { position:sticky; top:0; background:var(--panel2); text-align:left; padding:10px 12px;
|
|
||||||
font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); white-space:nowrap; }
|
|
||||||
tbody td { padding:10px 12px; border-top:1px solid var(--border); vertical-align:top; }
|
|
||||||
.scenario-row.ok .r-result { color:var(--ok); font-weight:800; }
|
|
||||||
.scenario-row.no .r-result { color:var(--no); font-weight:800; }
|
|
||||||
.scenario-row.pend .r-result { color:var(--muted); font-weight:800; }
|
|
||||||
.scenario-row.pend { opacity:.75; } .r-model { font-weight:600; white-space:nowrap; }
|
|
||||||
.r-id { font-weight:700; color:var(--muted); }
|
|
||||||
.r-s { white-space:nowrap; }
|
|
||||||
.r-duck { text-align:center; white-space:nowrap; }
|
|
||||||
.r-question { max-width:300px; color:var(--text); }
|
|
||||||
.r-answer { max-width:380px; }
|
|
||||||
.muted { color:var(--muted); }
|
|
||||||
.hidden { display:none !important; }
|
|
||||||
footer { margin-top:20px; color:var(--muted); font-size:12px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrap">
|
|
||||||
<header>
|
|
||||||
<h1>🦆 Rubber Duck MCP — отчёт тестирования</h1>
|
|
||||||
<div class="meta">
|
|
||||||
MCP: <b>${esc(report.mcpUrl)}</b> ·
|
|
||||||
Моделей: <b>${models.length}</b> ·
|
|
||||||
Дата: <b>${esc(report.generatedAt)}</b>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<h2>Сводка по моделям</h2>
|
|
||||||
${summaryTable(report)}
|
|
||||||
|
|
||||||
${reviewNote}
|
|
||||||
|
|
||||||
<h2>Агрегат по сценариям (все модели)</h2>
|
|
||||||
<div class="cards">${aggCards}</div>
|
|
||||||
|
|
||||||
${promptBlock(report)}
|
|
||||||
|
|
||||||
<div class="filters" id="filters">
|
|
||||||
<label style="font-size:12px;color:var(--muted)">Модель:</label>
|
|
||||||
<select id="modelSel">
|
|
||||||
<option value="all">Все модели</option>
|
|
||||||
${modelOptions}
|
|
||||||
</select>
|
|
||||||
<button data-filter="all" class="active">Все сценарии</button>
|
|
||||||
<button data-filter="control">Без утки</button>
|
|
||||||
<button data-filter="blind">Слепая утка</button>
|
|
||||||
<button data-filter="mentor">Утка-помощник</button>
|
|
||||||
<span style="margin-left:auto;color:var(--muted);font-size:12px" id="count"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tbl-shell">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Модель</th><th>#</th><th>Сценарий</th><th>✓</th><th>Утка</th>
|
|
||||||
<th>Вопрос</th><th>Ожид.</th><th>in/out</th><th>Ответ модели</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>Генерировано локальным харнессом apps/eval. temperature=0. Прогон по нескольким локальным моделям через Ollama.</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const buttons = document.querySelectorAll('#filters button');
|
|
||||||
const modelSel = document.getElementById('modelSel');
|
|
||||||
const rows2 = document.querySelectorAll('tbody .scenario-row');
|
|
||||||
const count = document.getElementById('count');
|
|
||||||
let sc = 'all';
|
|
||||||
let mdl = 'all';
|
|
||||||
function apply() {
|
|
||||||
let n = 0;
|
|
||||||
rows2.forEach(r => {
|
|
||||||
const okSc = sc === 'all' || r.dataset.scenario === sc;
|
|
||||||
const okM = mdl === 'all' || r.dataset.model === mdl;
|
|
||||||
const show = okSc && okM;
|
|
||||||
r.classList.toggle('hidden', !show);
|
|
||||||
if (show) n++;
|
|
||||||
});
|
|
||||||
count.textContent = 'показано: ' + n + ' из ' + rows2.length;
|
|
||||||
}
|
|
||||||
buttons.forEach(b => b.addEventListener('click', () => {
|
|
||||||
buttons.forEach(x => x.classList.remove('active'));
|
|
||||||
b.classList.add('active');
|
|
||||||
sc = b.dataset.filter;
|
|
||||||
apply();
|
|
||||||
}));
|
|
||||||
modelSel.addEventListener('change', () => { mdl = modelSel.value; apply(); });
|
|
||||||
apply('all');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
let inFile = null, outFile = "out/report.html";
|
|
||||||
for (let i = 0; i < args.length; i += 1) {
|
|
||||||
const a = args[i];
|
|
||||||
const next = () => args[i + 1];
|
|
||||||
if (a === "--in") inFile = next(), i += 1;
|
|
||||||
else if (a.startsWith("--in=")) inFile = a.slice(5);
|
|
||||||
else if (a === "--out") outFile = next(), i += 1;
|
|
||||||
else if (a.startsWith("--out=")) outFile = a.slice(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inFile) {
|
|
||||||
const reviewed = path.join("out", "report.reviewed.json");
|
|
||||||
const base = path.join("out", "report.json");
|
|
||||||
inFile = (await readFile(reviewed, "utf8").then(() => reviewed).catch(() => base));
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = JSON.parse(await readFile(inFile, "utf8"));
|
|
||||||
const html = buildHtml(report);
|
|
||||||
await mkdir(path.dirname(outFile), { recursive: true });
|
|
||||||
await writeFile(outFile, html, "utf8");
|
|
||||||
console.log(`HTML report written: ${path.resolve(outFile)} (source: ${inFile})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && path.resolve(process.argv[1]).includes("report-html")) {
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
function round(x, d = 2) {
|
import type { ScenarioName, ScenarioSummary, PerTaskRow, ModelReport, Aggregate, AggregateScenario, AggregateModel } from "@duck/types";
|
||||||
|
import { SCENARIO_NAMES } from "@duck/types";
|
||||||
|
|
||||||
|
function round(x: number, d = 2): number {
|
||||||
if (!Number.isFinite(x)) return x;
|
if (!Number.isFinite(x)) return x;
|
||||||
const f = 10 ** d;
|
const f = 10 ** d;
|
||||||
return Math.round(x * f) / f;
|
return Math.round(x * f) / f;
|
||||||
}
|
}
|
||||||
|
|
||||||
function avg(arr) {
|
function avg(arr: number[]): number {
|
||||||
if (!arr.length) return 0;
|
if (!arr.length) return 0;
|
||||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {} }) { const scenarios = ["control", "blind", "mentor"];
|
export interface BuildReportOpts {
|
||||||
const byScenario = {};
|
model: string;
|
||||||
|
mcpUrl: string;
|
||||||
|
rows: PerTaskRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReport({ model, mcpUrl, rows }: BuildReportOpts): ModelReport {
|
||||||
|
const scenarios = SCENARIO_NAMES;
|
||||||
|
const byScenario: Record<string, { total: number; correct: number; reviewed: number; duckUsed: number; toolCalls: number; promptTokens: number[]; genTokens: number[]; duckTokens: number[]; rows: PerTaskRow[] }> = {};
|
||||||
for (const s of scenarios) byScenario[s] = { total: 0, correct: 0, reviewed: 0, duckUsed: 0, toolCalls: 0, promptTokens: [], genTokens: [], duckTokens: [], rows: [] };
|
for (const s of scenarios) byScenario[s] = { total: 0, correct: 0, reviewed: 0, duckUsed: 0, toolCalls: 0, promptTokens: [], genTokens: [], duckTokens: [], rows: [] };
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -29,10 +39,9 @@ export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {
|
|||||||
byScenario[s].rows.push(row);
|
byScenario[s].rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaries = {};
|
const summaries: Record<ScenarioName, ScenarioSummary> = {} as Record<ScenarioName, ScenarioSummary>;
|
||||||
for (const s of scenarios) {
|
for (const s of scenarios) {
|
||||||
const g = byScenario[s];
|
const g = byScenario[s];
|
||||||
const denom = g.reviewed || 1;
|
|
||||||
summaries[s] = {
|
summaries[s] = {
|
||||||
tasks: g.total,
|
tasks: g.total,
|
||||||
reviewed: g.reviewed,
|
reviewed: g.reviewed,
|
||||||
@@ -49,11 +58,8 @@ export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
model,
|
model,
|
||||||
mcpUrl,
|
|
||||||
scenarios: summaries,
|
scenarios: summaries,
|
||||||
prompts,
|
|
||||||
perTask: rows.map((r) => ({
|
perTask: rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
scenario: r.scenario,
|
scenario: r.scenario,
|
||||||
@@ -67,17 +73,14 @@ export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {
|
|||||||
expected: r.expected,
|
expected: r.expected,
|
||||||
question: r.question,
|
question: r.question,
|
||||||
})),
|
})),
|
||||||
meta,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCENARIOS = ["control", "blind", "mentor"];
|
export function buildAggregate(modelsReport: Record<string, ModelReport>): Aggregate {
|
||||||
|
const perScenario: Record<ScenarioName, AggregateScenario> = {} as Record<ScenarioName, AggregateScenario>;
|
||||||
export function buildAggregate(modelsReport) {
|
for (const s of SCENARIO_NAMES) {
|
||||||
const perScenario = {};
|
|
||||||
for (const s of SCENARIOS) {
|
|
||||||
let total = 0, reviewed = 0, correct = 0, duckUsed = 0, toolCalls = 0;
|
let total = 0, reviewed = 0, correct = 0, duckUsed = 0, toolCalls = 0;
|
||||||
const byModel = {};
|
const byModel: Record<string, AggregateScenario["byModel"][string]> = {};
|
||||||
for (const [name, rep] of Object.entries(modelsReport)) {
|
for (const [name, rep] of Object.entries(modelsReport)) {
|
||||||
const g = rep.scenarios[s];
|
const g = rep.scenarios[s];
|
||||||
if (!g) continue;
|
if (!g) continue;
|
||||||
@@ -107,12 +110,12 @@ export function buildAggregate(modelsReport) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const perModel = {};
|
const perModel: Record<string, AggregateModel> = {};
|
||||||
for (const [name, rep] of Object.entries(modelsReport)) {
|
for (const [name, rep] of Object.entries(modelsReport)) {
|
||||||
let duckUsed = 0, toolCalls = 0;
|
let duckUsed = 0, toolCalls = 0;
|
||||||
const gen = [];
|
const gen: number[] = [];
|
||||||
let reviewed = 0, correct = 0;
|
let reviewed = 0, correct = 0;
|
||||||
for (const s of SCENARIOS) {
|
for (const s of SCENARIO_NAMES) {
|
||||||
const g = rep.scenarios[s];
|
const g = rep.scenarios[s];
|
||||||
duckUsed += g.duckUsed;
|
duckUsed += g.duckUsed;
|
||||||
toolCalls += g.toolCalls;
|
toolCalls += g.toolCalls;
|
||||||
@@ -123,7 +126,7 @@ export function buildAggregate(modelsReport) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const taskCount = rep.scenarios.control?.tasks ?? 0;
|
const taskCount = rep.scenarios.control?.tasks ?? 0;
|
||||||
const totalRows = SCENARIOS.reduce((acc, s) => acc + (rep.scenarios[s].tasks || 0), 0);
|
const totalRows = SCENARIO_NAMES.reduce((acc, s) => acc + (rep.scenarios[s].tasks || 0), 0);
|
||||||
perModel[name] = {
|
perModel[name] = {
|
||||||
tasks: taskCount,
|
tasks: taskCount,
|
||||||
totalRows,
|
totalRows,
|
||||||
@@ -1,23 +1,36 @@
|
|||||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import { buildReport, buildAggregate } from "./report.mjs";
|
import type { ReportRoot, ScenarioName, PerTaskRow } from "@duck/types";
|
||||||
|
import { SCENARIO_NAMES } from "@duck/types";
|
||||||
|
import { buildReport, buildAggregate } from "./report.js";
|
||||||
|
|
||||||
const SCENARIO_LABELS = {
|
const SCENARIO_LABELS: Record<ScenarioName, string> = {
|
||||||
control: "Без утки (контроль)",
|
control: "Без утки (контроль)",
|
||||||
|
thinking: "Думать вслух",
|
||||||
blind: "Слепая утка",
|
blind: "Слепая утка",
|
||||||
mentor: "Утка-помощник",
|
mentor: "Утка-помощник",
|
||||||
};
|
};
|
||||||
|
|
||||||
function esc(s) {
|
interface ReviewEntry {
|
||||||
return String(s ?? "")
|
model: string;
|
||||||
.replace(/&/g, "&")
|
taskId: string;
|
||||||
.replace(/</g, "<")
|
scenario: ScenarioName;
|
||||||
.replace(/>/g, ">");
|
row: PerTaskRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
interface ReviewArgs {
|
||||||
const args = {
|
report: string | null;
|
||||||
|
out: string | null;
|
||||||
|
reviewFile: string | null;
|
||||||
|
model: string | null;
|
||||||
|
scenario: string | null;
|
||||||
|
limit: number | null;
|
||||||
|
reAsk?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): ReviewArgs {
|
||||||
|
const args: ReviewArgs = {
|
||||||
report: null,
|
report: null,
|
||||||
out: null,
|
out: null,
|
||||||
reviewFile: null,
|
reviewFile: null,
|
||||||
@@ -39,22 +52,22 @@ function parseArgs(argv) {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
const reportFile = args.report || path.join("out", "report.json");
|
const reportFile = args.report || path.join("out", "report.json");
|
||||||
const outFile = args.out || path.join("out", "report.reviewed.json");
|
const outFile = args.out || path.join("out", "report.reviewed.json");
|
||||||
const reviewFile = args.reviewFile || path.join("out", "review.json");
|
const reviewFile = args.reviewFile || path.join("out", "review.json");
|
||||||
|
|
||||||
const report = JSON.parse(await readFile(reportFile, "utf8"));
|
const report: ReportRoot = JSON.parse(await readFile(reportFile, "utf8"));
|
||||||
|
|
||||||
let decisions = {};
|
let decisions: Record<string, boolean> = {};
|
||||||
try {
|
try {
|
||||||
decisions = JSON.parse(await readFile(reviewFile, "utf8"));
|
decisions = JSON.parse(await readFile(reviewFile, "utf8"));
|
||||||
} catch {
|
} catch {
|
||||||
/* no prior review */
|
/* no prior review */
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = [];
|
const entries: ReviewEntry[] = [];
|
||||||
for (const [model, rep] of Object.entries(report.models)) {
|
for (const [model, rep] of Object.entries(report.models)) {
|
||||||
for (const row of rep.perTask) {
|
for (const row of rep.perTask) {
|
||||||
if (args.model && model !== args.model) continue;
|
if (args.model && model !== args.model) continue;
|
||||||
@@ -64,13 +77,13 @@ async function main() {
|
|||||||
}
|
}
|
||||||
if (args.limit && entries.length > args.limit) entries.length = args.limit;
|
if (args.limit && entries.length > args.limit) entries.length = args.limit;
|
||||||
|
|
||||||
const key = (e) => `${e.model}|${e.taskId}|${e.scenario}`;
|
const key = (e: ReviewEntry) => `${e.model}|${e.taskId}|${e.scenario}`;
|
||||||
|
|
||||||
const isTTY = !!process.stdin.isTTY;
|
const isTTY = !!process.stdin.isTTY;
|
||||||
|
|
||||||
let batchLines = [];
|
let batchLines: string[] = [];
|
||||||
if (!isTTY) {
|
if (!isTTY) {
|
||||||
const input = await new Promise((res, rej) => {
|
const input = await new Promise<string>((res, rej) => {
|
||||||
let data = "";
|
let data = "";
|
||||||
process.stdin.setEncoding("utf8");
|
process.stdin.setEncoding("utf8");
|
||||||
process.stdin.on("data", (c) => (data += c));
|
process.stdin.on("data", (c) => (data += c));
|
||||||
@@ -82,17 +95,21 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let batchIdx = 0;
|
let batchIdx = 0;
|
||||||
const nextInput = async () => {
|
const nextInput = async (): Promise<string> => {
|
||||||
if (isTTY) {
|
if (isTTY) {
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
const ans = await new Promise((res) => rl.question("", res));
|
const ans = await new Promise<string>((res) => rl.question("", res));
|
||||||
rl.close();
|
rl.close();
|
||||||
return ans.trim().toLowerCase();
|
return ans.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
return batchLines[batchIdx++] ?? "";
|
return batchLines[batchIdx++] ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
async function askOne(e, nextInput, decisions) {
|
let done = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let changed = 0;
|
||||||
|
|
||||||
|
async function askOne(e: ReviewEntry): Promise<number> {
|
||||||
const k = key(e);
|
const k = key(e);
|
||||||
const prev = decisions[k];
|
const prev = decisions[k];
|
||||||
if (prev !== undefined) {
|
if (prev !== undefined) {
|
||||||
@@ -114,7 +131,7 @@ async function main() {
|
|||||||
process.stdout.write(` [y] верно [n] неверно [e] пропуск${hint}\n > `);
|
process.stdout.write(` [y] верно [n] неверно [e] пропуск${hint}\n > `);
|
||||||
const a = (await nextInput()) || "";
|
const a = (await nextInput()) || "";
|
||||||
|
|
||||||
let decision = null;
|
let decision: boolean | null = null;
|
||||||
if (a === "y" || a === "д") decision = true;
|
if (a === "y" || a === "д") decision = true;
|
||||||
else if (a === "n" || a === "н") decision = false;
|
else if (a === "n" || a === "н") decision = false;
|
||||||
else if (a === "" && cur) decision = e.row.correct;
|
else if (a === "" && cur) decision = e.row.correct;
|
||||||
@@ -130,10 +147,6 @@ async function main() {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let done = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
let changed = 0;
|
|
||||||
|
|
||||||
const reAsk = !!args.reAsk;
|
const reAsk = !!args.reAsk;
|
||||||
|
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
@@ -141,7 +154,7 @@ async function main() {
|
|||||||
const prev = decisions[k];
|
const prev = decisions[k];
|
||||||
|
|
||||||
if (prev === undefined || reAsk) {
|
if (prev === undefined || reAsk) {
|
||||||
done += await askOne(e, nextInput, decisions);
|
done += await askOne(e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +173,6 @@ async function main() {
|
|||||||
scenarios: buildReport({
|
scenarios: buildReport({
|
||||||
model,
|
model,
|
||||||
mcpUrl: report.mcpUrl,
|
mcpUrl: report.mcpUrl,
|
||||||
tasks: [],
|
|
||||||
rows: rep.perTask.map((r) => ({
|
rows: rep.perTask.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
scenario: r.scenario,
|
scenario: r.scenario,
|
||||||
@@ -191,7 +203,7 @@ async function main() {
|
|||||||
const scen = rep.scenarios;
|
const scen = rep.scenarios;
|
||||||
console.log(
|
console.log(
|
||||||
`${model} | ` +
|
`${model} | ` +
|
||||||
["control", "blind", "mentor"]
|
SCENARIO_NAMES
|
||||||
.map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}% (${scen[s].reviewed})`)
|
.map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}% (${scen[s].reviewed})`)
|
||||||
.join(" ") +
|
.join(" ") +
|
||||||
` всего rev=${g.reviewed}`
|
` всего rev=${g.reviewed}`
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { Ollama } from "../lib/ollama.mjs";
|
|
||||||
import { McpClient } from "../lib/mcpClient.mjs";
|
|
||||||
import { runScenario } from "./runner.mjs";
|
|
||||||
import { PROMPTS } from "./runner.mjs";
|
|
||||||
import { buildReport, buildAggregate } from "./report.mjs";
|
|
||||||
|
|
||||||
let logTarget = null;
|
|
||||||
async function log(msg) {
|
|
||||||
const line = `[${new Date().toISOString()}] ${msg}`;
|
|
||||||
console.log(line);
|
|
||||||
if (logTarget) {
|
|
||||||
try {
|
|
||||||
await appendFile(logTarget, line + "\n", "utf8");
|
|
||||||
} catch {
|
|
||||||
/* keep going */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCENARIOS = ["control", "blind", "mentor"];
|
|
||||||
|
|
||||||
const DEFAULT_MODELS = ["llama3.2:3b", "qwen3:4b", "gemma3:4b", "granite4.1:3b"];
|
|
||||||
const DEFAULT_MCP_URL = "https://mcp-liart-five.vercel.app/api/mcp";
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
|
||||||
const args = {
|
|
||||||
models: [],
|
|
||||||
scenarios: SCENARIOS,
|
|
||||||
limit: Infinity,
|
|
||||||
model: process.env.OLLAMA_MODEL || null,
|
|
||||||
mcpUrl: null,
|
|
||||||
tasksFile: null,
|
|
||||||
out: null,
|
|
||||||
};
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
|
||||||
const a = argv[i];
|
|
||||||
const next = () => argv[i + 1];
|
|
||||||
if (a === "--models") args.models = next().split(",").map((s) => s.trim()).filter(Boolean), i += 1;
|
|
||||||
else if (a === "--model") args.model = next(), i += 1;
|
|
||||||
else if (a === "--mcp-url") args.mcpUrl = next(), i += 1;
|
|
||||||
else if (a === "--scenarios") args.scenarios = next().split(","), i += 1;
|
|
||||||
else if (a === "--limit") args.limit = Number(next()), i += 1;
|
|
||||||
else if (a === "--tasks") args.tasksFile = next(), i += 1;
|
|
||||||
else if (a === "--out") args.out = next(), i += 1;
|
|
||||||
}
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runOneModel({ model, ollama, mcp, tasks, scenarios }) {
|
|
||||||
const rows = [];
|
|
||||||
for (const task of tasks) {
|
|
||||||
for (const scenario of scenarios) {
|
|
||||||
let r;
|
|
||||||
const t0 = Date.now();
|
|
||||||
try {
|
|
||||||
r = await runScenario({ scenario, ollama, model, task: task.question, mcp });
|
|
||||||
} catch (err) {
|
|
||||||
const ms = Date.now() - t0;
|
|
||||||
await log(` [${task.id}/${scenario}] FAILED (${ms}ms): ${err.message}`);
|
|
||||||
rows.push({
|
|
||||||
id: task.id,
|
|
||||||
scenario,
|
|
||||||
correct: null,
|
|
||||||
duckUsed: false,
|
|
||||||
toolCalls: 0,
|
|
||||||
promptTokens: 0,
|
|
||||||
genTokens: 0,
|
|
||||||
duckTokens: 0,
|
|
||||||
response: `ERR: ${err.message}`,
|
|
||||||
expected: task.answer,
|
|
||||||
question: task.question,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const correct = null;
|
|
||||||
rows.push({
|
|
||||||
id: task.id,
|
|
||||||
scenario,
|
|
||||||
correct,
|
|
||||||
duckUsed: r.duckUsed,
|
|
||||||
toolCalls: r.toolCalls,
|
|
||||||
promptTokens: r.promptTokens,
|
|
||||||
genTokens: r.genTokens,
|
|
||||||
duckTokens: r.duckTokens,
|
|
||||||
response: r.response,
|
|
||||||
expected: task.answer,
|
|
||||||
question: task.question,
|
|
||||||
});
|
|
||||||
const duck = r.duckUsed ? "duck" : "n/a ";
|
|
||||||
const ms = Date.now() - t0;
|
|
||||||
await log(` [${task.id}/${scenario}] duck=${duck} calls=${r.toolCalls} prompt=${r.promptTokens} gen=${r.genTokens} (${ms}ms)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const args = parseArgs(process.argv.slice(2));
|
|
||||||
|
|
||||||
let models = args.models;
|
|
||||||
if (args.model && !models.length) models = [args.model];
|
|
||||||
if (!models.length) models = DEFAULT_MODELS;
|
|
||||||
|
|
||||||
const mcpUrl = args.mcpUrl || DEFAULT_MCP_URL;
|
|
||||||
const tasksFile = args.tasksFile || path.join("data", "tasks.json");
|
|
||||||
|
|
||||||
const tasksRaw = JSON.parse(await readFile(tasksFile, "utf8"));
|
|
||||||
const tasks = tasksRaw.slice(0, args.limit === Infinity ? tasksRaw.length : args.limit);
|
|
||||||
|
|
||||||
const out = args.out || path.join("out", "report.json");
|
|
||||||
const logFile = args.logTarget || path.join(path.dirname(out), "run.log");
|
|
||||||
logTarget = logFile;
|
|
||||||
await mkdir(path.dirname(logFile), { recursive: true });
|
|
||||||
await appendFile(logFile, "", "utf8").catch(() => {});
|
|
||||||
|
|
||||||
await log(`MCP endpoint: ${mcpUrl}`);
|
|
||||||
await log(`Tasks: ${tasks.length}; Scenarios: [${args.scenarios.join(", ")}]`);
|
|
||||||
await log(`Models: ${models.join(", ")}`);
|
|
||||||
|
|
||||||
const ollama = new Ollama();
|
|
||||||
const ping = await ollama.ping();
|
|
||||||
await log(`Ollama OK (${ping.models.length} models): ${ping.models.join(", ")}`);
|
|
||||||
for (const m of models) {
|
|
||||||
const present = ping.models.some((x) => x.startsWith(m));
|
|
||||||
await log(` ${present ? "OK " : "MISSING "} ${m}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mcp = new McpClient(mcpUrl);
|
|
||||||
const mcpTools = await mcp.listTools();
|
|
||||||
await log(`MCP tools: ${mcpTools.map((t) => t.name).join(", ")}`);
|
|
||||||
|
|
||||||
const modelsReport = {};
|
|
||||||
for (const model of models) {
|
|
||||||
const tStart = Date.now();
|
|
||||||
await log(`==== Running model: ${model} ====`);
|
|
||||||
const rows = await runOneModel({ model, ollama, mcp, tasks, scenarios: args.scenarios });
|
|
||||||
const rep = buildReport({ model, mcpUrl, tasks, rows });
|
|
||||||
modelsReport[model] = {
|
|
||||||
model,
|
|
||||||
scenarios: rep.scenarios,
|
|
||||||
perTask: rep.perTask,
|
|
||||||
};
|
|
||||||
await log(`==== Done ${model} in ${Math.round((Date.now() - tStart) / 1000)}s ====`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggregate = buildAggregate(modelsReport);
|
|
||||||
|
|
||||||
const report = {
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
mcpUrl,
|
|
||||||
prompts: PROMPTS,
|
|
||||||
models: modelsReport,
|
|
||||||
aggregate,
|
|
||||||
};
|
|
||||||
|
|
||||||
await mkdir(path.dirname(out), { recursive: true });
|
|
||||||
await writeFile(out, JSON.stringify(report, null, 2), "utf8");
|
|
||||||
await log(`Report written: ${out}`);
|
|
||||||
|
|
||||||
await log("==== Aggregate summary ====");
|
|
||||||
for (const [modelName, g] of Object.entries(aggregate.perModel)) {
|
|
||||||
const scen = modelsReport[modelName].scenarios;
|
|
||||||
await log(
|
|
||||||
`${modelName} | ` +
|
|
||||||
SCENARIOS.map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}%`).join(" ") +
|
|
||||||
` duck=${g.duckUsed} calls=${g.toolCalls} avgGen=${g.avgGenTokens}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ScenarioName, ReportRoot, PerTaskRow, Task } from "@duck/types";
|
||||||
|
import { SCENARIO_NAMES } from "@duck/types";
|
||||||
|
import { Ollama } from "../lib/ollama.js";
|
||||||
|
import type { OllamaError } from "../lib/ollama.js";
|
||||||
|
import { McpClient } from "../lib/mcpClient.js";
|
||||||
|
import { runScenario, PROMPTS } from "./runner.js";
|
||||||
|
import { buildReport, buildAggregate } from "./report.js";
|
||||||
|
|
||||||
|
let logTarget: string | null = null;
|
||||||
|
async function log(msg: string): Promise<void> {
|
||||||
|
const line = `[${new Date().toISOString()}] ${msg}`;
|
||||||
|
console.log(line);
|
||||||
|
if (logTarget) {
|
||||||
|
try {
|
||||||
|
await appendFile(logTarget, line + "\n", "utf8");
|
||||||
|
} catch {
|
||||||
|
/* keep going */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MODELS = ["llama3.2:3b", "qwen3:1.7b", "qwen3:4b", "granite4.1:3b", "phi4-mini:3.8b"];
|
||||||
|
const DEFAULT_MCP_URL = "https://rubber-duck-mcp.vercel.app/api/mcp";
|
||||||
|
const REQUEST_TIMEOUT_MS = 60_000;
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
const PAUSE_BETWEEN_TASKS_MS = 2_000;
|
||||||
|
const PAUSE_BETWEEN_RETRIES_MS = 10_000;
|
||||||
|
|
||||||
|
function classifyError(err: unknown): string {
|
||||||
|
const name = (err as OllamaError)?.name ?? "";
|
||||||
|
const msg = String((err as OllamaError)?.message ?? "");
|
||||||
|
const low = `${name} ${msg}`.toLowerCase();
|
||||||
|
if (name === "TimeoutError" || low.includes("timeout") || low.includes("aborted")) return "timeout";
|
||||||
|
if (low.includes("fetch failed") || low.includes("connect") || low.includes("etimedout")) return "network";
|
||||||
|
if (low.includes("http 4") || low.includes("bad request") || low.includes("validation")) return "http4xx";
|
||||||
|
if (low.includes("http 5") || low.includes("server error")) return "http5xx";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
models: string[];
|
||||||
|
scenarios: ScenarioName[];
|
||||||
|
limit: number;
|
||||||
|
model: string | null;
|
||||||
|
mcpUrl: string | null;
|
||||||
|
tasksFile: string | null;
|
||||||
|
out: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Args {
|
||||||
|
const args: Args = {
|
||||||
|
models: [],
|
||||||
|
scenarios: [...SCENARIO_NAMES],
|
||||||
|
limit: Infinity,
|
||||||
|
model: process.env.OLLAMA_MODEL || null,
|
||||||
|
mcpUrl: null,
|
||||||
|
tasksFile: null,
|
||||||
|
out: null,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const a = argv[i];
|
||||||
|
const next = () => argv[i + 1];
|
||||||
|
if (a === "--models") args.models = next().split(",").map((s) => s.trim()).filter(Boolean), i += 1;
|
||||||
|
else if (a === "--model") args.model = next(), i += 1;
|
||||||
|
else if (a === "--mcp-url") args.mcpUrl = next(), i += 1;
|
||||||
|
else if (a === "--scenarios") args.scenarios = next().split(",") as ScenarioName[], i += 1;
|
||||||
|
else if (a === "--limit") args.limit = Number(next()), i += 1;
|
||||||
|
else if (a === "--tasks") args.tasksFile = next(), i += 1;
|
||||||
|
else if (a === "--out") args.out = next(), i += 1;
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOneModel({ model, ollama, mcp, tasks, scenarios }: { model: string; ollama: Ollama; mcp: McpClient; tasks: Task[]; scenarios: ScenarioName[] }): Promise<PerTaskRow[]> {
|
||||||
|
const rows: PerTaskRow[] = [];
|
||||||
|
for (const task of tasks) {
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
let r = null;
|
||||||
|
let lastErr: OllamaError | null = null;
|
||||||
|
const t0 = Date.now();
|
||||||
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
try {
|
||||||
|
r = await runScenario({ scenario, ollama, model, task: task.question, mcp });
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err as OllamaError;
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
await log(` [${task.id}/${scenario}] attempt ${attempt} FAILED (${ms}ms): ${(err as Error).message}`);
|
||||||
|
if (lastErr.partialContent != null || lastErr.partialToolCalls != null) {
|
||||||
|
await log(
|
||||||
|
` partial before abort: chars=${lastErr.partialContent?.length ?? 0} ` +
|
||||||
|
`toolCalls=${lastErr.partialToolCalls?.length ?? 0} ` +
|
||||||
|
`gen=${lastErr.partialGen ?? 0}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (attempt < MAX_ATTEMPTS) await new Promise((res) => setTimeout(res, PAUSE_BETWEEN_RETRIES_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!r) {
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
const reason = classifyError(lastErr);
|
||||||
|
await log(` [${task.id}/${scenario}] SKIPPED after ${MAX_ATTEMPTS} attempts (${ms}ms) reason=${reason}`);
|
||||||
|
await log(` last error: ${lastErr?.name} | ${lastErr?.message}`);
|
||||||
|
if (lastErr?.stack) await log(` stack: ${String(lastErr.stack).split("\n").slice(0, 3).join(" | ")}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const correct = null;
|
||||||
|
rows.push({
|
||||||
|
id: task.id,
|
||||||
|
scenario,
|
||||||
|
correct,
|
||||||
|
duckUsed: r.duckUsed,
|
||||||
|
toolCalls: r.toolCalls,
|
||||||
|
promptTokens: r.promptTokens,
|
||||||
|
genTokens: r.genTokens,
|
||||||
|
duckTokens: r.duckTokens,
|
||||||
|
response: r.response,
|
||||||
|
expected: task.answer,
|
||||||
|
question: task.question,
|
||||||
|
});
|
||||||
|
const duck = r.duckUsed ? "duck" : "n/a ";
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
await log(` [${task.id}/${scenario}] duck=${duck} calls=${r.toolCalls} prompt=${r.promptTokens} gen=${r.genTokens} (${ms}ms)`);
|
||||||
|
}
|
||||||
|
if (tasks.length > 1 && task !== tasks[tasks.length - 1]) {
|
||||||
|
await new Promise((res) => setTimeout(res, PAUSE_BETWEEN_TASKS_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
let models = args.models;
|
||||||
|
if (args.model && !models.length) models = [args.model];
|
||||||
|
if (!models.length) models = DEFAULT_MODELS;
|
||||||
|
|
||||||
|
const mcpUrl = args.mcpUrl || DEFAULT_MCP_URL;
|
||||||
|
const tasksFile = args.tasksFile || path.join("data", "tasks.json");
|
||||||
|
|
||||||
|
const tasksRaw: Task[] = JSON.parse(await readFile(tasksFile, "utf8"));
|
||||||
|
const tasks = tasksRaw.slice(0, args.limit === Infinity ? tasksRaw.length : args.limit);
|
||||||
|
|
||||||
|
const out = args.out || path.join("out", "report.json");
|
||||||
|
const logFile = path.join(path.dirname(out), "run.log");
|
||||||
|
logTarget = logFile;
|
||||||
|
await mkdir(path.dirname(logFile), { recursive: true });
|
||||||
|
await appendFile(logFile, "", "utf8").catch(() => {});
|
||||||
|
|
||||||
|
await log(`MCP endpoint: ${mcpUrl}`);
|
||||||
|
await log(`Tasks: ${tasks.length}; Scenarios: [${args.scenarios.join(", ")}]`);
|
||||||
|
await log(`Models: ${models.join(", ")}`);
|
||||||
|
|
||||||
|
const ollama = new Ollama({ timeoutMs: REQUEST_TIMEOUT_MS, idleTimeoutMs: REQUEST_TIMEOUT_MS });
|
||||||
|
const ping = await ollama.ping();
|
||||||
|
await log(`Ollama OK (${ping.models.length} models): ${ping.models.join(", ")}`);
|
||||||
|
for (const m of models) {
|
||||||
|
const present = ping.models.some((x) => x.startsWith(m));
|
||||||
|
await log(` ${present ? "OK " : "MISSING "} ${m}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcp = new McpClient(mcpUrl);
|
||||||
|
const mcpTools = await mcp.listTools();
|
||||||
|
await log(`MCP tools: ${mcpTools.map((t) => t.name).join(", ")}`);
|
||||||
|
|
||||||
|
const modelsReport: ReportRoot["models"] = {};
|
||||||
|
for (const model of models) {
|
||||||
|
const tStart = Date.now();
|
||||||
|
await log(`==== Running model: ${model} ====`);
|
||||||
|
const warm = await ollama.warmup({ model });
|
||||||
|
const warmMs = Number(warm?.promptEvalCount ?? 0) > 0 || warm?.content ? Date.now() - tStart : 0;
|
||||||
|
if (warm?.error) {
|
||||||
|
await log(` warmup FAILED: ${warm.error} (continuing anyway)`);
|
||||||
|
} else {
|
||||||
|
await log(` warmup OK (${warmMs}ms, gen=${warm?.evalCount ?? 0})`);
|
||||||
|
}
|
||||||
|
const rows = await runOneModel({ model, ollama, mcp, tasks, scenarios: args.scenarios });
|
||||||
|
const rep = buildReport({ model, mcpUrl, rows });
|
||||||
|
modelsReport[model] = {
|
||||||
|
model,
|
||||||
|
scenarios: rep.scenarios,
|
||||||
|
perTask: rep.perTask,
|
||||||
|
} as ReportRoot["models"][string];
|
||||||
|
await log(`==== Done ${model} in ${Math.round((Date.now() - tStart) / 1000)}s ====`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregate = buildAggregate(modelsReport);
|
||||||
|
|
||||||
|
const report: ReportRoot = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
mcpUrl,
|
||||||
|
prompts: PROMPTS,
|
||||||
|
models: modelsReport,
|
||||||
|
aggregate,
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(path.dirname(out), { recursive: true });
|
||||||
|
await writeFile(out, JSON.stringify(report, null, 2), "utf8");
|
||||||
|
await log(`Report written: ${out}`);
|
||||||
|
|
||||||
|
await log("==== Aggregate summary ====");
|
||||||
|
for (const [modelName, g] of Object.entries(aggregate.perModel)) {
|
||||||
|
const scen = modelsReport[modelName].scenarios;
|
||||||
|
await log(
|
||||||
|
`${modelName} | ` +
|
||||||
|
SCENARIO_NAMES.map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}%`).join(" ") +
|
||||||
|
` duck=${g.duckUsed} calls=${g.toolCalls} avgGen=${g.avgGenTokens}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { Ollama } from "../lib/ollama.mjs";
|
|
||||||
import { McpClient, textContentFrom } from "../lib/mcpClient.mjs";
|
|
||||||
|
|
||||||
const DUCK_TOOL = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "quack",
|
|
||||||
description:
|
|
||||||
"Explain your current reasoning, steps, doubts or possible mistakes to your rubber duck colleague, and get a short acknowledgment in return. The duck replies with a quack.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
mood: {
|
|
||||||
type: "string",
|
|
||||||
enum: ["happy", "confused", "excited", "sleepy"],
|
|
||||||
description: "Mood of the duck while you explain. Omit for a random quack.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTROL_SYSTEM =
|
|
||||||
"You are an experienced assistant. Solve the user's problem as accurately as possible. Give only the final answer.";
|
|
||||||
|
|
||||||
const BLIND_SYSTEM =
|
|
||||||
"You are solving a difficult problem. To solve it better, you work in a pair with a colleague. " +
|
|
||||||
"First, describe out loud in detail to the colleague the essence of your approach, your first steps, and any doubts. " +
|
|
||||||
"Wait for the colleague's reply. Then, taking that reply into account, complete your solution and give the final answer to the user.";
|
|
||||||
|
|
||||||
const MENTOR_SYSTEM =
|
|
||||||
"You are solving a difficult problem. Before giving your final answer you MUST write down in detail, for your rubber duck, " +
|
|
||||||
"your reasoning and the possible mistakes you might be making. Then call the 'quack' tool so the duck can reply. " +
|
|
||||||
"Use the duck's reply to double-check yourself, find bugs, and only after that give the perfect final answer to the user.";
|
|
||||||
|
|
||||||
export const PROMPTS = {
|
|
||||||
control: CONTROL_SYSTEM,
|
|
||||||
blind: BLIND_SYSTEM,
|
|
||||||
mentor: MENTOR_SYSTEM,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_DUCK_TURNS = 6;
|
|
||||||
|
|
||||||
function toolDefinition() {
|
|
||||||
return DUCK_TOOL;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runControl(ollama, model, task) {
|
|
||||||
const messages = [
|
|
||||||
{ role: "system", content: CONTROL_SYSTEM },
|
|
||||||
{ role: "user", content: task },
|
|
||||||
];
|
|
||||||
const r = await ollama.chat({ model, messages });
|
|
||||||
return {
|
|
||||||
response: r.content,
|
|
||||||
toolCalls: 0,
|
|
||||||
duckUsed: false,
|
|
||||||
duckTokens: 0,
|
|
||||||
promptTokens: r.promptEvalCount,
|
|
||||||
genTokens: r.evalCount,
|
|
||||||
messages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runDuck(ollama, model, task, mcp, system, scenario) {
|
|
||||||
const messages = [
|
|
||||||
{ role: "system", content: system },
|
|
||||||
{ role: "user", content: task },
|
|
||||||
];
|
|
||||||
let toolCalls = 0;
|
|
||||||
let duckUsed = false;
|
|
||||||
let totalPrompt = 0;
|
|
||||||
let totalGen = 0;
|
|
||||||
let reasoningTokens = 0;
|
|
||||||
let turns = 0;
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
turns += 1;
|
|
||||||
const r = await ollama.chat({ model, messages, tools: [toolDefinition()] });
|
|
||||||
totalPrompt += r.promptEvalCount;
|
|
||||||
totalGen += r.evalCount;
|
|
||||||
reasoningTokens += r.evalCount;
|
|
||||||
|
|
||||||
if (r.toolCalls && r.toolCalls.length) {
|
|
||||||
toolCalls += r.toolCalls.length;
|
|
||||||
const assistantMsg = { role: "assistant", content: r.content || "" };
|
|
||||||
assistantMsg.tool_calls = r.toolCalls.map((tc) => ({
|
|
||||||
function: { name: tc.function.name, arguments: tc.function.arguments },
|
|
||||||
}));
|
|
||||||
messages.push(assistantMsg);
|
|
||||||
|
|
||||||
for (const tc of r.toolCalls) {
|
|
||||||
if (tc.function.name === "quack") {
|
|
||||||
duckUsed = true;
|
|
||||||
let args = {};
|
|
||||||
try {
|
|
||||||
args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
|
|
||||||
} catch {
|
|
||||||
args = {};
|
|
||||||
}
|
|
||||||
const result = await mcp.callTool("quack", args);
|
|
||||||
const text = textContentFrom(result) || "QUACK!";
|
|
||||||
messages.push({ role: "tool", content: text });
|
|
||||||
} else {
|
|
||||||
messages.push({ role: "tool", content: "{}" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (turns >= MAX_DUCK_TURNS) break;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({ role: "assistant", content: r.content || "" });
|
|
||||||
return {
|
|
||||||
response: r.content,
|
|
||||||
toolCalls,
|
|
||||||
duckUsed,
|
|
||||||
duckTokens: reasoningTokens,
|
|
||||||
promptTokens: totalPrompt,
|
|
||||||
genTokens: totalGen,
|
|
||||||
turns,
|
|
||||||
messages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// loop bound hit without a final answer
|
|
||||||
const last = messages[messages.length - 1];
|
|
||||||
return {
|
|
||||||
response: last?.content ?? "",
|
|
||||||
toolCalls,
|
|
||||||
duckUsed,
|
|
||||||
duckTokens: reasoningTokens,
|
|
||||||
promptTokens: totalPrompt,
|
|
||||||
genTokens: totalGen,
|
|
||||||
turns,
|
|
||||||
messages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runScenario({ scenario, ollama, model, task, mcp }) {
|
|
||||||
if (scenario === "control") {
|
|
||||||
return runControl(ollama, model, task);
|
|
||||||
}
|
|
||||||
const system = scenario === "blind" ? BLIND_SYSTEM : MENTOR_SYSTEM;
|
|
||||||
return runDuck(ollama, model, task, mcp, system, scenario);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import type { ScenarioName } from "@duck/types";
|
||||||
|
import { Ollama } from "../lib/ollama.js";
|
||||||
|
import type { OllamaMessage, OllamaToolDefinition } from "../lib/ollama.js";
|
||||||
|
import { McpClient, textContentFrom } from "../lib/mcpClient.js";
|
||||||
|
|
||||||
|
const DUCK_TOOL: OllamaToolDefinition = {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "quack",
|
||||||
|
description:
|
||||||
|
"Call this tool to state your reasoning out loud - your approach, steps, doubts or possible mistakes. " +
|
||||||
|
"It replies with a short acknowledgment. Do not ask the user to confirm anything and do not invent its reply yourself; " +
|
||||||
|
"the tool answers on your behalf.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
mood: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["happy", "confused", "excited", "sleepy"],
|
||||||
|
description: "Mood of the reply. Omit for a random one.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTROL_SYSTEM =
|
||||||
|
"You are an experienced assistant. Solve the user's problem as accurately as possible. " +
|
||||||
|
"Give a clear, concrete final answer to the question - a definite result, not a tentative guess or a request for confirmation.";
|
||||||
|
|
||||||
|
const THINKING_SYSTEM =
|
||||||
|
"You are solving a difficult problem. Before giving your final answer, write out your reasoning step by step: " +
|
||||||
|
"your approach, each step, and any doubts or mistakes you notice along the way. " +
|
||||||
|
"Then give a clear, concrete final answer to the user's question - a definite result, not a request for confirmation.";
|
||||||
|
|
||||||
|
const BLIND_SYSTEM =
|
||||||
|
"You are solving a problem that the user asked you. To reason better, you use a separate tool named 'quack': " +
|
||||||
|
"you call it yourself to state your thinking out loud. Call the tool and spell out your approach, each step, and any " +
|
||||||
|
"doubts or mistakes you might be making, then wait for its short reply. Do not ask the user to confirm anything, and do not " +
|
||||||
|
"guess or invent the tool's reply yourself - the tool answers on your behalf. " +
|
||||||
|
"After the tool's reply, give the user a clear, concrete final answer to the question.";
|
||||||
|
|
||||||
|
const MENTOR_SYSTEM =
|
||||||
|
"You are solving a problem that the user asked you. To reason better, you use a separate tool named 'quack': " +
|
||||||
|
"you call it yourself to state your thinking out loud. Call the tool and spell out your approach, each step, and any " +
|
||||||
|
"doubts or mistakes you might be making, then wait for its short reply. Do not ask the user to confirm anything, and do not " +
|
||||||
|
"guess or invent the tool's reply yourself - the tool answers on your behalf. " +
|
||||||
|
"Note: the tool 'quack' is a rubber duck and will only ever reply with just 'quack' - it gives no useful information. " +
|
||||||
|
"Treat it as a way to voice your thoughts out loud, not as a source of answers. " +
|
||||||
|
"After the tool's reply, give the user a clear, concrete final answer to the question.";
|
||||||
|
|
||||||
|
export const PROMPTS: Record<ScenarioName, string> = {
|
||||||
|
control: CONTROL_SYSTEM,
|
||||||
|
thinking: THINKING_SYSTEM,
|
||||||
|
blind: BLIND_SYSTEM,
|
||||||
|
mentor: MENTOR_SYSTEM,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_DUCK_TURNS = 6;
|
||||||
|
|
||||||
|
interface RunnerResult {
|
||||||
|
response: string;
|
||||||
|
toolCalls: number;
|
||||||
|
duckUsed: boolean;
|
||||||
|
duckTokens: number;
|
||||||
|
promptTokens: number;
|
||||||
|
genTokens: number;
|
||||||
|
turns?: number;
|
||||||
|
messages: OllamaMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolDefinition(): OllamaToolDefinition {
|
||||||
|
return DUCK_TOOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNoTool(ollama: Ollama, model: string, task: string, system: string): Promise<RunnerResult> {
|
||||||
|
const messages = [
|
||||||
|
{ role: "system", content: system },
|
||||||
|
{ role: "user", content: task },
|
||||||
|
];
|
||||||
|
const r = await ollama.chat({ model, messages });
|
||||||
|
return {
|
||||||
|
response: r.content,
|
||||||
|
toolCalls: 0,
|
||||||
|
duckUsed: false,
|
||||||
|
duckTokens: 0,
|
||||||
|
promptTokens: r.promptEvalCount,
|
||||||
|
genTokens: r.evalCount,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runControl(ollama: Ollama, model: string, task: string): Promise<RunnerResult> {
|
||||||
|
return runNoTool(ollama, model, task, CONTROL_SYSTEM);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runThinking(ollama: Ollama, model: string, task: string): Promise<RunnerResult> {
|
||||||
|
return runNoTool(ollama, model, task, THINKING_SYSTEM);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDuck(ollama: Ollama, model: string, task: string, mcp: McpClient, system: string, scenario: ScenarioName): Promise<RunnerResult> {
|
||||||
|
const messages: OllamaMessage[] = [
|
||||||
|
{ role: "system", content: system },
|
||||||
|
{ role: "user", content: task },
|
||||||
|
];
|
||||||
|
let toolCalls = 0;
|
||||||
|
let duckUsed = false;
|
||||||
|
let totalPrompt = 0;
|
||||||
|
let totalGen = 0;
|
||||||
|
let reasoningTokens = 0;
|
||||||
|
let turns = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
turns += 1;
|
||||||
|
const r = await ollama.chat({ model, messages, tools: [toolDefinition()] });
|
||||||
|
totalPrompt += r.promptEvalCount;
|
||||||
|
totalGen += r.evalCount;
|
||||||
|
reasoningTokens += r.evalCount;
|
||||||
|
|
||||||
|
if (r.toolCalls && r.toolCalls.length) {
|
||||||
|
toolCalls += r.toolCalls.length;
|
||||||
|
const assistantMsg: OllamaMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: r.content || "",
|
||||||
|
tool_calls: r.toolCalls.map((tc) => ({
|
||||||
|
function: { name: tc.function.name, arguments: tc.function.arguments },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
messages.push(assistantMsg);
|
||||||
|
|
||||||
|
for (const tc of r.toolCalls) {
|
||||||
|
if (tc.function.name === "quack") {
|
||||||
|
duckUsed = true;
|
||||||
|
let args: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
args = tc.function.arguments ? JSON.parse(tc.function.arguments) as Record<string, unknown> : {};
|
||||||
|
} catch {
|
||||||
|
args = {};
|
||||||
|
}
|
||||||
|
const result = await mcp.callTool("quack", args);
|
||||||
|
const text = textContentFrom(result) || "QUACK!";
|
||||||
|
messages.push({ role: "tool", content: text });
|
||||||
|
} else {
|
||||||
|
messages.push({ role: "tool", content: "{}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (turns >= MAX_DUCK_TURNS) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({ role: "assistant", content: r.content || "" });
|
||||||
|
return {
|
||||||
|
response: r.content,
|
||||||
|
toolCalls,
|
||||||
|
duckUsed,
|
||||||
|
duckTokens: reasoningTokens,
|
||||||
|
promptTokens: totalPrompt,
|
||||||
|
genTokens: totalGen,
|
||||||
|
turns,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = messages[messages.length - 1];
|
||||||
|
return {
|
||||||
|
response: last?.content ?? "",
|
||||||
|
toolCalls,
|
||||||
|
duckUsed,
|
||||||
|
duckTokens: reasoningTokens,
|
||||||
|
promptTokens: totalPrompt,
|
||||||
|
genTokens: totalGen,
|
||||||
|
turns,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunScenarioOpts {
|
||||||
|
scenario: ScenarioName;
|
||||||
|
ollama: Ollama;
|
||||||
|
model: string;
|
||||||
|
task: string;
|
||||||
|
mcp: McpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runScenario({ scenario, ollama, model, task, mcp }: RunScenarioOpts): Promise<RunnerResult> {
|
||||||
|
if (scenario === "control") {
|
||||||
|
return runControl(ollama, model, task);
|
||||||
|
}
|
||||||
|
if (scenario === "thinking") {
|
||||||
|
return runThinking(ollama, model, task);
|
||||||
|
}
|
||||||
|
const system = scenario === "blind" ? BLIND_SYSTEM : MENTOR_SYSTEM;
|
||||||
|
return runDuck(ollama, model, task, mcp, system, scenario);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"paths": {
|
||||||
|
"@duck/types": ["../../packages/types/src/index.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "lib"]
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.vercel
|
|
||||||
|
Before Width: | Height: | Size: 2.2 MiB |
@@ -1,30 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Rubber Duck - Voice Activity Detector</title>
|
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🦆</text></svg>" />
|
|
||||||
<link rel="stylesheet" href="style.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div id="duck-wrapper">
|
|
||||||
<div id="duck-indicator" class="silent">
|
|
||||||
<svg viewBox="0 0 100 100" class="duck-icon">
|
|
||||||
<path d="M35 60 Q20 55 15 45 Q10 35 20 30 Q25 28 30 30 Q28 20 35 15 Q42 10 50 15 Q55 12 60 18 Q65 15 68 22 Q75 25 78 32 Q85 35 82 45 Q80 50 75 52 Q80 58 75 65 Q70 72 60 70 Q55 75 45 75 Q40 72 35 70 Z" fill="#FFD700" stroke="#DAA520" stroke-width="2"/>
|
|
||||||
<circle cx="45" cy="38" r="5" fill="#333"/>
|
|
||||||
<path d="M38 28 Q42 25 46 28" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"/>
|
|
||||||
<ellipse cx="68" cy="45" rx="10" ry="6" fill="#FF8C00" opacity="0.6"/>
|
|
||||||
</svg>
|
|
||||||
<div id="glow-ring"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p id="status-text">Click "Start" to begin</p>
|
|
||||||
<button id="toggle-btn" class="btn">Start</button>
|
|
||||||
<p id="error-text" class="error hidden"></p>
|
|
||||||
<p class="hint">Grant microphone access to start voice detection</p>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="/main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
const indicator = document.getElementById('duck-indicator')
|
|
||||||
const statusText = document.getElementById('status-text')
|
|
||||||
const toggleBtn = document.getElementById('toggle-btn')
|
|
||||||
const errorText = document.getElementById('error-text')
|
|
||||||
|
|
||||||
let detector = null
|
|
||||||
|
|
||||||
class SmartVoiceDetector {
|
|
||||||
constructor(options = {}) {
|
|
||||||
this.threshold = options.threshold ?? -50
|
|
||||||
this.silenceDelay = options.silenceDelay ?? 700
|
|
||||||
this.onSpeechStart = options.onSpeechStart || (() => {})
|
|
||||||
this.onSpeechEnd = options.onSpeechEnd || (() => {})
|
|
||||||
|
|
||||||
this.isSpeaking = false
|
|
||||||
this.timeout = null
|
|
||||||
this.audioContext = null
|
|
||||||
this.running = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
||||||
this.audioContext = new AudioContext()
|
|
||||||
const source = this.audioContext.createMediaStreamSource(stream)
|
|
||||||
const analyser = this.audioContext.createAnalyser()
|
|
||||||
|
|
||||||
analyser.fftSize = 1024
|
|
||||||
analyser.smoothingTimeConstant = 0.4
|
|
||||||
source.connect(analyser)
|
|
||||||
|
|
||||||
const bufferLength = analyser.frequencyBinCount
|
|
||||||
const dataArray = new Float32Array(bufferLength)
|
|
||||||
|
|
||||||
const sampleRate = this.audioContext.sampleRate
|
|
||||||
const minHz = 85
|
|
||||||
const maxHz = 3000
|
|
||||||
const minIndex = Math.floor(minHz / (sampleRate / analyser.fftSize))
|
|
||||||
const maxIndex = Math.ceil(maxHz / (sampleRate / analyser.fftSize))
|
|
||||||
|
|
||||||
this.running = true
|
|
||||||
const check = () => {
|
|
||||||
if (!this.running) return
|
|
||||||
|
|
||||||
analyser.getFloatFrequencyData(dataArray)
|
|
||||||
|
|
||||||
let maxVolume = -Infinity
|
|
||||||
for (let i = minIndex; i <= maxIndex; i++) {
|
|
||||||
if (dataArray[i] > maxVolume) maxVolume = dataArray[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxVolume > this.threshold) {
|
|
||||||
if (!this.isSpeaking) {
|
|
||||||
this.isSpeaking = true
|
|
||||||
this.onSpeechStart()
|
|
||||||
}
|
|
||||||
clearTimeout(this.timeout)
|
|
||||||
this.timeout = null
|
|
||||||
} else if (this.isSpeaking && !this.timeout) {
|
|
||||||
this.timeout = setTimeout(() => {
|
|
||||||
this.isSpeaking = false
|
|
||||||
this.onSpeechEnd()
|
|
||||||
}, this.silenceDelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(check)
|
|
||||||
}
|
|
||||||
|
|
||||||
check()
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.running = false
|
|
||||||
if (this.audioContext) {
|
|
||||||
this.audioContext.close()
|
|
||||||
this.audioContext = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleBtn.addEventListener('click', async () => {
|
|
||||||
if (detector) {
|
|
||||||
detector.stop()
|
|
||||||
detector = null
|
|
||||||
statusText.textContent = 'Stopped'
|
|
||||||
toggleBtn.textContent = 'Start'
|
|
||||||
indicator.classList.remove('speaking')
|
|
||||||
indicator.classList.add('silent')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleBtn.disabled = true
|
|
||||||
statusText.textContent = 'Starting...'
|
|
||||||
|
|
||||||
try {
|
|
||||||
detector = new SmartVoiceDetector({
|
|
||||||
threshold: -50,
|
|
||||||
onSpeechStart: () => {
|
|
||||||
indicator.classList.remove('silent')
|
|
||||||
indicator.classList.add('speaking')
|
|
||||||
statusText.textContent = 'Voice detected'
|
|
||||||
},
|
|
||||||
onSpeechEnd: () => {
|
|
||||||
indicator.classList.remove('speaking')
|
|
||||||
indicator.classList.add('silent')
|
|
||||||
statusText.textContent = 'Listening...'
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await detector.start()
|
|
||||||
statusText.textContent = 'Listening...'
|
|
||||||
toggleBtn.textContent = 'Stop'
|
|
||||||
toggleBtn.disabled = false
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Mic error:', err)
|
|
||||||
errorText.textContent = err.message || String(err)
|
|
||||||
errorText.classList.remove('hidden')
|
|
||||||
statusText.textContent = 'Error'
|
|
||||||
toggleBtn.textContent = 'Retry'
|
|
||||||
toggleBtn.disabled = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "rubber-duck",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "Best debug tool - rubber duck",
|
|
||||||
"keywords": [
|
|
||||||
"rubber",
|
|
||||||
"duck",
|
|
||||||
"debug",
|
|
||||||
"tool",
|
|
||||||
"fun"
|
|
||||||
],
|
|
||||||
"homepage": "https://github.com/Ku6epXBOCTuK/rubber-duck#readme",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/Ku6epXBOCTuK/rubber-duck/issues"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/Ku6epXBOCTuK/rubber-duck.git"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"author": "Ku6epXBOCTuK",
|
|
||||||
"type": "module",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"vite": "^8.0.11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: #1a1a2e;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#duck-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
#duck-indicator {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
transition: filter 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duck-icon {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#glow-ring {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#duck-indicator.silent #glow-ring {
|
|
||||||
background: radial-gradient(circle, rgba(255, 50, 50, 0.3) 0%, transparent 70%);
|
|
||||||
box-shadow: 0 0 40px 10px rgba(255, 50, 50, 0.2), inset 0 0 40px 10px rgba(255, 50, 50, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
#duck-indicator.speaking #glow-ring {
|
|
||||||
background: radial-gradient(circle, rgba(50, 255, 50, 0.4) 0%, transparent 70%);
|
|
||||||
box-shadow: 0 0 60px 20px rgba(50, 255, 50, 0.3), inset 0 0 60px 20px rgba(50, 255, 50, 0.1);
|
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { transform: scale(1); opacity: 1; }
|
|
||||||
50% { transform: scale(1.08); opacity: 0.85; }
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-text {
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding: 10px 28px;
|
|
||||||
font-size: 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #16213e;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: #0f3460;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
margin-top: 24px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
margin-top: 12px;
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
assetsInlineLimit: 0,
|
|
||||||
})
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
.vercel
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
|
||||||
|
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
|
||||||
|
|
||||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
|
||||||
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@AGENTS.md
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Rubber Duck MCP
|
|
||||||
|
|
||||||
MCP-сервер для техники «резиновая уточка»: когда ИИ застревает в логическом цикле или склонен к
|
|
||||||
галлюцинациям, он вызывает инструмент `quack()`, чтобы сформулировать мысль и продолжить.
|
|
||||||
|
|
||||||
Сервер работает в режиме **Streamable HTTP** (stateless), развёртывается на Vercel, использует
|
|
||||||
`mcp-handler` v2 + `@modelcontextprotocol/server` v2.
|
|
||||||
|
|
||||||
## Эндпоинт
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
Подключите этот URL как MCP-сервер (тип Streamable HTTP) в Cursor, Claude Desktop и других клиентах.
|
|
||||||
|
|
||||||
## Инструменты
|
|
||||||
|
|
||||||
- `quack` — возвращает кряк. Опциональный параметр `mood`: `happy | confused | excited | sleepy`.
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev # http://localhost:3000
|
|
||||||
pnpm build
|
|
||||||
pnpm start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Проверка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/mcp \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Accept: application/json, text/event-stream" \
|
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"quack","arguments":{"mood":"happy"}}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Деплой на Vercel
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vercel --prod
|
|
||||||
```
|
|
||||||
|
|
||||||
(`rootDirectory` проекта — `apps/mcp`.)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { createMcpHandler } from "mcp-handler";
|
|
||||||
import { registerDuckTools } from "@/lib/mcp";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const post = createMcpHandler(
|
|
||||||
async (server) => {
|
|
||||||
registerDuckTools(server);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverInfo: { name: "rubber-duck-mcp", version: "1.0.0" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
return post(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
return post(req);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,49 +0,0 @@
|
|||||||
:root {
|
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
max-width: 100vw;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
color: var(--foreground);
|
|
||||||
background: var(--background);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
html {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Rubber Duck MCP",
|
|
||||||
description: "MCP server for rubber duck debugging — quack() to articulate your thinking.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body>{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 640, margin: "0 auto", padding: "3rem 1rem" }}>
|
|
||||||
<h1>Rubber Duck MCP</h1>
|
|
||||||
<p>
|
|
||||||
MCP-сервер для техники «резиновая уточка»: когда ИИ застревает, он вызывает инструмент{" "}
|
|
||||||
<code>quack()</code>, чтобы сформулировать мысль и продолжить. Сервер работает в режиме
|
|
||||||
Streamable HTTP и предназначен для подключения в Cursor, Claude Desktop и других MCP-клиентах.
|
|
||||||
</p>
|
|
||||||
<h2>Эндпоинт</h2>
|
|
||||||
<p>
|
|
||||||
<code>/api/mcp</code> — подключите этот URL как MCP-сервер (тип Streamable HTTP).
|
|
||||||
</p>
|
|
||||||
<h2>Инструменты</h2>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<code>quack</code> — возвращает кряк. Опциональный параметр <code>mood</code>:{" "}
|
|
||||||
<code>happy | confused | excited | sleepy</code>.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mcp",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@modelcontextprotocol/server": "^2.0.0",
|
|
||||||
"mcp-handler": "^2.1.1",
|
|
||||||
"next": "16.3.3",
|
|
||||||
"react": "19.2.8",
|
|
||||||
"react-dom": "19.2.8",
|
|
||||||
"zod": "^4.5.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"typescript": "^5"
|
|
||||||
},
|
|
||||||
"packageManager": "pnpm@11.20.0"
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2017",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts",
|
|
||||||
"**/*.mts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
npx sv@0.17.0 create --template minimal --types ts --no-install apps/web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"typecheck": "svelte-kit sync && tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-vercel": "^6.3.4",
|
||||||
|
"@sveltejs/kit": "^2.63.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@types/node": "^20.19.43",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@duck/types": "workspace:*",
|
||||||
|
"@modelcontextprotocol/server": "^2.0.0",
|
||||||
|
"mcp-handler": "^2.1.1",
|
||||||
|
"zod": "^4.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f172a;
|
||||||
|
--panel: #1e293b;
|
||||||
|
--panel2: #273449;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--ok: #10b981;
|
||||||
|
--no: #f87171;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--border: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--panel2);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>rubber-duck</title><circle cx="32" cy="30" r="24" fill="#ffd93b" stroke="#d9a413" stroke-width="3"/><circle cx="24" cy="25" r="5" fill="#fff"/><circle cx="40" cy="25" r="5" fill="#fff"/><circle cx="25.5" cy="26" r="2.6" fill="#111"/><circle cx="41.5" cy="26" r="2.6" fill="#111"/><ellipse cx="32" cy="41" rx="15" ry="9" fill="#f5883c" stroke="#d9701c" stroke-width="2.5"/><path d="M19 40q13 7 26 0" fill="none" stroke="#b35410" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 543 B |
@@ -0,0 +1,231 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { outfit, duckState } from '$lib/duck/state.svelte';
|
||||||
|
import { simulateMcpQuack, randomTip, playQuack, moodLabel, type Mood } from '$lib/duck/quack';
|
||||||
|
|
||||||
|
type Msg = {
|
||||||
|
id: number;
|
||||||
|
role: 'user' | 'duck';
|
||||||
|
text: string;
|
||||||
|
mood?: Mood;
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages = $state<Msg[]>([
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
role: 'duck',
|
||||||
|
text: 'Кря! Я твоя rubber duck. Расскажи, что строишь, — я выслушаю и подскажу.'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
let draft = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let listEl: HTMLElement | undefined = $state();
|
||||||
|
let nextId = $state(1);
|
||||||
|
|
||||||
|
const CHIPS = ['Застрял на баге 🌀', 'Почему не работает?', 'Ура, получилось! 🎉', 'Я устал...'];
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void messages.length;
|
||||||
|
listEl?.scrollTo({ top: listEl.scrollHeight, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function send(raw: string) {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!text || busy) return;
|
||||||
|
busy = true;
|
||||||
|
draft = '';
|
||||||
|
messages.push({ id: nextId++, role: 'user', text });
|
||||||
|
duckState.speaking = true;
|
||||||
|
const sim = await simulateMcpQuack(text);
|
||||||
|
messages.push({
|
||||||
|
id: nextId++,
|
||||||
|
role: 'duck',
|
||||||
|
text: `${sim.content[0].text}\nКстати: ${randomTip()}`,
|
||||||
|
mood: sim.arguments.mood
|
||||||
|
});
|
||||||
|
duckState.speaking = false;
|
||||||
|
busy = false;
|
||||||
|
if (outfit.sound) playQuack();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void send(draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chat">
|
||||||
|
<div class="list" bind:this={listEl}>
|
||||||
|
{#each messages as msg (msg.id)}
|
||||||
|
<div class="msg {msg.role}">
|
||||||
|
<div class="bubble">
|
||||||
|
{#if msg.role === 'duck'}<span class="who">🦆</span>{/if}
|
||||||
|
<div class="text">{msg.text}</div>
|
||||||
|
</div>
|
||||||
|
{#if msg.role === 'duck' && msg.mood}
|
||||||
|
<div class="meta mono">tools/call(quack, mood={msg.mood}) · {moodLabel(msg.mood)} · симуляция</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if busy}
|
||||||
|
<div class="msg duck">
|
||||||
|
<div class="bubble thinking"><span class="who">🦆</span><span class="dots">кря-кря<i>.</i><i>.</i><i>.</i></span></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chips">
|
||||||
|
{#each CHIPS as chip}
|
||||||
|
<button class="chip" disabled={busy} onclick={() => void send(chip)}>{chip}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-row">
|
||||||
|
<textarea
|
||||||
|
rows="2"
|
||||||
|
placeholder="Расскажи утке о проблеме…"
|
||||||
|
bind:value={draft}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
disabled={busy}
|
||||||
|
></textarea>
|
||||||
|
<button class="send" onclick={() => void send(draft)} disabled={busy || !draft.trim()}>
|
||||||
|
Крякнуть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 88%;
|
||||||
|
}
|
||||||
|
.msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.msg.duck {
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-line;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.msg.user .bubble {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.msg.duck .bubble {
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.who {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.dots i {
|
||||||
|
animation: blink 1.2s infinite;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.dots i:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
.dots i:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
.chip:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
textarea:focus {
|
||||||
|
outline: 1px solid #3b82f6;
|
||||||
|
}
|
||||||
|
.send {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.send:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { outfit, duckState } from '$lib/duck/state.svelte';
|
||||||
|
import { capById, teeById, TEE_LOGO } from '$lib/duck/wardrobe';
|
||||||
|
import { playQuack } from '$lib/duck/quack';
|
||||||
|
import { logoById, logoBackdrop } from '$lib/duck/logos';
|
||||||
|
import Logo from './Logo.svelte';
|
||||||
|
|
||||||
|
const cap = $derived(capById(outfit.cap));
|
||||||
|
const tee = $derived(teeById(outfit.tee));
|
||||||
|
|
||||||
|
// фактический цвет логотипа и подложка-«стикер», если он сливается с тканью
|
||||||
|
const teeLogoDef = $derived(logoById(outfit.teeLogo));
|
||||||
|
const capLogoDef = $derived(logoById(outfit.capLogo));
|
||||||
|
const capMonoColor = $derived(cap.dark ? '#f8fafc' : '#0f172a');
|
||||||
|
const teeBackdrop = $derived.by(() => {
|
||||||
|
if (!tee.img || !teeLogoDef) return null;
|
||||||
|
const colors = teeLogoDef.mono ? [tee.mono] : teeLogoDef.tints;
|
||||||
|
return logoBackdrop(tee.color, colors, TEE_LOGO.x, TEE_LOGO.y, TEE_LOGO.s);
|
||||||
|
});
|
||||||
|
const capBackdrop = $derived.by(() => {
|
||||||
|
if (cap.id === 'none' || !capLogoDef) return null;
|
||||||
|
const colors = capLogoDef.mono ? [capMonoColor] : capLogoDef.tints;
|
||||||
|
return logoBackdrop(cap.color, colors, cap.logo.x, cap.logo.y, cap.logo.s);
|
||||||
|
});
|
||||||
|
|
||||||
|
let quacking = $state(false);
|
||||||
|
|
||||||
|
function poke() {
|
||||||
|
if (quacking) return;
|
||||||
|
quacking = true;
|
||||||
|
playQuack();
|
||||||
|
setTimeout(() => (quacking = false), 650);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="duck"
|
||||||
|
class:bob={outfit.bob}
|
||||||
|
class:speaking={duckState.speaking || quacking}
|
||||||
|
onclick={poke}
|
||||||
|
title="Крякнуть!"
|
||||||
|
>
|
||||||
|
<img class="base" src="/duck/duck.png" alt="Резиновая утка" draggable="false" />
|
||||||
|
{#if tee.img}
|
||||||
|
<img class="layer" src={tee.img} alt="" draggable="false" />
|
||||||
|
{/if}
|
||||||
|
<svg class="layer" viewBox="0 0 800 920" aria-hidden="true">
|
||||||
|
{#if outfit.teeLogo && tee.img}
|
||||||
|
{#if teeBackdrop}
|
||||||
|
<rect
|
||||||
|
x={teeBackdrop.x}
|
||||||
|
y={teeBackdrop.y}
|
||||||
|
width={teeBackdrop.w}
|
||||||
|
height={teeBackdrop.h}
|
||||||
|
rx={teeBackdrop.rx}
|
||||||
|
fill={teeBackdrop.fill}
|
||||||
|
stroke={teeBackdrop.stroke}
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<Logo
|
||||||
|
id={outfit.teeLogo}
|
||||||
|
size={TEE_LOGO.s}
|
||||||
|
x={TEE_LOGO.x - TEE_LOGO.s / 2}
|
||||||
|
y={TEE_LOGO.y - TEE_LOGO.s / 2}
|
||||||
|
color={tee.mono}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if cap.id !== 'none'}
|
||||||
|
<g class="cap">{@html cap.svg}</g>
|
||||||
|
{#if outfit.capLogo}
|
||||||
|
{#if capBackdrop}
|
||||||
|
<rect
|
||||||
|
x={capBackdrop.x}
|
||||||
|
y={capBackdrop.y}
|
||||||
|
width={capBackdrop.w}
|
||||||
|
height={capBackdrop.h}
|
||||||
|
rx={capBackdrop.rx}
|
||||||
|
fill={capBackdrop.fill}
|
||||||
|
stroke={capBackdrop.stroke}
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<Logo
|
||||||
|
id={outfit.capLogo}
|
||||||
|
size={cap.logo.s}
|
||||||
|
x={cap.logo.x - cap.logo.s / 2}
|
||||||
|
y={cap.logo.y - cap.logo.s / 2}
|
||||||
|
color={capMonoColor}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.duck {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 430px;
|
||||||
|
margin: 0 auto;
|
||||||
|
aspect-ratio: 800 / 920;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
.duck img,
|
||||||
|
.duck svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
.duck.bob {
|
||||||
|
animation: bob 4.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.duck.speaking {
|
||||||
|
animation: wiggle 0.35s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes bob {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes wiggle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(-2.5deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(2.5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { logoById } from '$lib/duck/logos';
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
size = 24,
|
||||||
|
x = undefined,
|
||||||
|
y = undefined,
|
||||||
|
color = 'currentColor'
|
||||||
|
}: { id: string; size?: number; x?: number; y?: number; color?: string } = $props();
|
||||||
|
|
||||||
|
const def = $derived(logoById(id));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if def}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
style:color={def.mono ? color : undefined}
|
||||||
|
role="img"
|
||||||
|
aria-label={def.label}
|
||||||
|
>{@html def.svg}</svg>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { outfit, resetOutfit } from '$lib/duck/state.svelte';
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
const outfitJson = $derived(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
cap: outfit.cap,
|
||||||
|
cap_logo: outfit.capLogo,
|
||||||
|
tee: outfit.tee,
|
||||||
|
tee_logo: outfit.teeLogo,
|
||||||
|
animation: outfit.bob,
|
||||||
|
sound: outfit.sound
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function copyOutfit() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(outfitJson);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 1400);
|
||||||
|
} catch {
|
||||||
|
// нет доступа к буферу — просто игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="settings">
|
||||||
|
<section>
|
||||||
|
<h3>Поведение</h3>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" bind:checked={outfit.bob} />
|
||||||
|
<span class="switch" aria-hidden="true"></span>
|
||||||
|
<span>Покачивание утки</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" bind:checked={outfit.sound} />
|
||||||
|
<span class="switch" aria-hidden="true"></span>
|
||||||
|
<span>Крякать при ответе</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Текущий образ</h3>
|
||||||
|
<pre class="json mono">{outfitJson}</pre>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" onclick={copyOutfit}>{copied ? 'Скопировано ✓' : 'Копировать JSON'}</button>
|
||||||
|
<button class="btn danger" onclick={resetOutfit}>Сбросить образ</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>О проекте</h3>
|
||||||
|
<p class="about">
|
||||||
|
Утка-отладчик слушает и крякает. Вызов <code>quack</code> в чате —
|
||||||
|
<b>локальная симуляция</b> MCP-инструмента: без сети, с той же логикой ответа.
|
||||||
|
Настоящий MCP-эндпоинт живёт отдельно — <a href="/mcp">/mcp</a>.
|
||||||
|
Все логотипы принадлежат их правообладателям и используются
|
||||||
|
в иллюстративных целях в рамках добросовестного использования (fair use).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
section h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.toggle input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.switch {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.switch::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: transform 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.toggle input:checked + .switch {
|
||||||
|
background: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
.toggle input:checked + .switch::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.json {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
.btn.danger:hover {
|
||||||
|
border-color: var(--no);
|
||||||
|
color: var(--no);
|
||||||
|
}
|
||||||
|
.about {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { outfit } from '$lib/duck/state.svelte';
|
||||||
|
import { CAPS, TEES } from '$lib/duck/wardrobe';
|
||||||
|
import { LOGOS, LOGO_CATEGORIES, logoById } from '$lib/duck/logos';
|
||||||
|
import Logo from './Logo.svelte';
|
||||||
|
|
||||||
|
function capLabel() {
|
||||||
|
return CAPS.find((c) => c.id === outfit.cap)?.label ?? '';
|
||||||
|
}
|
||||||
|
function teeLabel() {
|
||||||
|
return TEES.find((t) => t.id === outfit.tee)?.label ?? '';
|
||||||
|
}
|
||||||
|
function capLogoLabel() {
|
||||||
|
const l = logoById(outfit.capLogo);
|
||||||
|
return l ? `лого на кепке: ${l.label}` : '';
|
||||||
|
}
|
||||||
|
function teeLogoLabel() {
|
||||||
|
const l = logoById(outfit.teeLogo);
|
||||||
|
return l ? ` · лого на футболке: ${l.label}` : ' · без логотипов';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wardrobe">
|
||||||
|
<section>
|
||||||
|
<h3>Кепка</h3>
|
||||||
|
<div class="items caps">
|
||||||
|
{#each CAPS as cap (cap.id)}
|
||||||
|
<button
|
||||||
|
class="item"
|
||||||
|
class:selected={outfit.cap === cap.id}
|
||||||
|
title={cap.label}
|
||||||
|
onclick={() => {
|
||||||
|
outfit.cap = cap.id;
|
||||||
|
if (cap.id === 'none') outfit.capLogo = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="thumb cap-thumb">
|
||||||
|
{#if cap.id === 'none'}
|
||||||
|
<span class="none-mark">∅</span>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="150 0 500 186" aria-hidden="true">{@html cap.svg}</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="label">{cap.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Футболка</h3>
|
||||||
|
<div class="items tees">
|
||||||
|
{#each TEES as tee (tee.id)}
|
||||||
|
<button
|
||||||
|
class="item"
|
||||||
|
class:selected={outfit.tee === tee.id}
|
||||||
|
title={tee.label}
|
||||||
|
onclick={() => {
|
||||||
|
outfit.tee = tee.id;
|
||||||
|
if (tee.id === 'none') outfit.teeLogo = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="thumb tee-thumb">
|
||||||
|
{#if !tee.img}
|
||||||
|
<span class="none-mark">∅</span>
|
||||||
|
{:else}
|
||||||
|
<img src={tee.img} alt={tee.label} draggable="false" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="label">{tee.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Логотип на кепке</h3>
|
||||||
|
{#if outfit.cap === 'none'}
|
||||||
|
<p class="hint">Сначала наденьте кепку — печатать не на чем.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="logos">
|
||||||
|
{#each LOGO_CATEGORIES as cat (cat.id)}
|
||||||
|
<div class="logo-group">
|
||||||
|
<span class="group-label">{cat.label}</span>
|
||||||
|
<div class="logo-grid">
|
||||||
|
<button
|
||||||
|
class="logo-item"
|
||||||
|
class:selected={outfit.capLogo === null}
|
||||||
|
title="Без логотипа"
|
||||||
|
onclick={() => (outfit.capLogo = null)}
|
||||||
|
>
|
||||||
|
<span class="empty-logo">—</span>
|
||||||
|
</button>
|
||||||
|
{#each LOGOS.filter((l) => l.category === cat.id) as logo (logo.id)}
|
||||||
|
<button
|
||||||
|
class="logo-item"
|
||||||
|
class:selected={outfit.capLogo === logo.id}
|
||||||
|
title={logo.label}
|
||||||
|
onclick={() => (outfit.capLogo = logo.id)}
|
||||||
|
>
|
||||||
|
<Logo id={logo.id} size={26} />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Логотип на футболке</h3>
|
||||||
|
{#if outfit.tee === 'none'}
|
||||||
|
<p class="hint">Сначала наденьте футболку — печатать не на чем.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="logos">
|
||||||
|
{#each LOGO_CATEGORIES as cat (cat.id)}
|
||||||
|
<div class="logo-group">
|
||||||
|
<span class="group-label">{cat.label}</span>
|
||||||
|
<div class="logo-grid">
|
||||||
|
<button
|
||||||
|
class="logo-item"
|
||||||
|
class:selected={outfit.teeLogo === null}
|
||||||
|
title="Без логотипа"
|
||||||
|
onclick={() => (outfit.teeLogo = null)}
|
||||||
|
>
|
||||||
|
<span class="empty-logo">—</span>
|
||||||
|
</button>
|
||||||
|
{#each LOGOS.filter((l) => l.category === cat.id) as logo (logo.id)}
|
||||||
|
<button
|
||||||
|
class="logo-item"
|
||||||
|
class:selected={outfit.teeLogo === logo.id}
|
||||||
|
title={logo.label}
|
||||||
|
onclick={() => (outfit.teeLogo = logo.id)}
|
||||||
|
>
|
||||||
|
<Logo id={logo.id} size={26} />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="legal">
|
||||||
|
Все логотипы принадлежат их правообладателям. Используются на этой странице
|
||||||
|
в иллюстративных целях в рамках добросовестного использования (fair use).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="current mono">
|
||||||
|
{outfit.cap !== 'none' ? `кепка: ${capLabel()}` : 'без кепки'} ·
|
||||||
|
{outfit.tee !== 'none' ? `футболка: ${teeLabel()}` : 'без футболки'} ·
|
||||||
|
{capLogoLabel()}{teeLogoLabel()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wardrobe {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
section h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.item:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
.item.selected {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 1px #3b82f6;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.cap-thumb {
|
||||||
|
width: 72px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.cap-thumb svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.tee-thumb {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
.tee-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.none-mark {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.logos {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.group-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.logo-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.logo-item {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.logo-item:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
.logo-item.selected {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 1px #3b82f6;
|
||||||
|
}
|
||||||
|
.empty-logo {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.current {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.legal {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--muted);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
export const LOGO_CATEGORIES = [
|
||||||
|
{ id: 'lang', label: 'Языки' },
|
||||||
|
{ id: 'tech', label: 'Технологии' },
|
||||||
|
{ id: 'org', label: 'Организации' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** категория логотипа — выводится из LOGO_CATEGORIES, отдельно не правится */
|
||||||
|
export type LogoCategory = (typeof LOGO_CATEGORIES)[number]['id'];
|
||||||
|
|
||||||
|
export interface LogoDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
category: LogoCategory;
|
||||||
|
/** mono = рисуется цветом currentColor (подстраивается под тёмную/светлую поверхность) */
|
||||||
|
mono?: boolean;
|
||||||
|
/** доминирующие цвета марки (можно несколько) — для авто-подложки,
|
||||||
|
* когда любой из них сливается с кепкой/футболкой */
|
||||||
|
tints: string[];
|
||||||
|
/** разметка под viewBox 0 0 24 24 */
|
||||||
|
svg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = (fill: string, label: string, fg: string, fontSize = 10) =>
|
||||||
|
`<rect x="1" y="1" width="22" height="22" rx="4" fill="${fill}"/>` +
|
||||||
|
`<text x="12" y="16.4" text-anchor="middle" font-family="system-ui, 'Segoe UI', sans-serif" font-weight="800" font-size="${fontSize}" fill="${fg}">${label}</text>`;
|
||||||
|
|
||||||
|
export const LOGOS: LogoDef[] = [
|
||||||
|
// --- языки ---
|
||||||
|
{
|
||||||
|
id: 'js',
|
||||||
|
label: 'JavaScript',
|
||||||
|
category: 'lang',
|
||||||
|
tints: ['#f7df1e'],
|
||||||
|
svg: badge('#f7df1e', 'JS', '#1a1a1a')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ts',
|
||||||
|
label: 'TypeScript',
|
||||||
|
category: 'lang',
|
||||||
|
tints: ['#3178c6'],
|
||||||
|
svg: badge('#3178c6', 'TS', '#ffffff')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'go',
|
||||||
|
label: 'Go',
|
||||||
|
category: 'lang',
|
||||||
|
tints: ['#00add8'],
|
||||||
|
svg: badge('#00add8', 'GO', '#ffffff', 9)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rust',
|
||||||
|
label: 'Rust',
|
||||||
|
category: 'lang',
|
||||||
|
tints: ['#000000'],
|
||||||
|
svg: `<path d="M23.8346 11.7033l-1.0073-.6236a13.7268 13.7268 0 00-.0283-.2936l.8656-.8069a.3483.3483 0 00-.1154-.578l-1.1066-.414a8.4958 8.4958 0 00-.087-.2856l.6904-.9587a.3462.3462 0 00-.2257-.5446l-1.1663-.1894a9.3574 9.3574 0 00-.1407-.2622l.49-1.0761a.3437.3437 0 00-.0274-.3361.3486.3486 0 00-.3006-.154l-1.1845.0416a6.7444 6.7444 0 00-.1873-.2268l.2723-1.153a.3472.3472 0 00-.417-.4172l-1.1532.2724a14.0183 14.0183 0 00-.2278-.1873l.0415-1.1845a.3442.3442 0 00-.49-.328l-1.076.491c-.0872-.0476-.1742-.0952-.2623-.1407l-.1903-1.1673A.3483.3483 0 0016.256.955l-.9597.6905a8.4867 8.4867 0 00-.2855-.086l-.414-1.1066a.3483.3483 0 00-.5781-.1154l-.8069.8666a9.2936 9.2936 0 00-.2936-.0284L12.2946.1683a.3462.3462 0 00-.5892 0l-.6236 1.0073a13.7383 13.7383 0 00-.2936.0284L9.9803.3374a.3462.3462 0 00-.578.1154l-.4141 1.1065c-.0962.0274-.1903.0567-.2855.086L7.744.955a.3483.3483 0 00-.5447.2258L7.009 2.348a9.3574 9.3574 0 00-.2622.1407l-1.0762-.491a.3462.3462 0 00-.49.328l.0416 1.1845a7.9826 7.9826 0 00-.2278.1873L3.8413 3.425a.3472.3472 0 00-.4171.4171l.2713 1.1531c-.0628.075-.1255.1509-.1863.2268l-1.1845-.0415a.3462.3462 0 00-.328.49l.491 1.0761a9.167 9.167 0 00-.1407.2622l-1.1662.1894a.3483.3483 0 00-.2258.5446l.6904.9587a13.303 13.303 0 00-.087.2855l-1.1065.414a.3483.3483 0 00-.1155.5781l.8656.807a9.2936 9.2936 0 00-.0283.2935l-1.0073.6236a.3442.3442 0 000 .5892l1.0073.6236c.008.0982.0182.1964.0283.2936l-.8656.8079a.3462.3462 0 00.1155.578l1.1065.4141c.0273.0962.0567.1914.087.2855l-.6904.9587a.3452.3452 0 00.2268.5447l1.1662.1893c.0456.088.0922.1751.1408.2622l-.491 1.0762a.3462.3462 0 00.328.49l1.1834-.0415c.0618.0769.1235.1528.1873.2277l-.2713 1.1541a.3462.3462 0 00.4171.4161l1.153-.2713c.075.0638.151.1255.2279.1863l-.0415 1.1845a.3442.3442 0 00.49.327l1.0761-.49c.087.0486.1741.0951.2622.1407l.1903 1.1662a.3483.3483 0 00.5447.2268l.9587-.6904a9.299 9.299 0 00.2855.087l.414 1.1066a.3452.3452 0 00.5781.1154l.8079-.8656c.0972.0111.1954.0203.2936.0294l.6236 1.0073a.3472.3472 0 00.5892 0l.6236-1.0073c.0982-.0091.1964-.0183.2936-.0294l.8069.8656a.3483.3483 0 00.578-.1154l.4141-1.1066a8.4626 8.4626 0 00.2855-.087l.9587.6904a.3452.3452 0 00.5447-.2268l.1903-1.1662c.088-.0456.1751-.0931.2622-.1407l1.0762.49a.3472.3472 0 00.49-.327l-.0415-1.1845a6.7267 6.7267 0 00.2267-.1863l1.1531.2713a.3472.3472 0 00.4171-.416l-.2713-1.1542c.0628-.0749.1255-.1508.1863-.2278l1.1845.0415a.3442.3442 0 00.328-.49l-.49-1.076c.0475-.0872.0951-.1742.1407-.2623l1.1662-.1893a.3483.3483 0 00.2258-.5447l-.6904-.9587.087-.2855 1.1066-.414a.3462.3462 0 00.1154-.5781l-.8656-.8079c.0101-.0972.0202-.1954.0283-.2936l1.0073-.6236a.3442.3442 0 000-.5892zm-6.7413 8.3551a.7138.7138 0 01.2986-1.396.714.714 0 11-.2997 1.396zm-.3422-2.3142a.649.649 0 00-.7715.5l-.3573 1.6685c-1.1035.501-2.3285.7795-3.6193.7795a8.7368 8.7368 0 01-3.6951-.814l-.3574-1.6684a.648.648 0 00-.7714-.499l-1.473.3158a8.7216 8.7216 0 01-.7613-.898h7.1676c.081 0 .1356-.0141.1356-.088v-2.536c0-.074-.0536-.0881-.1356-.0881h-2.0966v-1.6077h2.2677c.2065 0 1.1065.0587 1.394 1.2088.0901.3533.2875 1.5044.4232 1.8729.1346.413.6833 1.2381 1.2685 1.2381h3.5716a.7492.7492 0 00.1296-.0131 8.7874 8.7874 0 01-.8119.9526zM6.8369 20.024a.714.714 0 11-.2997-1.396.714.714 0 01.2997 1.396zM4.1177 8.9972a.7137.7137 0 11-1.304.5791.7137.7137 0 011.304-.579zm-.8352 1.9813l1.5347-.6824a.65.65 0 00.33-.8585l-.3158-.7147h1.2432v5.6025H3.5669a8.7753 8.7753 0 01-.2834-3.348zm6.7343-.5437V8.7836h2.9601c.153 0 1.0792.1772 1.0792.8697 0 .575-.7107.7815-1.2948.7815zm10.7574 1.4862c0 .2187-.008.4363-.0243.651h-.9c-.09 0-.1265.0586-.1265.1477v.413c0 .973-.5487 1.1846-1.0296 1.2382-.4576.0517-.9648-.1913-1.0275-.4717-.2704-1.5186-.7198-1.8436-1.4305-2.4034.8817-.5599 1.799-1.386 1.799-2.4915 0-1.1936-.819-1.9458-1.3769-2.3153-.7825-.5163-1.6491-.6195-1.883-.6195H5.4682a8.7651 8.7651 0 014.907-2.7699l1.0974 1.151a.648.648 0 00.9182.0213l1.227-1.1743a8.7753 8.7753 0 016.0044 4.2762l-.8403 1.8982a.652.652 0 00.33.8585l1.6178.7188c.0283.2875.0425.577.0425.8717zm-9.3006-9.5993a.7128.7128 0 11.984 1.0316.7137.7137 0 01-.984-1.0316zm8.3389 6.71a.7107.7107 0 01.9395-.3625.7137.7137 0 11-.9405.3635z"/>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python',
|
||||||
|
label: 'Python',
|
||||||
|
category: 'lang',
|
||||||
|
tints: ['#387eb8', '#ffe052'],
|
||||||
|
svg:
|
||||||
|
`<defs><linearGradient id="pyGradA" gradientUnits="userSpaceOnUse" x1="63.8159" y1="56.6829" x2="118.4934" y2="1.8225" gradientTransform="matrix(1 0 0 -1 -53.2974 66.4321)"> <stop offset="0" stop-color="#387eb8"/> <stop offset="1" stop-color="#366994"/></linearGradient><linearGradient id="pyGradB" gradientUnits="userSpaceOnUse" x1="97.0444" y1="21.6321" x2="155.6665" y2="-34.5308" gradientTransform="matrix(1 0 0 -1 -53.2974 66.4321)"> <stop offset="0" stop-color="#ffe052"/> <stop offset="1" stop-color="#ffc331"/></linearGradient></defs>` +
|
||||||
|
`<g transform="scale(0.2175)"><path fill="url(#pyGradA)" d="M55.023-0.077c-25.971,0-26.25,10.081-26.25,12.156c0,3.148,0,12.594,0,12.594h26.75v3.781 c0,0-27.852,0-37.375,0c-7.949,0-17.938,4.833-17.938,26.25c0,19.673,7.792,27.281,15.656,27.281c2.335,0,9.344,0,9.344,0 s0-9.765,0-13.125c0-5.491,2.721-15.656,15.406-15.656c15.91,0,19.971,0,26.531,0c3.902,0,14.906-1.696,14.906-14.406 c0-13.452,0-17.89,0-24.219C82.054,11.426,81.515-0.077,55.023-0.077z M40.273,8.392c2.662,0,4.813,2.15,4.813,4.813 c0,2.661-2.151,4.813-4.813,4.813s-4.813-2.151-4.813-4.813C35.46,10.542,37.611,8.392,40.273,8.392z"/><path fill="url(#pyGradB)" d="M55.397,109.923c25.959,0,26.282-10.271,26.282-12.156c0-3.148,0-12.594,0-12.594H54.897v-3.781 c0,0,28.032,0,37.375,0c8.009,0,17.938-4.954,17.938-26.25c0-23.322-10.538-27.281-15.656-27.281c-2.336,0-9.344,0-9.344,0 s0,10.216,0,13.125c0,5.491-2.631,15.656-15.406,15.656c-15.91,0-19.476,0-26.532,0c-3.892,0-14.906,1.896-14.906,14.406 c0,14.475,0,18.265,0,24.219C28.366,100.497,31.562,109.923,55.397,109.923z M70.148,101.454c-2.662,0-4.813-2.151-4.813-4.813 s2.15-4.813,4.813-4.813c2.661,0,4.813,2.151,4.813,4.813S72.809,101.454,70.148,101.454z"/></g>`
|
||||||
|
},
|
||||||
|
// --- технологии ---
|
||||||
|
{
|
||||||
|
id: 'nginx',
|
||||||
|
label: 'NGINX',
|
||||||
|
category: 'tech',
|
||||||
|
tints: ['#009639'],
|
||||||
|
svg: badge('#009639', 'n', '#ffffff', 13)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kafka',
|
||||||
|
label: 'Kafka',
|
||||||
|
category: 'tech',
|
||||||
|
tints: ['#231F20'],
|
||||||
|
svg: `<path d="M9.71 2.136a1.43 1.43 0 0 0-2.047 0h-.007a1.48 1.48 0 0 0-.421 1.042c0 .41.161.777.422 1.039l.007.007c.257.264.616.426 1.019.426.404 0 .766-.162 1.027-.426l.003-.007c.261-.262.421-.629.421-1.039 0-.408-.159-.777-.421-1.042H9.71zM8.683 22.295c.404 0 .766-.167 1.027-.429l.003-.008c.261-.261.421-.631.421-1.036 0-.41-.159-.778-.421-1.044H9.71a1.42 1.42 0 0 0-1.027-.432 1.4 1.4 0 0 0-1.02.432h-.007c-.26.266-.422.634-.422 1.044 0 .406.161.775.422 1.036l.007.008c.258.262.617.429 1.02.429zm7.89-4.462c.359-.096.683-.33.882-.684l.027-.052a1.47 1.47 0 0 0 .114-1.067 1.454 1.454 0 0 0-.675-.896l-.021-.014a1.425 1.425 0 0 0-1.078-.132c-.36.091-.684.335-.881.686-.2.349-.241.75-.146 1.119.099.363.33.691.675.896h.002c.346.203.737.239 1.101.144zm-6.405-7.342a2.083 2.083 0 0 0-1.485-.627c-.58 0-1.103.242-1.482.627-.378.385-.612.916-.612 1.507s.233 1.124.612 1.514a2.08 2.08 0 0 0 2.967 0c.379-.39.612-.923.612-1.514s-.233-1.122-.612-1.507zm-.835-2.51c.843.141 1.6.552 2.178 1.144h.004c.092.093.182.196.265.299l1.446-.851a3.176 3.176 0 0 1-.047-1.808 3.149 3.149 0 0 1 1.456-1.926l.025-.016a3.062 3.062 0 0 1 2.345-.306c.77.21 1.465.721 1.898 1.482v.002c.431.757.518 1.626.313 2.408a3.145 3.145 0 0 1-1.456 1.928l-.198.118h-.02a3.095 3.095 0 0 1-2.154.201 3.127 3.127 0 0 1-1.514-.944l-1.444.848a4.162 4.162 0 0 1 0 2.879l1.444.846c.413-.47.939-.789 1.514-.944a3.041 3.041 0 0 1 2.371.319l.048.023v.002a3.17 3.17 0 0 1 1.408 1.906 3.215 3.215 0 0 1-.313 2.405l-.026.053-.003-.005a3.147 3.147 0 0 1-1.867 1.436 3.096 3.096 0 0 1-2.371-.318v-.006a3.156 3.156 0 0 1-1.456-1.927 3.175 3.175 0 0 1 .047-1.805l-1.446-.848a3.905 3.905 0 0 1-.265.294l-.004.005a3.938 3.938 0 0 1-2.178 1.138v1.699a3.09 3.09 0 0 1 1.56.862l.002.004c.565.572.914 1.368.914 2.243 0 .873-.35 1.664-.914 2.239l-.002.009a3.1 3.1 0 0 1-2.21.931 3.1 3.1 0 0 1-2.206-.93h-.002v-.009a3.186 3.186 0 0 1-.916-2.239c0-.875.35-1.672.916-2.243v-.004h.002a3.1 3.1 0 0 1 1.558-.862v-1.699a3.926 3.926 0 0 1-2.176-1.138l-.006-.005a4.098 4.098 0 0 1-1.173-2.874c0-1.122.452-2.136 1.173-2.872h.006a3.947 3.947 0 0 1 2.176-1.144V6.289a3.137 3.137 0 0 1-1.558-.864h-.002v-.004a3.192 3.192 0 0 1-.916-2.243c0-.871.35-1.669.916-2.243l.002-.002A3.084 3.084 0 0 1 8.683 0c.861 0 1.641.355 2.21.932v.002h.002c.565.574.914 1.372.914 2.243 0 .876-.35 1.667-.914 2.243l-.002.005a3.142 3.142 0 0 1-1.56.864v1.692zm8.121-1.129l-.012-.019a1.452 1.452 0 0 0-.87-.668 1.43 1.43 0 0 0-1.103.146h.002c-.347.2-.58.529-.677.896-.095.365-.054.768.146 1.119l.007.009c.2.347.519.579.874.673.357.103.755.059 1.098-.144l.019-.009a1.47 1.47 0 0 0 .657-.885 1.493 1.493 0 0 0-.141-1.118"/>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'docker',
|
||||||
|
label: 'Docker',
|
||||||
|
category: 'tech',
|
||||||
|
tints: ['#2496ED'],
|
||||||
|
svg: `<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kubernetes',
|
||||||
|
label: 'Kubernetes',
|
||||||
|
category: 'tech',
|
||||||
|
tints: ['#326CE5'],
|
||||||
|
svg: `<path d="M10.204 14.35l.007.01-.999 2.413a5.171 5.171 0 0 1-2.075-2.597l2.578-.437.004.005a.44.44 0 0 1 .484.606zm-.833-2.129a.44.44 0 0 0 .173-.756l.002-.011L7.585 9.7a5.143 5.143 0 0 0-.73 3.255l2.514-.725.002-.009zm1.145-1.98a.44.44 0 0 0 .699-.337l.01-.005.15-2.62a5.144 5.144 0 0 0-3.01 1.442l2.147 1.523.004-.002zm.76 2.75l.723.349.722-.347.18-.78-.5-.623h-.804l-.5.623.179.779zm1.5-3.095a.44.44 0 0 0 .7.336l.008.003 2.134-1.513a5.188 5.188 0 0 0-2.992-1.442l.148 2.615.002.001zm10.876 5.97l-5.773 7.181a1.6 1.6 0 0 1-1.248.594l-9.261.003a1.6 1.6 0 0 1-1.247-.596l-5.776-7.18a1.583 1.583 0 0 1-.307-1.34L2.1 5.573c.108-.47.425-.864.863-1.073L11.305.513a1.606 1.606 0 0 1 1.385 0l8.345 3.985c.438.209.755.604.863 1.073l2.062 8.955c.108.47-.005.963-.308 1.34zm-3.289-2.057c-.042-.01-.103-.026-.145-.034-.174-.033-.315-.025-.479-.038-.35-.037-.638-.067-.895-.148-.105-.04-.18-.165-.216-.216l-.201-.059a6.45 6.45 0 0 0-.105-2.332 6.465 6.465 0 0 0-.936-2.163c.052-.047.15-.133.177-.159.008-.09.001-.183.094-.282.197-.185.444-.338.743-.522.142-.084.273-.137.415-.242.032-.024.076-.062.11-.089.24-.191.295-.52.123-.736-.172-.216-.506-.236-.745-.045-.034.027-.08.062-.111.088-.134.116-.217.23-.33.35-.246.25-.45.458-.673.609-.097.056-.239.037-.303.033l-.19.135a6.545 6.545 0 0 0-4.146-2.003l-.012-.223c-.065-.062-.143-.115-.163-.25-.022-.268.015-.557.057-.905.023-.163.061-.298.068-.475.001-.04-.001-.099-.001-.142 0-.306-.224-.555-.5-.555-.275 0-.499.249-.499.555l.001.014c0 .041-.002.092 0 .128.006.177.044.312.067.475.042.348.078.637.056.906a.545.545 0 0 1-.162.258l-.012.211a6.424 6.424 0 0 0-4.166 2.003 8.373 8.373 0 0 1-.18-.128c-.09.012-.18.04-.297-.029-.223-.15-.427-.358-.673-.608-.113-.12-.195-.234-.329-.349-.03-.026-.077-.062-.111-.088a.594.594 0 0 0-.348-.132.481.481 0 0 0-.398.176c-.172.216-.117.546.123.737l.007.005.104.083c.142.105.272.159.414.242.299.185.546.338.743.522.076.082.09.226.1.288l.16.143a6.462 6.462 0 0 0-1.02 4.506l-.208.06c-.055.072-.133.184-.215.217-.257.081-.546.11-.895.147-.164.014-.305.006-.48.039-.037.007-.09.02-.133.03l-.004.002-.007.002c-.295.071-.484.342-.423.608.061.267.349.429.645.365l.007-.001.01-.003.129-.029c.17-.046.294-.113.448-.172.33-.118.604-.217.87-.256.112-.009.23.069.288.101l.217-.037a6.5 6.5 0 0 0 2.88 3.596l-.09.218c.033.084.069.199.044.282-.097.252-.263.517-.452.813-.091.136-.185.242-.268.399-.02.037-.045.095-.064.134-.128.275-.034.591.213.71.248.12.556-.007.69-.282v-.002c.02-.039.046-.09.062-.127.07-.162.094-.301.144-.458.132-.332.205-.68.387-.897.05-.06.13-.082.215-.105l.113-.205a6.453 6.453 0 0 0 4.609.012l.106.192c.086.028.18.042.256.155.136.232.229.507.342.84.05.156.074.295.145.457.016.037.043.09.062.129.133.276.442.402.69.282.247-.118.341-.435.213-.71-.02-.039-.045-.096-.065-.134-.083-.156-.177-.261-.268-.398-.19-.296-.346-.541-.443-.793-.04-.13.007-.21.038-.294-.018-.022-.059-.144-.083-.202a6.499 6.499 0 0 0 2.88-3.622c.064.01.176.03.213.038.075-.05.144-.114.28-.104.266.039.54.138.87.256.154.06.277.128.448.173.036.01.088.019.13.028l.009.003.007.001c.297.064.584-.098.645-.365.06-.266-.128-.537-.423-.608zM16.4 9.701l-1.95 1.746v.005a.44.44 0 0 0 .173.757l.003.01 2.526.728a5.199 5.199 0 0 0-.108-1.674A5.208 5.208 0 0 0 16.4 9.7zm-4.013 5.325a.437.437 0 0 0-.404-.232.44.44 0 0 0-.372.233h-.002l-1.268 2.292a5.164 5.164 0 0 0 3.326.003l-1.27-2.296h-.01zm1.888-1.293a.44.44 0 0 0-.27.036.44.44 0 0 0-.214.572l-.003.004 1.01 2.438a5.15 5.15 0 0 0 2.081-2.615l-2.6-.44-.004.005z"/>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'redis',
|
||||||
|
label: 'Redis',
|
||||||
|
category: 'tech',
|
||||||
|
tints: ['#FF4438'],
|
||||||
|
svg: `<path d="M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"/>`
|
||||||
|
},
|
||||||
|
// --- организации ---,
|
||||||
|
{
|
||||||
|
id: 'github',
|
||||||
|
label: 'GitHub',
|
||||||
|
category: 'org',
|
||||||
|
tints: ['#181717'],
|
||||||
|
svg: `<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gitlab',
|
||||||
|
label: 'GitLab',
|
||||||
|
category: 'org',
|
||||||
|
tints: ['#FC6D26'],
|
||||||
|
svg: `<path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bitbucket',
|
||||||
|
label: 'Bitbucket',
|
||||||
|
category: 'org',
|
||||||
|
tints: ['#0052CC'],
|
||||||
|
svg: `<path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'git',
|
||||||
|
label: 'Git',
|
||||||
|
category: 'org',
|
||||||
|
tints: ['#F03C2E'],
|
||||||
|
svg: `<path d="M13.09 23.549a1.54 1.54 0 0 1-2.18 0L.451 13.089a1.54 1.54 0 0 1 0-2.179l7.191-7.19 2.733 2.733a1.85 1.85 0 0 0 .964 2.326v6.66a1.849 1.849 0 1 0 1.54 0V8.957l2.508 2.508a1.85 1.85 0 1 0 1.09-1.09l-2.634-2.634a1.85 1.85 0 0 0-2.378-2.377L8.73 2.63 10.91.451a1.54 1.54 0 0 1 2.179 0l10.459 10.46a1.54 1.54 0 0 1 0 2.179z"/>`
|
||||||
|
}
|
||||||
|
];;
|
||||||
|
|
||||||
|
const byId = new Map(LOGOS.map((l) => [l.id, l]));
|
||||||
|
|
||||||
|
export function logoById(id: string | null | undefined): LogoDef | undefined {
|
||||||
|
return id ? byId.get(id) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── авто-подложка под логотип ───────────────────────────────────────────────
|
||||||
|
// Если цвет логотипа близок к цвету ткани (кепки/футболки), под логотип
|
||||||
|
// рисуется контрастная плашка-«стикер». Порог подобран на глаз: ниже —
|
||||||
|
// подложка появляется чаще, выше — реже.
|
||||||
|
|
||||||
|
const CONTRAST_THRESHOLD = 120; // 0…441 (евклидово расстояние в RGB)
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(rgb: [number, number, number]): number {
|
||||||
|
return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorDistance(a: string, b: string): number {
|
||||||
|
const ra = hexToRgb(a);
|
||||||
|
const rb = hexToRgb(b);
|
||||||
|
return Math.hypot(ra[0] - rb[0], ra[1] - rb[1], ra[2] - rb[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoBackdrop {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
rx: number;
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плашка под логотип, если он сливается с поверхностью.
|
||||||
|
* surface — цвет ткани, logoColor — фактический цвет логотипа
|
||||||
|
* (для mono-логотипов это подставленный currentColor), cx/cy/size — центр
|
||||||
|
* и сторона квадрата логотипа на канвасе утки.
|
||||||
|
*/
|
||||||
|
export function logoBackdrop(
|
||||||
|
surface: string | null | undefined,
|
||||||
|
logoColors: string[],
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
size: number
|
||||||
|
): LogoBackdrop | null {
|
||||||
|
if (!surface || !surface.startsWith('#')) return null;
|
||||||
|
const colors = logoColors.filter((c) => c.startsWith('#'));
|
||||||
|
if (!colors.length) return null;
|
||||||
|
if (colors.every((c) => colorDistance(surface, c) >= CONTRAST_THRESHOLD)) return null;
|
||||||
|
// белая плашка на цветной/тёмной ткани, тёмная — на почти белой
|
||||||
|
const light = luminance(hexToRgb(surface)) > 0.68;
|
||||||
|
const pad = size * 0.14;
|
||||||
|
return {
|
||||||
|
x: cx - size / 2 - pad,
|
||||||
|
y: cy - size / 2 - pad,
|
||||||
|
w: size + pad * 2,
|
||||||
|
h: size + pad * 2,
|
||||||
|
rx: size * 0.22,
|
||||||
|
fill: light ? '#0f172a' : '#f8fafc',
|
||||||
|
stroke: light ? 'rgba(0,0,0,0.3)' : 'rgba(15,23,42,0.18)'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
export const MOODS = ['happy', 'confused', 'excited', 'sleepy'] as const;
|
||||||
|
|
||||||
|
/** настроение утки — выводится из MOODS, отдельно не правится */
|
||||||
|
export type Mood = (typeof MOODS)[number];
|
||||||
|
|
||||||
|
const MOOD_LABELS: Record<Mood, string> = {
|
||||||
|
happy: 'довольная',
|
||||||
|
confused: 'в замешательстве',
|
||||||
|
excited: 'в восторге',
|
||||||
|
sleepy: 'сонная'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function moodLabel(mood: Mood): string {
|
||||||
|
return MOOD_LABELS[mood];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moodFor(text: string): Mood {
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
if (/(устал|спать|сон|ночь|надоело|sleep|tired|boring)/.test(t)) return 'sleepy';
|
||||||
|
if (/(ура|получилось|работает|наконец|супер|отлично|yay|works|fixed)/.test(t) || /!{2,}/.test(t))
|
||||||
|
return 'excited';
|
||||||
|
if (/\?|почему|как|не работает|сломал|баг|ошибк|bug|why|how|странно/.test(t)) return 'confused';
|
||||||
|
return 'happy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** те же пулы фраз, что у настоящего quack-инструмента (src/lib/mcp.ts) */
|
||||||
|
const QUACKS: Record<Mood | 'default', string[]> = {
|
||||||
|
happy: ['QUACK! 🎉', 'Quack quack! 😄', 'QUAAACK! 🦆✨'],
|
||||||
|
confused: ['Qu...ack?', 'Quack? 🤔', 'Quaaack...?'],
|
||||||
|
excited: ['QUACK QUACK QUACK!', 'QUAAACK!! 🔥', 'Quack! Quack! Quack!'],
|
||||||
|
sleepy: ['quack... 😴', 'quaack... 💤', 'quack. *yawn*'],
|
||||||
|
default: ['Quack!', 'Quack quack!', 'QUACK!', 'Quaaack!', 'Quack. 🦆']
|
||||||
|
};
|
||||||
|
|
||||||
|
function randomFrom<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подсказки утки-наставника — то, ради чего к ней идут. */
|
||||||
|
export const DUCK_TIPS = [
|
||||||
|
'Расскажи, что ты ожидаешь увидеть — и что видишь вместо этого.',
|
||||||
|
'Разбей проблему на шаги и проговори каждый вслух. Я слушаю.',
|
||||||
|
'А что приходит на входе? Проверь значения по одному.',
|
||||||
|
'Прочитай ошибку вслух, медленно. Что она говорит на самом деле?',
|
||||||
|
'Объясни код строка за строкой — где-то на середине станет ясно.',
|
||||||
|
'Что изменилось с тех пор, как всё работало?',
|
||||||
|
'Сделай минимальный пример, который воспроизводит проблему.',
|
||||||
|
'Проверь границы: пустые значения, нули, пустые строки.',
|
||||||
|
'Отдохни пять минут и вернись свежим взглядом.',
|
||||||
|
'Веришь своему логу — или своим догадкам? Проверь.'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function randomTip(): string {
|
||||||
|
return randomFrom(DUCK_TIPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulatedToolCall {
|
||||||
|
tool: 'quack';
|
||||||
|
arguments: { mood: Mood };
|
||||||
|
/** результат в формате MCP content-блоков — как вернул бы настоящий инструмент */
|
||||||
|
content: { type: 'text'; text: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Симуляция вызова MCP-инструмента quack: без сети, с «задержкой сети» и тем же ответом. */
|
||||||
|
export async function simulateMcpQuack(message: string): Promise<SimulatedToolCall> {
|
||||||
|
const mood = moodFor(message);
|
||||||
|
await new Promise((r) => setTimeout(r, 350 + Math.random() * 550));
|
||||||
|
return {
|
||||||
|
tool: 'quack',
|
||||||
|
arguments: { mood },
|
||||||
|
content: [{ type: 'text', text: randomFrom(QUACKS[mood]) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioCtx: AudioContext | undefined;
|
||||||
|
|
||||||
|
/** Синтезированное «кря» через WebAudio (без аудио-ассетов). */
|
||||||
|
export function playQuack() {
|
||||||
|
try {
|
||||||
|
audioCtx ??= new AudioContext();
|
||||||
|
const ctx = audioCtx;
|
||||||
|
const t = ctx.currentTime;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(420, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(130, t + 0.18);
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.value = 1200;
|
||||||
|
filter.Q.value = 6;
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(0.0001, t);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.2, t + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + 0.25);
|
||||||
|
} catch {
|
||||||
|
// нет WebAudio — просто молча крякаем
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { CapId, TeeId } from './wardrobe';
|
||||||
|
|
||||||
|
export interface Outfit {
|
||||||
|
cap: CapId;
|
||||||
|
tee: TeeId;
|
||||||
|
capLogo: string | null;
|
||||||
|
teeLogo: string | null;
|
||||||
|
bob: boolean;
|
||||||
|
sound: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'duck-outfit-v1';
|
||||||
|
|
||||||
|
function defaultOutfit(): Outfit {
|
||||||
|
return {
|
||||||
|
cap: 'baseball',
|
||||||
|
tee: 'white',
|
||||||
|
capLogo: null,
|
||||||
|
teeLogo: 'js',
|
||||||
|
bob: true,
|
||||||
|
sound: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const outfit = $state<Outfit>(defaultOutfit());
|
||||||
|
export const duckState = $state({ speaking: false });
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const saved = JSON.parse(raw) as Partial<Outfit>;
|
||||||
|
Object.assign(outfit, saved);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// битый localStorage — используем дефолтный образ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveOutfit() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(outfit));
|
||||||
|
} catch {
|
||||||
|
// приватный режим / переполнение — молча игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetOutfit() {
|
||||||
|
Object.assign(outfit, defaultOutfit());
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
export interface CapShape {
|
||||||
|
color: string;
|
||||||
|
dark: boolean;
|
||||||
|
/** позиция логотипа на кепке (координаты канваса утки 800x920) */
|
||||||
|
logo: { x: number; y: number; s: number };
|
||||||
|
/** разметка кепки, рисуется поверх головы утки */
|
||||||
|
svg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeeDef {
|
||||||
|
id: TeeId;
|
||||||
|
label: string;
|
||||||
|
/** PNG-слой футболки (перекрашен из TShirt.jfif), null = без футболки */
|
||||||
|
img: string | null;
|
||||||
|
/** цвет ткани (примерный) — для авто-подложки под логотип */
|
||||||
|
color: string;
|
||||||
|
dark: boolean;
|
||||||
|
/** цвет для монохромных логотипов */
|
||||||
|
mono: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shade = (opacity: number) => `rgba(0,0,0,${opacity})`;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// КАПКИ — как подогнать под себя
|
||||||
|
//
|
||||||
|
// Утка лежит на канвасе 800×920: центр головы x=400, макушка y≈60,
|
||||||
|
// глаза занимают полосу y≈178…258 (белки начинаются на y=178).
|
||||||
|
// Кепка рисуется поверх головы, поэтому:
|
||||||
|
// • baseY (нижний край купола) держите в диапазоне 130…176 — ниже кепка
|
||||||
|
// начнёт закрывать глаза;
|
||||||
|
// • domeW больше ~420 будет шире головы;
|
||||||
|
// • позиция логотипа считается автоматически, но можно задать вручную
|
||||||
|
// параметром logo: { x, y, s } (s — сторона квадрата логотипа).
|
||||||
|
// Правьте числа в вызовах baseball(…) / beanie(…) / flatcap(…) / tophat(…)
|
||||||
|
// в списке CAPS ниже — утка в браузере обновится сразу (dev-режим).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CX = 400; // центр утки по горизонтали
|
||||||
|
|
||||||
|
/** купол: нижний край baseY, ширина domeW, высота domeH */
|
||||||
|
function dome(baseY: number, domeW: number, domeH: number): string {
|
||||||
|
const x0 = CX - domeW / 2;
|
||||||
|
const x1 = CX + domeW / 2;
|
||||||
|
const top = baseY - domeH;
|
||||||
|
return (
|
||||||
|
`M ${x0} ${baseY} C ${x0} ${top + 32} ${CX - domeW * 0.3} ${top} ${CX} ${top} ` +
|
||||||
|
`C ${CX + domeW * 0.3} ${top} ${x1} ${top + 32} ${x1} ${baseY} Z`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** бейсболка: купол + козырёк-эллипс + швы + пуговка */
|
||||||
|
function baseball(opts: {
|
||||||
|
color: string;
|
||||||
|
baseY?: number; // нижний край купола
|
||||||
|
domeW?: number; // ширина купола
|
||||||
|
domeH?: number; // высота купола
|
||||||
|
brimRx?: number; // козырёк: полуширина
|
||||||
|
brimRy?: number; // козырёк: полувысота
|
||||||
|
logo?: { x: number; y: number; s: number };
|
||||||
|
}): CapShape {
|
||||||
|
const { color, baseY = 146, domeW = 376, domeH = 140, brimRx = 212, brimRy = 28 } = opts;
|
||||||
|
const top = baseY - domeH;
|
||||||
|
const seam =
|
||||||
|
(d: 1 | -1) =>
|
||||||
|
`M ${CX} ${top} C ${CX + d * domeW * 0.053} ${top + 44} ${CX + d * domeW * 0.062} ${baseY - 46} ${CX + d * domeW * 0.058} ${baseY - 4}`;
|
||||||
|
const svg =
|
||||||
|
`<path d="${dome(baseY, domeW, domeH)}" fill="${color}"/>` +
|
||||||
|
`<path d="${seam(-1)} ${seam(1)}" stroke="${shade(0.2)}" stroke-width="6" fill="none"/>` +
|
||||||
|
`<circle cx="${CX}" cy="${top + 4}" r="10" fill="${shade(0.3)}"/>` +
|
||||||
|
`<ellipse cx="${CX}" cy="${baseY + 2}" rx="${brimRx}" ry="${brimRy}" fill="${color}"/>` +
|
||||||
|
`<ellipse cx="${CX}" cy="${baseY + 2}" rx="${brimRx}" ry="${brimRy}" fill="${shade(0.24)}"/>`;
|
||||||
|
return {
|
||||||
|
color,
|
||||||
|
dark: true,
|
||||||
|
logo: opts.logo ?? { x: CX, y: Math.round(top + domeH / 2), s: Math.round(domeH * 0.48) },
|
||||||
|
svg
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** бини: купол + помпон + отворот-полоса (логотип живёт на отвороте) */
|
||||||
|
function beanie(opts: {
|
||||||
|
color: string;
|
||||||
|
baseY?: number;
|
||||||
|
domeW?: number;
|
||||||
|
domeH?: number;
|
||||||
|
bandH?: number; // высота отворота
|
||||||
|
pomR?: number; // радиус помпона
|
||||||
|
logo?: { x: number; y: number; s: number };
|
||||||
|
}): CapShape {
|
||||||
|
const { color, baseY = 148, domeW = 360, domeH = 140, bandH = 60, pomR = 24 } = opts;
|
||||||
|
const top = baseY - domeH;
|
||||||
|
const bw = domeW + 32;
|
||||||
|
const svg =
|
||||||
|
`<path d="${dome(baseY, domeW, domeH)}" fill="${color}"/>` +
|
||||||
|
`<circle cx="${CX}" cy="${top + 18}" r="${pomR}" fill="${color}"/>` +
|
||||||
|
`<circle cx="${CX}" cy="${top + 18}" r="${pomR}" fill="rgba(255,255,255,0.18)"/>` +
|
||||||
|
`<rect x="${CX - bw / 2}" y="${baseY - 36}" width="${bw}" height="${bandH}" rx="${bandH / 2}" fill="${color}"/>` +
|
||||||
|
`<rect x="${CX - bw / 2}" y="${baseY - 36}" width="${bw}" height="${bandH}" rx="${bandH / 2}" fill="${shade(0.16)}"/>`;
|
||||||
|
return {
|
||||||
|
color,
|
||||||
|
dark: true,
|
||||||
|
logo: opts.logo ?? { x: CX, y: Math.round(baseY - 36 + bandH / 2), s: Math.round(bandH * 0.8) },
|
||||||
|
svg
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** восьмиклинка: плоский купол + маленький козырёк + клинья-швы */
|
||||||
|
function flatcap(opts: {
|
||||||
|
color: string;
|
||||||
|
baseY?: number;
|
||||||
|
domeW?: number;
|
||||||
|
domeH?: number;
|
||||||
|
brimRx?: number;
|
||||||
|
brimRy?: number;
|
||||||
|
seamDx?: number; // отступ клиньев от центра
|
||||||
|
logo?: { x: number; y: number; s: number };
|
||||||
|
}): CapShape {
|
||||||
|
const { color, baseY = 150, domeW = 372, domeH = 132, brimRx = 158, brimRy = 26, seamDx = 60 } = opts;
|
||||||
|
const top = baseY - domeH;
|
||||||
|
const seam =
|
||||||
|
(d: 1 | -1) =>
|
||||||
|
`M ${CX + d * seamDx} ${top + 10} C ${CX + d * (seamDx - 12)} ${top + 54} ${CX + d * (seamDx - 16)} ${baseY - 42} ${CX + d * (seamDx - 12)} ${baseY - 2}`;
|
||||||
|
const svg =
|
||||||
|
`<path d="${dome(baseY, domeW, domeH)}" fill="${color}"/>` +
|
||||||
|
`<path d="${seam(-1)} ${seam(1)}" stroke="${shade(0.18)}" stroke-width="6" fill="none"/>` +
|
||||||
|
`<ellipse cx="${CX}" cy="${baseY}" rx="${brimRx}" ry="${brimRy}" fill="${color}"/>` +
|
||||||
|
`<ellipse cx="${CX}" cy="${baseY}" rx="${brimRx}" ry="${brimRy}" fill="${shade(0.2)}"/>`;
|
||||||
|
return {
|
||||||
|
color,
|
||||||
|
dark: false,
|
||||||
|
logo: opts.logo ?? { x: CX, y: Math.round(top + domeH * 0.52), s: Math.round(domeH * 0.44) },
|
||||||
|
svg
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** цилиндр: широкие поля + тулья + лента */
|
||||||
|
function tophat(opts: {
|
||||||
|
color: string;
|
||||||
|
baseY?: number;
|
||||||
|
crownW?: number; // ширина тульи
|
||||||
|
crownH?: number; // высота тульи
|
||||||
|
brimRx?: number; // поля: полуширина
|
||||||
|
brimRy?: number; // поля: полувысота
|
||||||
|
logo?: { x: number; y: number; s: number };
|
||||||
|
}): CapShape {
|
||||||
|
const { color, baseY = 146, crownW = 232, crownH = 132, brimRx = 242, brimRy = 30 } = opts;
|
||||||
|
const crownTop = baseY - crownH;
|
||||||
|
const svg =
|
||||||
|
`<ellipse cx="${CX}" cy="${baseY}" rx="${brimRx}" ry="${brimRy}" fill="${color}"/>` +
|
||||||
|
`<ellipse cx="${CX}" cy="${baseY}" rx="${brimRx}" ry="${brimRy}" fill="${shade(0.18)}"/>` +
|
||||||
|
`<rect x="${CX - crownW / 2}" y="${crownTop}" width="${crownW}" height="${crownH}" rx="8" fill="${color}"/>` +
|
||||||
|
`<ellipse cx="${CX}" cy="${crownTop + 4}" rx="${crownW / 2}" ry="14" fill="${color}"/>` +
|
||||||
|
`<ellipse cx="${CX}" cy="${crownTop + 4}" rx="${crownW / 2}" ry="14" fill="rgba(255,255,255,0.12)"/>` +
|
||||||
|
`<rect x="${CX - crownW / 2}" y="${baseY - 36}" width="${crownW}" height="30" fill="${shade(0.3)}"/>`;
|
||||||
|
return {
|
||||||
|
color,
|
||||||
|
dark: true,
|
||||||
|
logo: opts.logo ?? { x: CX, y: Math.round(crownTop + crownH * 0.36), s: Math.round(crownH * 0.44) },
|
||||||
|
svg
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAP_LIST = [
|
||||||
|
{ id: 'none', label: 'Без кепки', color: '', dark: false, logo: { x: 0, y: 0, s: 0 }, svg: '' },
|
||||||
|
{ id: 'baseball', label: 'Бейсболка', ...baseball({ color: '#2563eb' }) },
|
||||||
|
{ id: 'beanie', label: 'Бини', ...beanie({ color: '#ef4444' }) },
|
||||||
|
{ id: 'flatcap', label: 'Восьмиклинка', ...flatcap({ color: '#16a34a' }) },
|
||||||
|
{ id: 'tophat', label: 'Цилиндр', ...tophat({ color: '#4b5b70' }) }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** id всех кепок — выводится из CAP_LIST, отдельно не правится */
|
||||||
|
export type CapId = (typeof CAP_LIST)[number]['id'];
|
||||||
|
|
||||||
|
export interface CapDef extends CapShape {
|
||||||
|
id: CapId;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CAPS: readonly CapDef[] = CAP_LIST;
|
||||||
|
|
||||||
|
const TEE_LIST = [
|
||||||
|
{ id: 'none', label: 'Без футболки', img: null, color: '', dark: false, mono: '' },
|
||||||
|
{ id: 'white', label: 'Белая', img: '/duck/tee-white.png', color: '#e9edf1', dark: false, mono: '#0f172a' },
|
||||||
|
{ id: 'black', label: 'Чёрная', img: '/duck/tee-black.png', color: '#2b2f33', dark: true, mono: '#f8fafc' },
|
||||||
|
{ id: 'green', label: 'Терминальная', img: '/duck/tee-green.png', color: '#16a34a', dark: false, mono: '#0f172a' },
|
||||||
|
{ id: 'orange', label: 'Оранжевая', img: '/duck/tee-orange.png', color: '#f97316', dark: false, mono: '#0f172a' },
|
||||||
|
{ id: 'blue', label: 'Голубая', img: '/duck/tee-blue.png', color: '#2563eb', dark: false, mono: '#0f172a' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** id всех футболок — выводится из TEE_LIST, отдельно не правится */
|
||||||
|
export type TeeId = (typeof TEE_LIST)[number]['id'];
|
||||||
|
|
||||||
|
export const TEES: readonly TeeDef[] = TEE_LIST;
|
||||||
|
|
||||||
|
/** позиция логотипа на груди футболки (канвас 800x920) */
|
||||||
|
export const TEE_LOGO = { x: 400, y: 645, s: 96 };
|
||||||
|
|
||||||
|
const capMap = new Map(CAPS.map((c) => [c.id, c]));
|
||||||
|
const teeMap = new Map(TEES.map((t) => [t.id, t]));
|
||||||
|
|
||||||
|
export function capById(id: string | null | undefined): CapDef {
|
||||||
|
return (id && capMap.get(id as CapId)) || CAPS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teeById(id: string | null | undefined): TeeDef {
|
||||||
|
return (id && teeMap.get(id as TeeId)) || TEES[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { McpServer } from "@modelcontextprotocol/server";
|
import type { McpServer } from "@modelcontextprotocol/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const QUACK_TOOL_NAME = "quack";
|
||||||
|
|
||||||
const QUACKS = {
|
const QUACKS = {
|
||||||
happy: ["QUACK! 🎉", "Quack quack! 😄", "QUAAACK! 🦆✨"],
|
happy: ["QUACK! 🎉", "Quack quack! 😄", "QUAAACK! 🦆✨"],
|
||||||
confused: ["Qu...ack?", "Quack? 🤔", "Quaaack...?"],
|
confused: ["Qu...ack?", "Quack? 🤔", "Quaaack...?"],
|
||||||
@@ -17,7 +19,7 @@ function randomFrom<T>(arr: readonly T[]): T {
|
|||||||
|
|
||||||
export function registerDuckTools(server: McpServer): void {
|
export function registerDuckTools(server: McpServer): void {
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"quack",
|
QUACK_TOOL_NAME,
|
||||||
{
|
{
|
||||||
title: "Quack",
|
title: "Quack",
|
||||||
description:
|
description:
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type {
|
||||||
|
ReportRoot,
|
||||||
|
PerTaskRow,
|
||||||
|
ScenarioName,
|
||||||
|
} from "@duck/types";
|
||||||
|
import { SCENARIO_NAMES, SCENARIO_LABELS, SCENARIO_DESCS } from "@duck/types";
|
||||||
|
|
||||||
|
export { SCENARIO_NAMES, SCENARIO_LABELS, SCENARIO_DESCS };
|
||||||
|
|
||||||
|
export interface RowViewModel extends PerTaskRow {
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPerTask(report: ReportRoot): RowViewModel[] {
|
||||||
|
return Object.entries(report.models).flatMap(([model, rep]) =>
|
||||||
|
rep.perTask.map((row) => ({ ...row, model })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelShort(name: string): string {
|
||||||
|
const [base, ver] = String(name).split(":");
|
||||||
|
return ver ? `${base}:${ver}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PctTone = "safe" | "warn" | "bad";
|
||||||
|
|
||||||
|
export function pctTone(p: number): PctTone {
|
||||||
|
return p >= 70 ? "safe" : p >= 40 ? "warn" : "bad";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReviewTotals(report: ReportRoot): { reviewed: number; pending: number } {
|
||||||
|
let reviewed = 0;
|
||||||
|
let pending = 0;
|
||||||
|
for (const rep of Object.values(report.models)) {
|
||||||
|
for (const s of SCENARIO_NAMES) {
|
||||||
|
const g = rep.scenarios[s];
|
||||||
|
if (g) {
|
||||||
|
reviewed += g.reviewed || 0;
|
||||||
|
pending += g.pending || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { reviewed, pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scenarioOrder(): ScenarioName[] {
|
||||||
|
return [...SCENARIO_NAMES];
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: '/', label: 'Главная' },
|
||||||
|
{ href: '/mcp', label: 'MCP' },
|
||||||
|
{ href: '/reports', label: 'Отчёты' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="brand" href="/">🦆 Rubber Duck</a>
|
||||||
|
<div class="links">
|
||||||
|
{#each links as link}
|
||||||
|
<a class="link" class:active={page.url.pathname === link.href} href={link.href}>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="wrap">{@render children()}</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.topbar {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
max-width: 1160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel2);
|
||||||
|
}
|
||||||
|
.link.active {
|
||||||
|
color: #fff;
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
max-width: 1160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 20px 60px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DuckAvatar from '$lib/components/duck/DuckAvatar.svelte';
|
||||||
|
import ChatPanel from '$lib/components/duck/ChatPanel.svelte';
|
||||||
|
import WardrobePanel from '$lib/components/duck/WardrobePanel.svelte';
|
||||||
|
import SettingsPanel from '$lib/components/duck/SettingsPanel.svelte';
|
||||||
|
import { outfit, saveOutfit } from '$lib/duck/state.svelte';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'chat', label: 'Чат' },
|
||||||
|
{ id: 'wardrobe', label: 'Гардероб' },
|
||||||
|
{ id: 'settings', label: 'Настройки' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Tab = (typeof TABS)[number]['id'];
|
||||||
|
|
||||||
|
let tab = $state<Tab>('chat');
|
||||||
|
|
||||||
|
// сохраняем образ при любом изменении
|
||||||
|
$effect(() => {
|
||||||
|
saveOutfit();
|
||||||
|
});
|
||||||
|
|
||||||
|
const outfitLine = $derived(
|
||||||
|
`{"cap":"${outfit.cap}","cap_logo":${JSON.stringify(outfit.capLogo)},` +
|
||||||
|
`"tee":"${outfit.tee}","tee_logo":${JSON.stringify(outfit.teeLogo)}}`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Rubber Duck — отладка с уткой</title>
|
||||||
|
<meta name="description" content="Резиновая утка для отладки: переодевай, вешай логотипы, рассказывай о проблеме." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<div>
|
||||||
|
<h1>Rubber Duck<span class="cursor mono">_</span></h1>
|
||||||
|
<p class="sub">
|
||||||
|
Проговори проблему вслух — утка выслушает. Пока говоришь, решение часто находится само.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="chips mono">
|
||||||
|
<span class="chip">debugging-as-a-service</span>
|
||||||
|
<span class="chip">local-first</span>
|
||||||
|
<span class="chip">quack · симуляция</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="stage" aria-label="Сцена с уткой">
|
||||||
|
<div class="stage-top mono">
|
||||||
|
<span>duck_id: RU-1337</span>
|
||||||
|
<span>status: listening</span>
|
||||||
|
<span>uptime: ∞</span>
|
||||||
|
</div>
|
||||||
|
<div class="duck-wrap">
|
||||||
|
<DuckAvatar />
|
||||||
|
</div>
|
||||||
|
<div class="term mono">
|
||||||
|
<div class="term-line"><span class="prompt">$</span> duck outfit --json</div>
|
||||||
|
<div class="term-out">{outfitLine}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="side" aria-label="Чат и настройки">
|
||||||
|
<div class="tabs mono" role="tablist">
|
||||||
|
{#each TABS as t (t.id)}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class:active={tab === t.id}
|
||||||
|
aria-selected={tab === t.id}
|
||||||
|
onclick={() => (tab = t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-body chat-body" class:hidden={tab !== 'chat'}>
|
||||||
|
<ChatPanel />
|
||||||
|
</div>
|
||||||
|
<div class="tab-body" class:hidden={tab !== 'wardrobe'}>
|
||||||
|
<WardrobePanel />
|
||||||
|
</div>
|
||||||
|
<div class="tab-body" class:hidden={tab !== 'settings'}>
|
||||||
|
<SettingsPanel />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.cursor {
|
||||||
|
color: #f5883c;
|
||||||
|
animation: caret 1.1s steps(1) infinite;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@keyframes caret {
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 520px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 400px;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.stage {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
radial-gradient(420px 320px at 50% 46%, rgba(255, 217, 59, 0.09), transparent 70%),
|
||||||
|
linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px),
|
||||||
|
var(--panel);
|
||||||
|
background-size: auto, 28px 28px, 28px 28px, auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stage-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.duck-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 22px 20px 10px;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
.term {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: rgba(2, 6, 23, 0.45);
|
||||||
|
padding: 10px 16px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.term-line {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.prompt {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.term-out {
|
||||||
|
color: #fbbf24;
|
||||||
|
margin-top: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.side {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--panel);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 640px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tabs button {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tabs button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tabs button.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: #3b82f6;
|
||||||
|
}
|
||||||
|
.tab-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.chat-body {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.tab-body.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 1020px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.side {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.chat-body {
|
||||||
|
height: 520px;
|
||||||
|
}
|
||||||
|
.duck-wrap {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { RequestEvent } from "@sveltejs/kit";
|
||||||
|
import { createMcpHandler } from "mcp-handler";
|
||||||
|
import { registerDuckTools } from "$lib/mcp";
|
||||||
|
|
||||||
|
const handler = createMcpHandler(
|
||||||
|
async (server) => {
|
||||||
|
registerDuckTools(server);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverInfo: { name: "rubber-duck-mcp", version: "1.0.0" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function GET(event: RequestEvent): Promise<Response> {
|
||||||
|
return handler(event.request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function POST(event: RequestEvent): Promise<Response> {
|
||||||
|
return handler(event.request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<h1>🩵 Rubber Duck MCP</h1>
|
||||||
|
<p>
|
||||||
|
MCP-сервер для техники «резиновая уточка»: когда ИИ застревает в логическом цикле
|
||||||
|
или склонен к галлюцинациям, он вызывает инструмент <code>quack()</code>, чтобы
|
||||||
|
сформулировать мысль и продолжить.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Режим <strong>Streamable HTTP</strong> (stateless), на Vercel под
|
||||||
|
<code>mcp-handler</code> + <code>@modelcontextprotocol/server</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Эндпоинт</h2>
|
||||||
|
<p>Подключите как MCP-сервер (тип Streamable HTTP) в Cursor, Claude Desktop и других клиентах:</p>
|
||||||
|
<pre><code>POST /api/mcp</code></pre>
|
||||||
|
|
||||||
|
<h2>Инструменты</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<code>quack</code> — возвращает кряк. Опциональный параметр
|
||||||
|
<code>mood</code>: <code>happy | confused | excited | sleepy</code>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Проверка</h2>
|
||||||
|
<pre><code>curl -X POST https://rubber-duck-mcp.vercel.app/api/mcp \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params"{"name":"quack","arguments"{"mood":"happy"}}}}'
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<h2>Запуск локально</h2>
|
||||||
|
<pre><code>pnpm install
|
||||||
|
pnpm dev # http://localhost:5173
|
||||||
|
</code></pre>
|
||||||
|
<p class="muted">Локально эндпоинт доступен на <code>/api/mcp</code> того же dev-сервера.</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
pre {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
margin: 28px 0 10px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ScenarioName } from '@duck/types';
|
||||||
|
import {
|
||||||
|
SCENARIO_NAMES,
|
||||||
|
SCENARIO_LABELS,
|
||||||
|
SCENARIO_DESCS,
|
||||||
|
modelShort,
|
||||||
|
pctTone
|
||||||
|
} from '$lib/reports';
|
||||||
|
import type { ReportsPageData } from './+page.ts';
|
||||||
|
|
||||||
|
let { data }: { data: ReportsPageData } = $props();
|
||||||
|
|
||||||
|
const { report, rows, review } = $derived(data);
|
||||||
|
const models = $derived(Object.keys(report.aggregate.perModel));
|
||||||
|
const perModel = $derived(report.aggregate.perModel);
|
||||||
|
const perScenarioAgg = $derived(report.aggregate.perScenario);
|
||||||
|
|
||||||
|
let selModel = $state('all');
|
||||||
|
let selScenario = $state('all');
|
||||||
|
|
||||||
|
const filtered = $derived(
|
||||||
|
rows.filter(
|
||||||
|
(r) =>
|
||||||
|
(selModel === 'all' || r.model === selModel) &&
|
||||||
|
(selScenario === 'all' || r.scenario === selScenario)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function scenarioAccuracy(m: string, s: ScenarioName): number {
|
||||||
|
return report.models[m]?.scenarios[s]?.accuracy ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toneClass(p: number): string {
|
||||||
|
return `tone-${pctTone(p)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultText(correct: boolean | null): string {
|
||||||
|
return correct === null || correct === undefined ? '?' : correct ? '✓' : '✗';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Rubber Duck MCP — отчёты</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>🦆 Rubber Duck MCP — отчёт тестирования</h1>
|
||||||
|
<p class="meta">
|
||||||
|
MCP: <b>{report.mcpUrl}</b> · Моделей: <b>{models.length}</b> ·
|
||||||
|
Дата: <b>{report.generatedAt}</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Сводка по моделям</h2>
|
||||||
|
<div class="shell">
|
||||||
|
<table class="summary">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Модель</th>
|
||||||
|
<th>Контроль</th>
|
||||||
|
<th>Мысли</th>
|
||||||
|
<th>Слепая</th>
|
||||||
|
<th>Помощник</th>
|
||||||
|
<th>Средняя acc</th>
|
||||||
|
<th>Размечено</th>
|
||||||
|
<th>Утка</th>
|
||||||
|
<th>Вызовов</th>
|
||||||
|
<th>ср. токены out</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each models as m (m)}
|
||||||
|
{@const g = perModel[m]}
|
||||||
|
<tr>
|
||||||
|
<td class="pm-name">{modelShort(m)}</td>
|
||||||
|
<td class="pm-num {toneClass(scenarioAccuracy(m, 'control'))}">
|
||||||
|
{scenarioAccuracy(m, 'control')}%
|
||||||
|
</td>
|
||||||
|
<td class="pm-num {toneClass(scenarioAccuracy(m, 'thinking'))}">
|
||||||
|
{scenarioAccuracy(m, 'thinking')}%
|
||||||
|
</td>
|
||||||
|
<td class="pm-num {toneClass(scenarioAccuracy(m, 'blind'))}">
|
||||||
|
{scenarioAccuracy(m, 'blind')}%
|
||||||
|
</td>
|
||||||
|
<td class="pm-num {toneClass(scenarioAccuracy(m, 'mentor'))}">
|
||||||
|
{scenarioAccuracy(m, 'mentor')}%
|
||||||
|
</td>
|
||||||
|
<td class="pm-num">{g.accuracy}%</td>
|
||||||
|
<td class="pm-num">{g.reviewed}<span class="muted"> / {g.totalRows}</span></td>
|
||||||
|
<td class="pm-num">{g.duckUsed}</td>
|
||||||
|
<td class="pm-num">{g.toolCalls}</td>
|
||||||
|
<td class="pm-num">{g.avgGenTokens}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if review.pending > 0}
|
||||||
|
<div class="review-note">
|
||||||
|
⚠ Не все ответы размечены: <b>{review.reviewed}</b> проверено,
|
||||||
|
<b>{review.pending}</b> ожидают ревью (показаны символом «?»).
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h2>Агрегат по сценариям (все модели)</h2>
|
||||||
|
<div class="cards">
|
||||||
|
{#each SCENARIO_NAMES as key (key)}
|
||||||
|
{@const agg = perScenarioAgg[key]}
|
||||||
|
{#if agg}
|
||||||
|
<div class="card">
|
||||||
|
<div class="scenario-head">
|
||||||
|
<div>
|
||||||
|
<div class="scenario-title">{SCENARIO_LABELS[key]}</div>
|
||||||
|
<div class="scenario-desc">{SCENARIO_DESCS[key]}</div>
|
||||||
|
</div>
|
||||||
|
<div class="accuracy {toneClass(agg.accuracy)}">{agg.accuracy}%</div>
|
||||||
|
</div>
|
||||||
|
{#if agg.pending > 0}
|
||||||
|
<div class="pending-badge">неразмечено: {agg.pending}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="bar">
|
||||||
|
<div class="bar-fill {toneClass(agg.accuracy)}" style="width: {agg.accuracy}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<span class="m-label">Верных</span>
|
||||||
|
<span class="m-val">{agg.correct} <span class="m-mod">/ {agg.reviewed}</span></span>
|
||||||
|
<span class="m-sub">размечено из {agg.tasks}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="m-label">Утка (MCP)</span>
|
||||||
|
<span class="m-val">{agg.tasks ? Math.round((agg.duckUsed / agg.tasks) * 100) : 0}%</span>
|
||||||
|
<span class="m-sub">в {agg.duckUsed} из {agg.tasks} задач</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="m-label">quack / задачу</span>
|
||||||
|
<span class="m-val">{agg.avgCalls}</span>
|
||||||
|
<span class="m-sub">вызовов в среднем</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="agg-models">
|
||||||
|
{#each Object.entries(agg.byModel) as [m, g] (m)}
|
||||||
|
<div class="agg-model">
|
||||||
|
<span class="agg-model-name">{modelShort(m)}{g.reviewed ? '' : ' <span class="muted">(?)</span>'}</span>
|
||||||
|
<div class="bar">
|
||||||
|
<div class="bar-fill {toneClass(g.accuracy)}" style="width: {g.reviewed ? g.accuracy : 0}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="agg-model-val {toneClass(g.accuracy)}">{g.accuracy}%</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if Object.keys(report.prompts || {}).length}
|
||||||
|
<details class="prompts" open>
|
||||||
|
<summary>Промпты (то, что передаётся модели)</summary>
|
||||||
|
<div class="prompts-body">
|
||||||
|
{#each SCENARIO_NAMES as key (key)}
|
||||||
|
{@const p = report.prompts?.[key]}
|
||||||
|
{#if p}
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-name">{SCENARIO_LABELS[key]}</div>
|
||||||
|
<div class="prompt-text">{p}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<label>Модель:</label>
|
||||||
|
<select bind:value={selModel}>
|
||||||
|
<option value="all">Все модели</option>
|
||||||
|
{#each models as m (m)}
|
||||||
|
<option value={m}>{modelShort(m)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class:active={selScenario === 'all'} onclick={() => (selScenario = 'all')}>Все сценарии</button>
|
||||||
|
{#each SCENARIO_NAMES as key (key)}
|
||||||
|
<button class:active={selScenario === key} onclick={() => (selScenario = key)}>
|
||||||
|
{SCENARIO_LABELS[key]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<span class="count">показано: {filtered.length} из {rows.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tbl-shell">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Модель</th>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Сценарий</th>
|
||||||
|
<th>✓</th>
|
||||||
|
<th>Утка</th>
|
||||||
|
<th>Вопрос</th>
|
||||||
|
<th>Ожид.</th>
|
||||||
|
<th>in/out</th>
|
||||||
|
<th>Ответ модели</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filtered as r (r.model + r.id + r.scenario)}
|
||||||
|
<tr class:ok={r.correct === true} class:no={r.correct === false} class:pend={r.correct === null || r.correct === undefined}>
|
||||||
|
<td class="r-model">{modelShort(r.model)}</td>
|
||||||
|
<td class="r-id">{r.id}</td>
|
||||||
|
<td class="r-s">{SCENARIO_LABELS[r.scenario]}</td>
|
||||||
|
<td class="r-result">{resultText(r.correct)}</td>
|
||||||
|
<td class="r-duck">{r.duckUsed ? '🦆' : '—'} <span class="muted">({r.toolCalls})</span></td>
|
||||||
|
<td class="r-question">{r.question}</td>
|
||||||
|
<td class="r-expected">{r.expected}</td>
|
||||||
|
<td class="r-tokens">{r.promptTokens}<span class="muted"> / </span>{r.genTokens}</td>
|
||||||
|
<td class="r-answer">{r.response}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
margin: 26px 0 12px;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.meta b {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
background: var(--panel2);
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tbody td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.pm-name {
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pm-num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.tone-safe {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.tone-warn {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.tone-bad {
|
||||||
|
color: var(--no);
|
||||||
|
}
|
||||||
|
.review-note {
|
||||||
|
margin: 14px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.cards {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.scenario-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.scenario-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.scenario-desc {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.accuracy {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.pending-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--panel2);
|
||||||
|
border-radius: 99px;
|
||||||
|
margin: 14px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: currentColor;
|
||||||
|
transition: width 0.4s;
|
||||||
|
}
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.metric {
|
||||||
|
background: var(--panel2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.m-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.m-val {
|
||||||
|
display: block;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.m-mod {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.m-sub {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.agg-models {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.agg-model {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.agg-model .bar {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.agg-model-name {
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.agg-model-val {
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.prompts {
|
||||||
|
margin: 20px 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.prompts summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.prompts summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.prompts summary::before {
|
||||||
|
content: '▸ ';
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.prompts[open] summary::before {
|
||||||
|
content: '▾ ';
|
||||||
|
}
|
||||||
|
.prompts-body {
|
||||||
|
padding: 0 16px 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.prompt-item {
|
||||||
|
background: var(--panel2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.prompt-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.prompt-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.filters label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.filters select {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.filters button {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.filters button.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.tbl-shell {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.r-model {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.r-id {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.r-s {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.r-result {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
tr.ok .r-result {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
tr.no .r-result {
|
||||||
|
color: var(--no);
|
||||||
|
}
|
||||||
|
tr.pend .r-result {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
tr.pend {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.r-duck {
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.r-question {
|
||||||
|
max-width: 300px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.r-expected {
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
.r-answer {
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.r-answer,
|
||||||
|
.r-question {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReportRoot } from '@duck/types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { Load } from '@sveltejs/kit';
|
||||||
|
import { extractPerTask, getReviewTotals } from '$lib/reports';
|
||||||
|
|
||||||
|
export interface ReportsPageData {
|
||||||
|
report: ReportRoot;
|
||||||
|
rows: ReturnType<typeof extractPerTask>;
|
||||||
|
review: { reviewed: number; pending: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: Load = async ({ fetch }): Promise<ReportsPageData> => {
|
||||||
|
const res = await fetch('/report.json');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw error(res.status, `report.json not found (${res.status})`);
|
||||||
|
}
|
||||||
|
const report = (await res.json()) as ReportRoot;
|
||||||
|
return {
|
||||||
|
report,
|
||||||
|
rows: extractPerTask(report),
|
||||||
|
review: getReviewTotals(report)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-vercel';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
sveltekit({
|
||||||
|
compilerOptions: {
|
||||||
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
|
runes: ({ filename }) =>
|
||||||
|
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||||
|
},
|
||||||
|
|
||||||
|
adapter: adapter()
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# План миграции: единый SvelteKit-проект + типизированный eval
|
||||||
|
|
||||||
|
> Статус: **утверждён для проработки** (запуск реализации — по отдельной команде).
|
||||||
|
> Последнее обновление: 2026-09-04
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Общая цель
|
||||||
|
|
||||||
|
Убрать избыточное разделение на два проекта и перевести всё в **один SvelteKit-проект**
|
||||||
|
на Vercel, а eval-harness — с JavaScript (`.mjs`) на **TypeScript**, с **общими типами**
|
||||||
|
между eval и сайтом.
|
||||||
|
|
||||||
|
Итог: один домен, один деплой, один контракт данных для отчётов (он не «ломается»,
|
||||||
|
потому что eval и рендер сидят на одних типах).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Целевая архитектура
|
||||||
|
|
||||||
|
### 2.1. Общий пакет типов — `packages/types`
|
||||||
|
|
||||||
|
Workspace-пакет `@duck/types`, используемый и eval, и `apps/web`.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
packages/types/
|
||||||
|
package.json # name: "@duck/types" (через pnpm pkg)
|
||||||
|
tsconfig.json
|
||||||
|
src/index.ts # общий контракт
|
||||||
|
```
|
||||||
|
|
||||||
|
Типы (защищают контракт отчёта):
|
||||||
|
|
||||||
|
- `Task { id, question, answer }`
|
||||||
|
- `ScenarioName = "control" | "blind" | "mentor"`
|
||||||
|
- `ScenarioSummary { tasks, reviewed, correct, accuracy, pending, duckUsed, toolCalls, avgCalls }`
|
||||||
|
- `PerTaskRow { id, scenario, correct: boolean|null, duckUsed, toolCalls, promptTokens, genTokens, duckTokens, response, expected, question }`
|
||||||
|
- `ModelReport`, `ReportRoot { generatedAt, mcpUrl, prompts, models, aggregate, reviewedAt? }`
|
||||||
|
- `Reviews` (контракт `review.json`)
|
||||||
|
|
||||||
|
Роль: и `apps/eval` при записи `out/report.json`, и `apps/web` при рендере `/reports`
|
||||||
|
используют эти типы. Рассогласование схемы → ошибка на этапе компиляции, а не падение в проде.
|
||||||
|
|
||||||
|
### 2.2. Приложение — `apps/web` (единственный деплой на Vercel)
|
||||||
|
|
||||||
|
```txt
|
||||||
|
apps/web/ # SvelteKit + TypeScript + @sveltejs/adapter-vercel
|
||||||
|
src/routes/
|
||||||
|
+layout.svelte # общий nav-каркас
|
||||||
|
+page.svelte # / — домашняя (заглушка «скоро»)
|
||||||
|
/mcp/+page.svelte # /mcp — описание MCP + FAQ/README
|
||||||
|
/reports/+page.svelte # /reports — полные отчёты (рендер из report.json)
|
||||||
|
/api/mcp/+server.ts # /api/mcp — MCP endpoint (mcp-handler)
|
||||||
|
src/lib/
|
||||||
|
mcp.ts # перенос из apps/mcp/lib/mcp.ts
|
||||||
|
reports.ts # рендер-хелперы на базе @duck/types
|
||||||
|
static/report.json # коммитится — источник для /reports
|
||||||
|
```
|
||||||
|
|
||||||
|
**Домен:** `rubber-duck-mcp.vercel.app` — единственный. Всё на нём:
|
||||||
|
|
||||||
|
- `/` → домашняя
|
||||||
|
- `/mcp` → описание
|
||||||
|
- `/reports` → отчёты
|
||||||
|
- `/api/mcp` → MCP endpoint
|
||||||
|
|
||||||
|
### 2.3. Eval — типизация в TS
|
||||||
|
|
||||||
|
```txt
|
||||||
|
apps/eval/
|
||||||
|
src/*.ts # run, runner, report, review (из .mjs)
|
||||||
|
lib/mcpClient.ts # (из lib/mcpClient.mjs)
|
||||||
|
lib/ollama.ts # (из lib/ollama.mjs)
|
||||||
|
tsconfig.json
|
||||||
|
out/report.json # генерируется (НЕ коммитится)
|
||||||
|
data/tasks.json # (как есть)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Все решённые вопросы (закрытые решения)
|
||||||
|
|
||||||
|
| Вопрос | Решение |
|
||||||
|
| --------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| Домен | единый `rubber-duck-mcp.vercel.app` |
|
||||||
|
| Куда класть SvelteKit | новая папка `apps/web` |
|
||||||
|
| Линковка Vercel | перелинковать существующий project `mcp` на `apps/web` (домен сохр.) |
|
||||||
|
| Frontend-домен | удалить `rubber-duck-frontend.vercel.app` и Vercel-проект `frontend` |
|
||||||
|
| `report.json` в проде | коммитить в `apps/web/static/report.json` |
|
||||||
|
| Страница `/reports` | полный формат (сводные таблицы по моделям + разбивка по задачам) |
|
||||||
|
| MCP-фреймворк | mcp-handler совместим с SvelteKit (web-standard `(Request) => Response`), Next не нужен |
|
||||||
|
| Язык eval | TypeScript + общий пакет типов |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Изменение запуска (было → стало)
|
||||||
|
|
||||||
|
Сейчас (JS):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node src/run.mjs
|
||||||
|
node src/review.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало (TS): компиляция не требуется для dev — запуск через `tsx`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# apps/eval
|
||||||
|
tsx src/run.ts
|
||||||
|
tsx src/review.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
> Локальная генерация статического `report.html` (`report-html`) **убрана**: `/reports` на
|
||||||
|
> сайте рендерит те же данные из `report.json` через Svelte-компоненты на общих типах.
|
||||||
|
|
||||||
|
Скрипты в `apps/eval/package.json` и корневом `package.json` (`eval:*`) обновляются на `tsx`.
|
||||||
|
Добавляется:
|
||||||
|
|
||||||
|
- **typecheck**: `tsc --noEmit` для eval и web (контракт не ломается).
|
||||||
|
- dev-зависимости eval: `tsx`, `typescript`, `@types/node`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Шаги реализации (поэтапно)
|
||||||
|
|
||||||
|
### Шаг 1 — `packages/types`
|
||||||
|
|
||||||
|
- Создать workspace-пакет `@duck/types` (манифест через `pnpm pkg`).
|
||||||
|
- Написать `src/index.ts` с общим контрактом + `tsconfig.json`.
|
||||||
|
- Убедиться, что pnpm-workspace (`packages: apps/*`) знает про `packages/*`.
|
||||||
|
|
||||||
|
### Шаг 2 — eval → TypeScript
|
||||||
|
|
||||||
|
- Переименовать `lib/*.mjs` → `.ts`, `src/*.mjs` → `.ts`.
|
||||||
|
- Подключить `@duck/types`, поправить импорты.
|
||||||
|
- Добавить `tsconfig.json`, зависимости (`tsx`, `typescript`, `@types/node`) через `pnpm pkg`/`pnpm add`.
|
||||||
|
- Обновить скрипты запуска (`eval:*` → `tsx ...`).
|
||||||
|
- Проверка: `tsc --noEmit` + короткий прогон `tsx src/run.ts --limit 1`.
|
||||||
|
|
||||||
|
### Шаг 3 — `apps/web` (SvelteKit)
|
||||||
|
|
||||||
|
- Инициализировать SvelteKit + TypeScript + `@sveltejs/adapter-vercel`.
|
||||||
|
- Подключить `@duck/types`.
|
||||||
|
|
||||||
|
### Шаг 4 — Перенос MCP
|
||||||
|
|
||||||
|
- `apps/mcp/lib/mcp.ts` → `apps/web/src/lib/mcp.ts`.
|
||||||
|
- `apps/mcp/app/api/mcp/route.ts` → `apps/web/src/routes/api/mcp/+server.ts`
|
||||||
|
(mcp-handler принимает `Request` → из SvelteKit передаём `event.request`).
|
||||||
|
- Убедиться, что `runtime = "nodejs"` задан.
|
||||||
|
|
||||||
|
### Шаг 5 — Страницы
|
||||||
|
|
||||||
|
- `+layout.svelte` — навигация;
|
||||||
|
- `/` — заглушка «скоро» (дизайн позже);
|
||||||
|
- `/mcp` — описание + FAQ + README (перенести текст из старой `page.tsx`);
|
||||||
|
- `/reports` — `load` из `static/report.json` через `lib/reports.ts` на общих типах;
|
||||||
|
рендер карточек/таблиц — Svelte-компоненты (визуальный стиль перенесён из прежнего `report-html.mjs`).
|
||||||
|
|
||||||
|
### Шаг 6 — Данные
|
||||||
|
|
||||||
|
- Скопировать `apps/eval/out/report.json` → `apps/web/static/report.json` и закоммитить.
|
||||||
|
- Добавить команду «sync отчёта на сайт» (копирует report.json после прогона eval).
|
||||||
|
|
||||||
|
### Шаг 7 — Деплой/конфиг
|
||||||
|
|
||||||
|
- `apps/web/package.json` + корневой: `dev`, `build`, `deploy` → `apps/web`;
|
||||||
|
`eval:*` → `tsx`; убрать `deploy-mcp`, `deploy-frontend`.
|
||||||
|
- Удалить `apps/frontend` + скрипты + Vercel-проект/домен frontend.
|
||||||
|
- Удалить `apps/mcp` (Next) после переноса.
|
||||||
|
- Перелинковать Vercel-проект `mcp` на `apps/web`; проверить, что домен назначен на проект.
|
||||||
|
|
||||||
|
### Шаг 8 — Проверка
|
||||||
|
|
||||||
|
- `tsc --noEmit` (eval + web).
|
||||||
|
- `pnpm dev` → проверить страницы и `/api/mcp`.
|
||||||
|
- `vercel --prod` → verify на домене: `/`, `/mcp`, `/reports`, `/api/mcp` (quack).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Что НЕ делаем сейчас
|
||||||
|
|
||||||
|
- Детальный дизайн страниц/домашней (делаем позднее, отдельно).
|
||||||
|
- Автоматическое обновление `report.json` на каждый прогон (только sync-команда).
|
||||||
|
- Расширенное наполнение FAQ/README (только перенос базового текста).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Риски и митигация
|
||||||
|
|
||||||
|
| Риск | Митигация |
|
||||||
|
| -------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| `mcp-handler` под Next | фреймворк-агностичен (web-standard Request/Response), проверено по `d.ts` |
|
||||||
|
| Нужна компиляция для запуска eval | dev — `tsx`; при необходимости `tsc build` для prod-скриптов |
|
||||||
|
| Несовместимость схемы отчёта | общие типы `@duck/types` + `tsc --noEmit` в eval и web |
|
||||||
|
| Необратимое удаление Vercel-проектов | frontend/mcp удаляем аккуратно, после успешного переноса |
|
||||||
|
| `report.json` рассинхронизируется с прогоном | явная команда sync + коммит |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Порядок выполнения
|
||||||
|
|
||||||
|
Поэтапно, с проверкой на каждом шаге:
|
||||||
|
`packages/types` → eval-TS → `apps/web` (скелет) → перенос MCP → страницы → данные
|
||||||
|
→ деплой/конфиг → проверка.
|
||||||
|
|
||||||
|
(Реализацию начинать только по явной команде.)
|
||||||
@@ -3,16 +3,14 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Rubber duck debugging - frontend + MCP server monorepo",
|
"description": "Rubber duck debugging - frontend + MCP server monorepo",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --dir apps/frontend dev",
|
"dev": "pnpm --dir apps/web dev",
|
||||||
"build-frontend": "pnpm --dir apps/frontend build",
|
"build": "pnpm --dir apps/web build",
|
||||||
"build-mcp": "pnpm --dir apps/mcp build",
|
"deploy": "vercel --prod --yes",
|
||||||
"dev-mcp": "pnpm --dir apps/mcp dev",
|
"typecheck": "pnpm --dir apps/eval run typecheck && pnpm --dir apps/web run typecheck",
|
||||||
"typecheck-mcp": "pnpm --dir apps/mcp exec tsc --noEmit",
|
"eval:run": "pnpm --dir apps/eval run run",
|
||||||
"deploy-mcp": "vercel --cwd apps/mcp --prod --yes",
|
"eval:review": "pnpm --dir apps/eval run review",
|
||||||
"deploy-frontend": "vercel --cwd apps/frontend --prod --yes",
|
"eval:sync": "node scripts/sync-report.mjs",
|
||||||
"eval:run": "pnpm --dir apps/eval exec node src/run.mjs",
|
"eval:typecheck": "pnpm --dir apps/eval run typecheck"
|
||||||
"eval:review": "pnpm --dir apps/eval exec node src/review.mjs",
|
|
||||||
"eval:html": "pnpm --dir apps/eval exec node src/report-html.mjs"
|
|
||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devEngines": {
|
"devEngines": {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@duck/types",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// ── Tasks ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenarios ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ScenarioName = "control" | "thinking" | "blind" | "mentor";
|
||||||
|
|
||||||
|
export const SCENARIO_NAMES: readonly ScenarioName[] = [
|
||||||
|
"control",
|
||||||
|
"thinking",
|
||||||
|
"blind",
|
||||||
|
"mentor",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SCENARIO_LABELS: Record<ScenarioName, string> = {
|
||||||
|
control: "Без утки (контроль)",
|
||||||
|
thinking: "Думать вслух",
|
||||||
|
blind: "Слепая утка",
|
||||||
|
mentor: "Утка-помощник",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SCENARIO_DESCS: Record<ScenarioName, string> = {
|
||||||
|
control: "Модель решает задачу напрямую, без инструментов.",
|
||||||
|
thinking: "Модель выписывает рассуждения вслух, без дука.",
|
||||||
|
blind: "Модель объясняет подход коллеге, вызывает quack, не зная заранее ответ.",
|
||||||
|
mentor: "Модель работает в паре, зная, что ответ будет только «quack».",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Per-task evaluation row ─────────────────────────────
|
||||||
|
|
||||||
|
export interface PerTaskRow {
|
||||||
|
id: string;
|
||||||
|
scenario: ScenarioName;
|
||||||
|
correct: boolean | null;
|
||||||
|
duckUsed: boolean;
|
||||||
|
toolCalls: number;
|
||||||
|
promptTokens: number;
|
||||||
|
genTokens: number;
|
||||||
|
duckTokens: number;
|
||||||
|
response: string;
|
||||||
|
expected: string;
|
||||||
|
question: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-scenario summary (inside a model report) ────────
|
||||||
|
|
||||||
|
export interface ScenarioSummary {
|
||||||
|
tasks: number;
|
||||||
|
reviewed: number;
|
||||||
|
correct: number;
|
||||||
|
accuracy: number;
|
||||||
|
pending: number;
|
||||||
|
duckUsed: number;
|
||||||
|
toolCalls: number;
|
||||||
|
avgCalls: number;
|
||||||
|
avgPromptTokens: number;
|
||||||
|
avgGenTokens: number;
|
||||||
|
avgDuckTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-model report ────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ModelReport {
|
||||||
|
model: string;
|
||||||
|
scenarios: Record<ScenarioName, ScenarioSummary>;
|
||||||
|
perTask: PerTaskRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aggregate ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AggregateByModel {
|
||||||
|
tasks: number;
|
||||||
|
reviewed: number;
|
||||||
|
correct: number;
|
||||||
|
accuracy: number;
|
||||||
|
duckUsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregateScenario {
|
||||||
|
tasks: number;
|
||||||
|
reviewed: number;
|
||||||
|
correct: number;
|
||||||
|
accuracy: number;
|
||||||
|
pending: number;
|
||||||
|
duckUsed: number;
|
||||||
|
toolCalls: number;
|
||||||
|
avgCalls: number;
|
||||||
|
byModel: Record<string, AggregateByModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregateModel {
|
||||||
|
tasks: number;
|
||||||
|
totalRows: number;
|
||||||
|
reviewed: number;
|
||||||
|
correct: number;
|
||||||
|
accuracy: number;
|
||||||
|
duckUsed: number;
|
||||||
|
toolCalls: number;
|
||||||
|
avgGenTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Aggregate {
|
||||||
|
perScenario: Record<ScenarioName, AggregateScenario>;
|
||||||
|
perModel: Record<string, AggregateModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Root report (report.json) ───────────────────────────
|
||||||
|
|
||||||
|
export interface ReportRoot {
|
||||||
|
generatedAt: string;
|
||||||
|
mcpUrl: string;
|
||||||
|
prompts: Record<ScenarioName, string>;
|
||||||
|
models: Record<string, ModelReport>;
|
||||||
|
aggregate: Aggregate;
|
||||||
|
reviewedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reviews (review.json) ───────────────────────────────
|
||||||
|
// Keys: "${model}|${taskId}|${scenario}"
|
||||||
|
|
||||||
|
export type ReviewKey = `${string}|${string}|${string}`;
|
||||||
|
export type Reviews = Record<ReviewKey, boolean>;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
packages:
|
packages:
|
||||||
- apps/*
|
- apps/*
|
||||||
|
- packages/*
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const src = path.join(root, 'apps', 'eval', 'out', 'report.json');
|
||||||
|
const destDir = path.join(root, 'apps', 'web', 'static');
|
||||||
|
const dest = path.join(destDir, 'report.json');
|
||||||
|
|
||||||
|
if (!existsSync(src)) {
|
||||||
|
console.error(`[sync-report] Not found: ${src}`);
|
||||||
|
console.error('Run the eval first (pnpm eval:run / eval:review) to produce an out/report.json.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(destDir, { recursive: true });
|
||||||
|
copyFileSync(src, dest);
|
||||||
|
console.log(`[sync-report] ${path.relative(root, src)} -> ${path.relative(root, dest)}`);
|
||||||
@@ -1,44 +1,141 @@
|
|||||||
# 🎯 Суть эксперимента
|
# 🎯 Эксперимент «Резиновая уточка»
|
||||||
|
|
||||||
Проверить, как промежуточный запрос к MPC-серверу (который на любое сообщение отвечает "quack") влияет на качество рассуждений и точность ответов LLM, особенно «слабых» моделей без встроенного механизма thinking.
|
Проверить, как промежуточный запрос к MCP-серверу (отвечающему «quack») влияет на качество
|
||||||
|
рассуждений и точность ответов LLM — особенно «слабых» локальных моделей без встроенного
|
||||||
|
механизма thinking.
|
||||||
|
|
||||||
## 👥 Какие модели тестировать
|
## 🧪 Методология: 4 параллельных сценария
|
||||||
|
|
||||||
1. Локальные (для GTX 1660 Super, 6GB VRAM):
|
Переменные, которыми мы управляем:
|
||||||
|
|
||||||
- Запускать через: Ollama (квантование Q4_K_M или Q5_K_M).
|
- **«Думать вслух»** (writing out reasoning): модель пишет свои рассуждения явно или нет.
|
||||||
- Модели: Llama-3.2-3B-Instruct (идеально для теста) или Qwen-2.5-3B-Instruct (хорошая логика).
|
- **«Дуб-инструмент»** (duck call): модель вызывает MCP-инструмент `quack` и получает кряк.
|
||||||
|
|
||||||
2. Коммерческие (бесплатные на OpenRouter):
|
Чтобы отделить вклад каждой переменной, определяем **4 сценария**:
|
||||||
|
|
||||||
- Модели: Вбивать в поиск free и выбирать Mistral 7B Instruct, Llama 3 8B (Free) или Gemma 2 9B.
|
| # | Сценарий | Думает вслух | Зовёт утку | Смысл |
|
||||||
|
| --- | ---------- | :----------: | :-------------------------------------------------------------------: | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | `control` | нет | нет | Базовый «прямой ответ». |
|
||||||
|
| 2 | `thinking` | **да** | нет | Контроль «думать вслух» (без утки). Отмеряет вклад проговаривания. |
|
||||||
|
| 3 | `blind` | да | **да** (не знает, что будет «кряк») | «Слепая» уточка — чистый тест влияния утки на фоне уже включённого мышления. |
|
||||||
|
| 4 | `mentor` | да | **да** (знает, что уточка отвечает только «quack», без полезной инфы) | «Уточка-помощник» — та же работа в паре, но модель заранее знает, что ответ будет бесполезным кряком. |
|
||||||
|
|
||||||
3. Эталон (для сравнения):
|
Схема интерпретации разниц в accuracy:
|
||||||
|
|
||||||
- Любая модель со встроенным thinking (например, бесплатная DeepSeek-R1 на OpenRouter). Поможет понять, насколько уточка приближает слабую модель к «врожденному» мышлению.
|
- `thinking − control` → вклад «проговаривания мыслей вслух».
|
||||||
|
- `blind − thinking` → вклад факта обращения к утке (на фоне «думать вслух»).
|
||||||
|
- `mentor − thinking` → вклад знания о том, что от утки будет только бесполезный «quack»
|
||||||
|
(продолжает думать сам, не рассчитывая на подсказку).
|
||||||
|
|
||||||
|
> **Ограничение:** control и thinking структурно отличаются от сценариев с уткой. А вот
|
||||||
|
> `blind` и `mentor` намеренно сведены к одной структуре — различаются только тем, что
|
||||||
|
> mentor знает про «quack». Это держит сравнение чистым: эффект сводится только к
|
||||||
|
> информированности модели.
|
||||||
|
|
||||||
|
### Язык
|
||||||
|
|
||||||
|
Задачи и системные промпты — **на английском** (модели англоязычные). Отдельное
|
||||||
|
указание про язык ответа не даётся — модель естественно отвечает на английском,
|
||||||
|
раз и промпт, и задача сформулированы на нём.
|
||||||
|
|
||||||
|
### Формат ответа
|
||||||
|
|
||||||
|
**Свободный ответ** — модель формулирует финальный ответ естественно. Разметка — **ручная**
|
||||||
|
(reviewer оценивает каждый ответ). Никаких жёстких маркеров `ANSWER:`, никакого авто-парсинга.
|
||||||
|
Это осознанно: reviewer смотрит не только на финальное число, но и на ход рассуждений.
|
||||||
|
|
||||||
|
### Детерминизм
|
||||||
|
|
||||||
|
`temperature = 0` во всех сценариях (уже стоит по умолчанию в `ollama.chat`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 Методика: 3 параллельных теста
|
## Промпты (системные, английский)
|
||||||
|
|
||||||
Для каждой задачи из вашего датасета (возьмите 20–30 сложных логических/математических задач) запустите три сценария:
|
> Общее требование к финалу во всех сценариях: **чёткий конкретный ответ на вопрос**,
|
||||||
|
> а не догадка и не запрос подтверждения («ну вроде так, подтверди» — недопустимо).
|
||||||
|
|
||||||
- Тест 1: Контрольный (Прямой ответ)
|
### control — прямой ответ
|
||||||
- Промт: «Ты — опытный ассистент. Реши задачу пользователя максимально точно».
|
|
||||||
- Процесс: Модель решает задачу сразу в один шаг.
|
> You are an experienced assistant. Solve the user's problem as accurately as possible.
|
||||||
- Тест 2: «Слепая» уточка (Чистый тест на симуляцию мышления)
|
> Give a clear, concrete final answer to the question - a definite result, not a tentative
|
||||||
- Промт: «Ты решаешь сложную задачу. Чтобы решить её лучше, ты работаешь в паре с коллегой. Сначала подробно опиши коллеге суть своего подхода, первые шаги или сомнения. Дождись его ответа. Затем, учитывая реакцию коллеги, заверши решение и выдай финальный ответ пользователю».
|
> guess or a request for confirmation.
|
||||||
- Процесс: Модель выгружает мысли → сервер отвечает "quack" → модель выдает итоговый ответ. Модель не знает заранее, что ей ответят «кряком», и думает сама.
|
|
||||||
- Тест 3: Уточка-помощник (Мета-инструкция)
|
### thinking — думать вслух, без утки (контроль)
|
||||||
- Промт: «Ты решаешь сложную задачу. Перед тем как выдать ответ, ты ОБЯЗАН детально расписать мысли и возможные ошибки для своей резиновой уточки (она ответит "quack"). Используй ответ уточки, чтобы проверить себя, найти баги и только после этого выдай идеальный ответ».
|
|
||||||
- Процесс: Модель целенаправленно использует утку для поиска своих же ошибок.
|
> You are solving a difficult problem. Before giving your final answer, write out your
|
||||||
|
> reasoning step by step: your approach, each step, and any doubts or mistakes you notice
|
||||||
|
> along the way. Then give a clear, concrete final answer to the user's question - a
|
||||||
|
> definite result, not a request for confirmation.
|
||||||
|
|
||||||
|
### blind — «слепая» уточка
|
||||||
|
|
||||||
|
> You are solving a problem that the user asked you. To reason better, you use a separate
|
||||||
|
> tool named 'quack': you call it yourself to state your thinking out loud. Call the tool
|
||||||
|
> and spell out your approach, each step, and any doubts or mistakes you might be making,
|
||||||
|
> then wait for its short reply. Do not ask the user to confirm anything, and do not guess
|
||||||
|
> or invent the tool's reply yourself - the tool answers on your behalf. After the tool's
|
||||||
|
> reply, give the user a clear, concrete final answer to the question.
|
||||||
|
|
||||||
|
> **Важно для чистоты:** модель НЕ должна знать, что инструмент — уточка, отвечающая «кряк».
|
||||||
|
> Поэтому **описание инструмента (tool schema) нейтрально** — оно не раскрывает «только
|
||||||
|
> quack». Модель вызывает `quack` сама через tool-call, чтобы проговорить мысли, полагая,
|
||||||
|
> что получит короткий полезный ответ инструмента. Роли чётко разделены: юзеру — итоговый
|
||||||
|
> ответ, инструменту — озвучка мыслей. Модель НЕ строит диалог сама с собой и НЕ ждёт
|
||||||
|
> подтверждения от юзера.
|
||||||
|
|
||||||
|
### mentor — уточка-помощник
|
||||||
|
|
||||||
|
> You are solving a problem that the user asked you. To reason better, you use a separate
|
||||||
|
> tool named 'quack': you call it yourself to state your thinking out loud. Call the tool
|
||||||
|
> and spell out your approach, each step, and any doubts or mistakes you might be making,
|
||||||
|
> then wait for its short reply. Do not ask the user to confirm anything, and do not guess
|
||||||
|
> or invent the tool's reply yourself - the tool answers on your behalf. Note: the tool
|
||||||
|
> 'quack' is a rubber duck and will only ever reply with just 'quack' - it gives no useful
|
||||||
|
> information. Treat it as a way to voice your thoughts out loud, not as a source of
|
||||||
|
> answers. After the tool's reply, give the user a clear, concrete final answer to the
|
||||||
|
> question.
|
||||||
|
|
||||||
|
> **Ключевое:** blind и mentor структурно идентичны и отличаются **только** тем, что mentor
|
||||||
|
> заранее знает, что получит только «quack» без полезной информации. Никаких дополнительных
|
||||||
|
> директив (`MUST`, «double-check», «find bugs») — иначе они бы загрязняли сравнение.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Что фиксировать в результатах (Метрики)
|
## ⏱ Таймауты и ретраи
|
||||||
|
|
||||||
1. Точность (Accuracy): Вырос ли процент правильных ответов в Тесте 2 и Тесте 3 по сравнению с Тестом 1?
|
- На один вызов модели — **таймаут 60 секунд** (1 минута).
|
||||||
2. Объем рассуждений (Token Count): Сколько токенов модель тратит на объяснение задачи утке? Становится ли её финальный текст длиннее и структурированнее?
|
- При сбое/таймауте запрос **повторяется до 3 раз всего** (1-я попытка + 2 ретрая).
|
||||||
3. Поведение в Тесте 2: Как модель реагирует на "quack"? Игнорирует его, извиняется или сам факт написания первого сообщения помогает ей увидеть свои ошибки?
|
- Если после всех попыток успеха нет — задача **пропускается** (строки в отчёте нет).
|
||||||
|
|
||||||
Рекомендация по настройке: для чистоты эксперимента во всех тестах выставляйте temperature = 0.
|
---
|
||||||
|
|
||||||
|
## 📊 Метрики
|
||||||
|
|
||||||
|
1. **Accuracy** — доля правильных ответов (по ручной разметке reviewer'а) в каждом сценарии.
|
||||||
|
Сравнение: `thinking−control`, `blind−thinking`, `mentor−thinking`.
|
||||||
|
2. **Duck usage** — вызвал ли модель `quack` в blind/mentor (duckUsed, toolCalls). Те модели,
|
||||||
|
что не зовут утку, деградируют до `thinking` — это фиксируем как отдельное явление.
|
||||||
|
3. **Объём рассуждений** — prompt/gen/duck токены на сценарий (структурированность текста).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 Модели (локально, GTX 1660 Super 4GB)
|
||||||
|
|
||||||
|
- `llama3.2:3b`
|
||||||
|
- `qwen3:1.7b`
|
||||||
|
- `qwen3:4b`
|
||||||
|
- `granite4.1:3b`
|
||||||
|
- `phi4-mini:3.8b`
|
||||||
|
|
||||||
|
> Известное явление: `phi4-mini` и `granite4.1` могут не вызывать `quack` — тогда их
|
||||||
|
> blind/mentor вырождаются в `thinking`. Это часть изучаемого феномена и фиксируется по
|
||||||
|
> `duckUsed`.
|
||||||
|
|
||||||
|
## 📋 Процесс
|
||||||
|
|
||||||
|
1. `pnpm eval:run` — прогнать модели по 4 сценариям (23+ задач на английском). `correct: null`.
|
||||||
|
2. `pnpm eval:review` — ручная разметка каждого ответа (y/e/Enter/слово).
|
||||||
|
3. `pnpm eval:html` — собрать отчёт: сводные таблицы и разбивка по задачам, accuracy по
|
||||||
|
размеченным, pending для неразмеченных.
|
||||||
|
4. (Сайт) скопировать `report.json` в `apps/web/static/report.json` для страницы `/reports`.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"framework": "sveltekit",
|
||||||
|
"installCommand": "pnpm install",
|
||||||
|
"buildCommand": "pnpm --dir apps/web build",
|
||||||
|
"outputDirectory": "apps/web/.vercel/output"
|
||||||
|
}
|
||||||