- {messages.map((msg) => (
+ {visibleMessages.map((msg) => (
-
{msg.role}
+
{roleLabel(msg.role)}
- {msg.role === "assistant" ? (
+ {msg.role === "assistant" || msg.role === "notice" ? (
{msg.content}
) : (
msg.content
@@ -131,6 +164,16 @@ export default function Chat() {
))}
+
+ {liveNotices.map((notice, idx) => (
+
assistant
diff --git a/frontend/src/utils/characterCard.ts b/frontend/src/utils/characterCard.ts
new file mode 100644
index 0000000..5ca2533
--- /dev/null
+++ b/frontend/src/utils/characterCard.ts
@@ -0,0 +1,124 @@
+export interface CharacterCardData {
+ name: string;
+ description: string;
+ personality: string;
+ scenario: string;
+ first_mes: string;
+ mes_example: string;
+ system_prompt: string;
+ post_history_instructions: string;
+ tags: string[];
+ creator: string;
+ creator_notes: string;
+ alternate_greetings: string[];
+ character_version: string;
+}
+
+export interface CharacterCardV2 {
+ spec: string;
+ spec_version: string;
+ data: CharacterCardData;
+}
+
+export const DEFAULT_CARD: CharacterCardV2 = {
+ spec: "chara_card_v2",
+ spec_version: "2.0",
+ data: {
+ name: "Домашний ассистент",
+ description:
+ "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
+ personality: "Тёплый, остроумный, по делу. Говорит на русском.",
+ scenario: "Пользователь общается с ассистентом дома через веб-интерфейс.",
+ first_mes: "Привет! Чем займёмся — поболтаем или заведём помидоро?",
+ mes_example: "",
+ system_prompt: "",
+ post_history_instructions: "",
+ tags: ["assistant", "home"],
+ creator: "",
+ creator_notes: "",
+ alternate_greetings: [],
+ character_version: "1.0",
+ },
+};
+
+export function normalizeCard(raw: CharacterCardV2 | Record
): CharacterCardV2 {
+ if (raw.data && typeof raw.data === "object") {
+ return {
+ spec: (raw.spec as string) ?? "chara_card_v2",
+ spec_version: (raw.spec_version as string) ?? "2.0",
+ data: { ...DEFAULT_CARD.data, ...(raw.data as Partial) },
+ };
+ }
+ return {
+ spec: "chara_card_v2",
+ spec_version: "2.0",
+ data: { ...DEFAULT_CARD.data, ...(raw as Partial) },
+ };
+}
+
+function readPngTextChunks(buffer: ArrayBuffer): Map {
+ const bytes = new Uint8Array(buffer);
+ const result = new Map();
+
+ let offset = 8;
+ while (offset + 12 <= bytes.length) {
+ const length =
+ (bytes[offset] << 24) |
+ (bytes[offset + 1] << 16) |
+ (bytes[offset + 2] << 8) |
+ bytes[offset + 3];
+ const type = String.fromCharCode(
+ bytes[offset + 4],
+ bytes[offset + 5],
+ bytes[offset + 6],
+ bytes[offset + 7]
+ );
+ const dataStart = offset + 8;
+ const dataEnd = dataStart + length;
+
+ if (type === "tEXt") {
+ const chunk = bytes.slice(dataStart, dataEnd);
+ const zero = chunk.indexOf(0);
+ if (zero > 0) {
+ const keyword = new TextDecoder().decode(chunk.slice(0, zero));
+ const text = new TextDecoder().decode(chunk.slice(zero + 1));
+ result.set(keyword, text);
+ }
+ }
+
+ offset = dataEnd + 4;
+ if (type === "IEND") break;
+ }
+
+ return result;
+}
+
+export async function parseCharacterFile(file: File): Promise {
+ if (file.name.endsWith(".json")) {
+ const text = await file.text();
+ return normalizeCard(JSON.parse(text) as Record);
+ }
+
+ if (file.type === "image/png" || file.name.endsWith(".png")) {
+ const buffer = await file.arrayBuffer();
+ const chunks = readPngTextChunks(buffer);
+ const encoded = chunks.get("chara") ?? chunks.get("ccv3");
+ if (!encoded) {
+ throw new Error("В PNG нет поля chara/ccv3 (не Tavern/Chub карточка)");
+ }
+ const json = atob(encoded);
+ return normalizeCard(JSON.parse(json) as Record);
+ }
+
+ throw new Error("Поддерживаются файлы .json и .png (chara_card_v2)");
+}
+
+export function exportCardJson(card: CharacterCardV2): void {
+ const blob = new Blob([JSON.stringify(card, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${card.data.name || "character"}.json`;
+ link.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts
new file mode 100644
index 0000000..351d9c0
--- /dev/null
+++ b/frontend/src/utils/time.ts
@@ -0,0 +1,5 @@
+export function formatTime(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
+}
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
index b3a6036..5b8bc19 100644
--- a/frontend/tsconfig.tsbuildinfo
+++ b/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/pages/chat.tsx","./src/pages/pomodoro.tsx"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/hooks/usepomodoro.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/pomodoro.tsx","./src/utils/charactercard.ts","./src/utils/time.ts"],"version":"5.9.3"}
\ No newline at end of file