From 569174af98df50fdd7b59b2ce871d1ff352683f8 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 17:39:17 +0000 Subject: [PATCH 1/3] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/Payel-git-ol/Octra/issues/70 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..febbd9d --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-10T17:39:17.202Z for PR creation at branch issue-70-913795612a15 for issue https://github.com/Payel-git-ol/Octra/issues/70 \ No newline at end of file From 921aa1cbdd674b083955c7eb81e2df8bcb52e503 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 17:53:25 +0000 Subject: [PATCH 2/3] Chat redesign: conversational replies, streaming, completion report (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the chat behave like a normal assistant while still launching workflows, and never leave the user in silence (issue #70): - Backend (apigateway): rewrite buildBossChatReply to answer casual messages (greetings, thanks, how-are-you, identity/help, farewell, fallbacks) in the user's language. A plain «привет» / "hello" now gets a real reply instead of an English-only canned line. Language is detected via a new isRussian() Cyrillic check. - Backend: when a workflow finishes successfully the boss reports back in the chat via buildCompletionReport/sendCompletionReport — headline in the request's language, task title, the boss's own answer (chatSummary), and a result link (PR / repo / zip) when available. - Frontend: new TypewriterText component reveals boss replies character-by-character so answers appear to be typed out in real time. Chat renders boss messages through it; live replies animate, while restored history, the user's own messages, and the frequently-updating progress message are shown instantly (animate flag on ChatMessage). - Frontend: stop synthesising a completion message from chatSummary in useWebSocket — the backend now sends the completion report, avoiding a double post. - Tests: Go table tests for conversational replies and completion-report content; frontend check-chat-typewriter.mjs wired into npm test. --- apigateway/internal/fetcher/http.go | 128 +++++++++++++++++- apigateway/internal/fetcher/http_test.go | 128 ++++++++++++++++++ frontend/web/package.json | 3 +- .../web/scripts/check-chat-typewriter.mjs | 60 ++++++++ frontend/web/src/app/App.tsx | 12 +- frontend/web/src/app/components/Chat.tsx | 17 ++- .../web/src/app/components/TypewriterText.tsx | 60 ++++++++ frontend/web/src/hooks/useWebSocket.ts | 9 +- 8 files changed, 402 insertions(+), 15 deletions(-) create mode 100644 frontend/web/scripts/check-chat-typewriter.mjs create mode 100644 frontend/web/src/app/components/TypewriterText.tsx diff --git a/apigateway/internal/fetcher/http.go b/apigateway/internal/fetcher/http.go index 3a0ff4d..fb78529 100644 --- a/apigateway/internal/fetcher/http.go +++ b/apigateway/internal/fetcher/http.go @@ -437,18 +437,79 @@ func shouldLaunchSearchWorkflowFromChat(words map[string]bool) bool { return false } +// isRussian reports whether the message contains Cyrillic letters. The chat +// answers in the same language the user wrote in, so a greeting like «привет» +// is met with a Russian reply instead of the previous English-only canned text +// (issue #70: the chat ignored casual/Russian messages and felt dead). +func isRussian(message string) bool { + for _, r := range message { + if unicode.Is(unicode.Cyrillic, r) { + return true + } + } + return false +} + +// buildBossChatReply produces a conversational reply for messages that are not +// workflow/search requests. The chat must behave like a normal assistant — a +// plain «привет» or "hello" should get a friendly answer rather than silence or +// a generic English line (issue #70). Replies are returned in the user's +// language (Russian when the message contains Cyrillic, English otherwise). func buildBossChatReply(message string) string { + ru := isRussian(message) words := normalizedWords(message) - if hasAnyWord(words, []string{"hello", "hi", "hey"}) { - return "Hi, I'm here." + + // Greetings — hello / hi / привет / здравствуйте / добрый день … + if hasAnyWord(words, []string{ + "hello", "hi", "hey", "yo", "hiya", "howdy", + "привет", "приветик", "прив", "здравствуй", "здравствуйте", "здарова", + "хай", "ку", "салют", "добрый", "доброе", + }) { + if ru { + return "Привет! Я Octra. Можем просто пообщаться, а можно описать, что нужно собрать или найти, — и я возьмусь за задачу." + } + return "Hi! I'm Octra. We can just chat, or you can describe what to build or look up and I'll get to work." } - if hasAnyWord(words, []string{"thanks", "thank"}) { - return "You're welcome." + + // How are you — как дела / как ты / how are you … + if hasAnyWord(words, []string{"дела", "поживаешь"}) || + (words["how"] && words["you"] && (words["are"] || words["doing"])) { + if ru { + return "У меня всё отлично, спасибо! Чем помочь — пообщаться, поискать что-то в интернете или собрать проект?" + } + return "I'm doing great, thanks! Want to chat, research something, or have me build a project?" + } + + // Thanks — thanks / thank you / спасибо / благодарю … + if hasAnyWord(words, []string{"thanks", "thank", "thx", "спасибо", "благодарю", "спс", "пасиб"}) { + if ru { + return "Всегда пожалуйста! Если понадобится что-то ещё — просто напишите." + } + return "You're welcome! Let me know if there's anything else." + } + + // Farewell — bye / goodbye / пока / до свидания … + if hasAnyWord(words, []string{"bye", "goodbye", "cya", "пока", "свидания", "увидимся", "прощай"}) { + if ru { + return "Пока! Возвращайтесь, когда понадобится помощь." + } + return "Bye! Come back whenever you need a hand." + } + + // Identity / capabilities / help — who are you / что ты умеешь / помоги … + if hasAnyWord(words, []string{"help", "помощь", "помоги", "умеешь", "можешь"}) || + (words["who"] && words["you"]) || (words["what"] && (words["can"] || words["do"]) && words["you"]) || + (words["кто"] && words["ты"]) || (words["что"] && words["ты"]) { + if ru { + return "Я Octra — фабрика ИИ-агентов. Могу просто общаться, искать информацию в интернете или собрать проект целиком. Например: «создай php сервер» или «найди документацию по httpx»." + } + return "I'm Octra, an AI agent factory. I can chat, research the web, or build a whole project for you. Try: \"create a php server\" or \"find the httpx docs\"." } - if hasAnyWord(words, []string{"help", "workflow", "build", "create"}) { - return "I can answer here, or start the workflow when you describe code you want built or changed." + + if ru { + return "Понял вас. Опишите задачу — и я возьмусь за работу, либо продолжим общение." } - return "I understand. Send the next detail when you are ready." + return "Got it. Describe a task and I'll get to work, or we can keep chatting." } func normalizedWords(message string) map[string]bool { @@ -589,11 +650,64 @@ func processTaskStreamWS(conn *websocket.Conn, taskReq requests.CreateTaskReques if update.Status == "success" || update.Status == "error" { wsHub.Broadcast(streamID, wsUpdate) + // When the workflow finishes successfully the boss reports back to the + // user in the chat ("отчитаться") so a completed task never ends in + // silence (issue #70). Errors keep their existing red status banner. + if update.Status == "success" { + sendCompletionReport(conn, streamID, taskReq, update.Data) + } return } } } +// sendCompletionReport posts a short boss chat message summarizing a finished +// task. It folds in the boss's own answer (chatSummary, used by +// research/document tasks) and a link to the result when one is available, and +// is written in the language of the original request (issue #70). +func sendCompletionReport(conn *websocket.Conn, taskID string, taskReq requests.CreateTaskRequest, data map[string]string) { + writeBossChatMessage(conn, taskID, buildCompletionReport(taskReq, data), false) +} + +// buildCompletionReport composes the boss's completion message in the language +// of the original request, folding in the boss's own answer (chatSummary) and a +// result link when available. +func buildCompletionReport(taskReq requests.CreateTaskRequest, data map[string]string) string { + ru := isRussian(taskReq.Title + " " + taskReq.Description) + + link := data["pullRequestUrl"] + if link == "" { + link = data["repoUrl"] + } + if link == "" { + link = data["zipUrl"] + } + + var b strings.Builder + if ru { + b.WriteString("✅ Готово! Я завершил задачу") + } else { + b.WriteString("✅ Done! I've finished the task") + } + if title := strings.TrimSpace(taskReq.Title); title != "" { + b.WriteString(" «" + title + "»") + } + b.WriteString(".") + + if summary := strings.TrimSpace(data["chatSummary"]); summary != "" { + b.WriteString("\n\n" + summary) + } + if link != "" { + if ru { + b.WriteString("\n\nРезультат: " + link) + } else { + b.WriteString("\n\nResult: " + link) + } + } + + return b.String() +} + // handleTaskReconnectWS handles WebSocket reconnection to an existing task func handleTaskReconnectWS(c *gin.Context) { conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) diff --git a/apigateway/internal/fetcher/http_test.go b/apigateway/internal/fetcher/http_test.go index ef41245..d460422 100644 --- a/apigateway/internal/fetcher/http_test.go +++ b/apigateway/internal/fetcher/http_test.go @@ -3,6 +3,8 @@ package fetcher import ( "strings" "testing" + + "apigateway/pkg/requests" ) // TestNewStreamIDIsolatesConcurrentStreams guards the fix for cross-tab history @@ -111,3 +113,129 @@ func TestShouldLaunchWorkflowFromChatSearchRequests(t *testing.T) { }) } } + +// TestBuildBossChatReplyConversational guards issue #70: casual messages that +// are not workflow/search requests must get a real conversational answer, in +// the user's language, instead of silence or a generic English line. The key +// regression is «привет» — it used to fall through to an English fallback, so +// the chat felt dead. +func TestBuildBossChatReplyConversational(t *testing.T) { + tests := []struct { + name string + message string + wantSubstrings []string // reply must contain at least one of these + wantRussian bool // reply must be in Russian (Cyrillic) ... + wantNotRussian bool // ... or must NOT contain Cyrillic + }{ + { + name: "Russian greeting replies in Russian", + message: "привет", + wantSubstrings: []string{"Привет"}, + wantRussian: true, + }, + { + name: "English greeting replies in English", + message: "hello", + wantSubstrings: []string{"Hi"}, + wantNotRussian: true, + }, + { + name: "Russian thanks", + message: "спасибо", + wantSubstrings: []string{"пожалуйста"}, + wantRussian: true, + }, + { + name: "English thanks", + message: "thanks!", + wantSubstrings: []string{"welcome"}, + wantNotRussian: true, + }, + { + name: "Russian capability question", + message: "что ты умеешь?", + wantSubstrings: []string{"Octra"}, + wantRussian: true, + }, + { + name: "English who are you", + message: "who are you?", + wantSubstrings: []string{"Octra"}, + wantNotRussian: true, + }, + { + name: "Russian fallback stays Russian", + message: "ну ладно", + wantSubstrings: []string{"задач"}, + wantRussian: true, + }, + { + name: "English fallback stays English", + message: "ok then", + wantSubstrings: []string{"task"}, + wantNotRussian: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reply := buildBossChatReply(tt.message) + if reply == "" { + t.Fatalf("buildBossChatReply(%q) returned empty reply", tt.message) + } + + matched := false + for _, sub := range tt.wantSubstrings { + if strings.Contains(reply, sub) { + matched = true + break + } + } + if !matched { + t.Fatalf("buildBossChatReply(%q) = %q, want it to contain one of %v", tt.message, reply, tt.wantSubstrings) + } + + if tt.wantRussian && !isRussian(reply) { + t.Fatalf("buildBossChatReply(%q) = %q, want a Russian reply", tt.message, reply) + } + if tt.wantNotRussian && isRussian(reply) { + t.Fatalf("buildBossChatReply(%q) = %q, want an English reply", tt.message, reply) + } + }) + } +} + +// TestSendCompletionReportContent guards the issue #70 requirement that the boss +// reports back in chat when a task finishes — including the boss's own answer +// (chatSummary), a result link, and the request's language. +func TestSendCompletionReportContent(t *testing.T) { + t.Run("Russian task with PR link and summary", func(t *testing.T) { + report := buildCompletionReport( + requests.CreateTaskRequest{Title: "Мини прокси на Go"}, + map[string]string{ + "chatSummary": "Сделал прокси с маршрутизацией.", + "pullRequestUrl": "https://github.com/o/r/pull/7", + }, + ) + for _, want := range []string{"Готово", "Мини прокси на Go", "Сделал прокси", "https://github.com/o/r/pull/7", "Результат"} { + if !strings.Contains(report, want) { + t.Fatalf("report = %q, want it to contain %q", report, want) + } + } + }) + + t.Run("English task falls back to repo link", func(t *testing.T) { + report := buildCompletionReport( + requests.CreateTaskRequest{Title: "PHP server"}, + map[string]string{"repoUrl": "https://github.com/o/r"}, + ) + for _, want := range []string{"Done", "PHP server", "https://github.com/o/r", "Result"} { + if !strings.Contains(report, want) { + t.Fatalf("report = %q, want it to contain %q", report, want) + } + } + if isRussian(report) { + t.Fatalf("report = %q, want an English report", report) + } + }) +} diff --git a/frontend/web/package.json b/frontend/web/package.json index a583127..75cbb4b 100644 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -30,7 +30,8 @@ "test:app-render-guards": "node scripts/check-app-render-guards.mjs", "test:onboarding-tour": "node scripts/check-onboarding-tour.mjs", "test:token-statistics": "node scripts/check-token-statistics.mjs", - "test": "npm run test:settings && npm run test:task-store && npm run test:task-payload && npm run test:chat-history && npm run test:solution-viewer && npm run test:search-steps && npm run test:search-button && npm run test:sidebar-auth && npm run test:sidebar-dock && npm run test:landing && npm run test:custom-providers && npm run test:chat-input && npm run test:bottom-input-attachments && npm run test:file-tree && npm run test:workspace && npm run test:mobile-header && npm run test:profile-avatar && npm run test:pull-request-summary && npm run test:model-selector-toggle && npm run test:desktop-integration && npm run test:app-render-guards && npm run test:onboarding-tour && npm run test:token-statistics" + "test:chat-typewriter": "node scripts/check-chat-typewriter.mjs", + "test": "npm run test:settings && npm run test:task-store && npm run test:task-payload && npm run test:chat-history && npm run test:solution-viewer && npm run test:search-steps && npm run test:search-button && npm run test:sidebar-auth && npm run test:sidebar-dock && npm run test:landing && npm run test:custom-providers && npm run test:chat-input && npm run test:bottom-input-attachments && npm run test:file-tree && npm run test:workspace && npm run test:mobile-header && npm run test:profile-avatar && npm run test:pull-request-summary && npm run test:model-selector-toggle && npm run test:desktop-integration && npm run test:app-render-guards && npm run test:onboarding-tour && npm run test:token-statistics && npm run test:chat-typewriter" }, "dependencies": { "@emotion/react": "11.14.0", diff --git a/frontend/web/scripts/check-chat-typewriter.mjs b/frontend/web/scripts/check-chat-typewriter.mjs new file mode 100644 index 0000000..fb3acee --- /dev/null +++ b/frontend/web/scripts/check-chat-typewriter.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +// Regression test for issue #70 ("Chat redesign"): the assistant's answers must +// stream in with a typewriter effect (text "being typed") instead of popping in +// all at once, and task-completion reports come from the backend over a `chat` +// message — so the frontend must no longer synthesise its own completion text +// from chatSummary (which would double-post). + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..'); +const read = (rel) => readFileSync(resolve(root, rel), 'utf8'); + +// --- TypewriterText reveals text incrementally. --- +const typewriter = read('src/app/components/TypewriterText.tsx'); +assert.match(typewriter, /export function TypewriterText/, 'TypewriterText component must be exported'); +assert.match(typewriter, /animate\s*=\s*true/, 'TypewriterText must animate by default'); +assert.match(typewriter, /setInterval/, 'TypewriterText must reveal characters over time'); +assert.match(typewriter, /text\.slice\(0,\s*count\)/, 'TypewriterText must show a growing slice of the text'); +assert.match(typewriter, /onTick/, 'TypewriterText must expose an onTick callback so the chat can keep scrolling'); +// Non-animated messages (history / user) show the full text immediately. +assert.match(typewriter, /if \(!animate\)/, 'TypewriterText must render full text immediately when animate is false'); + +// --- Chat renders boss messages through TypewriterText. --- +const chat = read('src/app/components/Chat.tsx'); +assert.match(chat, /import \{ TypewriterText \}/, 'Chat must import TypewriterText'); +assert.match(chat, / 0, 'could not locate the success case block'); +assert.ok( + !/onChatMessage\(/.test(successCase), + 'the success handler must not post its own completion chat message (backend reports completion now)', +); + +console.log('check-chat-typewriter: all assertions passed'); diff --git a/frontend/web/src/app/App.tsx b/frontend/web/src/app/App.tsx index fc5f6e5..c6c2296 100644 --- a/frontend/web/src/app/App.tsx +++ b/frontend/web/src/app/App.tsx @@ -194,6 +194,10 @@ export default function App() { isClarification, progress, showProgress, + // Freshly received boss replies type themselves out (issue #70). The + // progress message (showProgress) updates its text frequently, so it is + // shown instantly instead of re-animating on every tick. + animate: sender === 'boss' && !showProgress, }; setChatMessages(prev => [...prev, newMessage]); if (sender === 'boss') { @@ -215,7 +219,8 @@ export default function App() { }; return updatedMessages; } else { - // Create new boss message with progress + // Create new boss message with progress. Progress text updates often, + // so it is shown instantly rather than typed out (issue #70). const newMessage = { id: Date.now().toString(), text: message || 'Processing your request...', @@ -224,6 +229,7 @@ export default function App() { read: false, progress, showProgress: true, + animate: false, }; return [...prev, newMessage]; } @@ -399,6 +405,7 @@ export default function App() { sender: 'user' as const, timestamp: new Date(), read: true, + animate: false, }; setChatMessages(prev => [...prev, newMessage]); setMode('chat'); @@ -499,6 +506,9 @@ export default function App() { sender: message.role, timestamp: new Date(message.created_at || message.timestamp || Date.now()), read: true, + // Restored history is shown instantly — only live replies type out + // (issue #70). + animate: false, }))); // Restore this chat's saved workflow (a full graph swap, empty if none). diff --git a/frontend/web/src/app/components/Chat.tsx b/frontend/web/src/app/components/Chat.tsx index bd4f9aa..d7462ab 100644 --- a/frontend/web/src/app/components/Chat.tsx +++ b/frontend/web/src/app/components/Chat.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { CheckCircle2, ChevronDown, ChevronRight, Circle, CircleDotDashed, Download, GitBranch, Globe, Loader2, UserRound } from 'lucide-react'; import octraMascot from '../../images/octra-mascot.png'; import { useTaskStore } from '../../stores/taskStore'; +import { TypewriterText } from './TypewriterText'; export interface ChatMessage { id: string; @@ -12,6 +13,10 @@ export interface ChatMessage { isClarification?: boolean; progress?: number; showProgress?: boolean; + // When true the boss message is revealed with a typewriter animation. Set for + // freshly received boss replies; left false for the user's own messages and + // for history restored when switching chats (issue #70). + animate?: boolean; } interface ChatProps { @@ -86,7 +91,17 @@ export function Chat({ messages, onMarkAsRead }: ChatProps) { ? 'border-[var(--warning)]/40 bg-[var(--warning)]/10 text-[var(--text)]' : 'border-[var(--border)] bg-[var(--surface)] text-[var(--text)]' }`}> -
{message.text}
+
+ {message.sender === 'boss' ? ( + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })} + /> + ) : ( + message.text + )} +
{message.showProgress && (
diff --git a/frontend/web/src/app/components/TypewriterText.tsx b/frontend/web/src/app/components/TypewriterText.tsx new file mode 100644 index 0000000..0946ea2 --- /dev/null +++ b/frontend/web/src/app/components/TypewriterText.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react'; + +interface TypewriterTextProps { + text: string; + // When false the full text is shown immediately (e.g. messages restored from + // history or the user's own messages). Defaults to true. + animate?: boolean; + // Characters revealed per tick. Higher = faster typing. + speed?: number; + // Called on every reveal step so the chat can keep scrolling to the bottom + // while the answer "types" itself out. + onTick?: () => void; +} + +// TypewriterText reveals its text character-by-character so the assistant's +// answers appear to be typed out in real time, like a normal chat, instead of +// popping in all at once (issue #70). Once a message has finished animating it +// stays fully visible; changing the `text` prop restarts the animation. +export function TypewriterText({ text, animate = true, speed = 2, onTick }: TypewriterTextProps) { + const [count, setCount] = useState(animate ? 0 : text.length); + const onTickRef = useRef(onTick); + onTickRef.current = onTick; + + useEffect(() => { + if (!animate) { + setCount(text.length); + return; + } + + setCount(0); + const step = Math.max(1, speed); + const timer = setInterval(() => { + setCount((prev) => { + const next = Math.min(text.length, prev + step); + onTickRef.current?.(); + if (next >= text.length) { + clearInterval(timer); + } + return next; + }); + }, 18); + + return () => clearInterval(timer); + }, [text, animate, speed]); + + const visible = animate ? text.slice(0, count) : text; + const isTyping = animate && count < text.length; + + return ( + + {visible} + {isTyping && ( + + ); +} diff --git a/frontend/web/src/hooks/useWebSocket.ts b/frontend/web/src/hooks/useWebSocket.ts index ccca7aa..0be671e 100644 --- a/frontend/web/src/hooks/useWebSocket.ts +++ b/frontend/web/src/hooks/useWebSocket.ts @@ -331,11 +331,10 @@ export function useWebSocket(url: string, onChatMessage?: (message: string, send message: msg.message || 'Project ready!', type: 'success', }); - // Non-code tasks (research/document/presentation): Boss posts a short - // text answer in chat; the full result lives in the Solution tab. - if (msg.data?.chatSummary && onChatMessage) { - onChatMessage(msg.data.chatSummary, 'boss', false); - } + // The boss reports task completion back in the chat via a dedicated + // `chat` message (see sendCompletionReport on the gateway), which already + // folds in the short text answer (chatSummary) used by + // research/document tasks — so nothing extra is posted here (issue #70). if (msg.data?.repoUrl) { storeActions.setZipUrl(msg.data.repoUrl); addGitHubNode(msg.data.repoUrl, msg.data?.pullRequestUrl); From 7c17cc2b6aa890c8c3a3e3a4f7dbf7fad4e69822 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 17:58:10 +0000 Subject: [PATCH 3/3] Revert "Initial commit with task details" This reverts commit 569174af98df50fdd7b59b2ce871d1ff352683f8. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index febbd9d..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-06-10T17:39:17.202Z for PR creation at branch issue-70-913795612a15 for issue https://github.com/Payel-git-ol/Octra/issues/70 \ No newline at end of file