From 8963eedb3f1e8e746e6acb1197ffdea9f16ce967 Mon Sep 17 00:00:00 2001 From: jetezh Date: Sat, 10 Oct 2026 20:59:50 +0700 Subject: [PATCH 01/45] research: native Zotero & Google Docs integration with approval gate and split view --- Makefile | 7 +- dev.sh | 12 +- internal/config/config.go | 22 + internal/config/load.go | 4 + internal/config/schema.go | 14 + internal/server/handlers_research.go | 65 +++ internal/server/routes.go | 4 + internal/tools/gdocs.go | 241 ++++++++++ internal/tools/gdocs_test.go | 65 +++ internal/tools/register.go | 3 + internal/tools/registry.go | 2 + internal/tools/zotero.go | 459 ++++++++++++++++++ internal/tools/zotero_test.go | 58 +++ web/src/components/chat/Markdown.tsx | 70 ++- web/src/components/chat/ResearchPanel.tsx | 540 ++++++++++++++++++++++ web/src/lib/i18n.tsx | 38 ++ web/src/lib/locales/id.ts | 38 ++ web/src/lib/locales/ja.ts | 38 ++ web/src/lib/locales/ru.ts | 38 ++ web/src/lib/locales/zh.ts | 38 ++ web/src/pages/ChatPage.tsx | 26 ++ web/vite.config.ts | 7 +- 22 files changed, 1780 insertions(+), 9 deletions(-) create mode 100644 internal/server/handlers_research.go create mode 100644 internal/tools/gdocs.go create mode 100644 internal/tools/gdocs_test.go create mode 100644 internal/tools/zotero.go create mode 100644 internal/tools/zotero_test.go create mode 100644 web/src/components/chat/ResearchPanel.tsx diff --git a/Makefile b/Makefile index 273bb54c..f66b317f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ SHELL := /bin/bash +-include .env +export + # Resolve each tool from $PATH first, then fall back to its common install # location. Override any of them explicitly, e.g. `make GO=/opt/go/bin/go build`. # `tool ` = the binary on PATH if present, else the fallback. @@ -30,7 +33,7 @@ help: ## Show this help .PHONY: dev dev: ## Run backend (Air hot reload) and frontend (Vite HMR) together - @echo "Antares dev: API :8787 · Web :5173" + @echo "Antares dev: API :8790 · Web :5174" @trap 'kill 0' EXIT INT TERM; \ $(MAKE) dev-api & \ $(MAKE) dev-web & \ @@ -42,7 +45,7 @@ dev-api: ## Backend with hot reload .PHONY: dev-web dev-web: ## Frontend dev server with HMR - @cd web && $(BUN) x vite + @cd web && $(BUN) x vite --port 5174 .PHONY: tui tui: ## Hot-reload the TUI preview in the foreground (real terminal) diff --git a/dev.sh b/dev.sh index cab34726..23be6d7a 100755 --- a/dev.sh +++ b/dev.sh @@ -4,6 +4,16 @@ set -e ROOT="$(cd "$(dirname "$0")" && pwd)" cd "$ROOT" +if [ -f "$ROOT/.env" ]; then + set -a + source "$ROOT/.env" + set +a +fi + +if [ -n "$ANTARES_HOME" ]; then + mkdir -p "$ANTARES_HOME" +fi + # Colors GREEN='\033[0;32m' YELLOW='\033[0;33m' @@ -33,7 +43,7 @@ if [ ! -x "$BUN" ]; then fi echo -e "${CYAN}Antares Dev${NC}" -echo -e " Backend: ${GREEN}http://localhost:8787${NC}" +echo -e " Backend: ${GREEN}http://localhost:${ANTARES_PORT:-8790}${NC}" echo -e " Frontend: ${GREEN}http://localhost:${VITE_PORT:-5174}${NC}" echo -e " ${YELLOW}Open http://localhost:${VITE_PORT:-5174} in your browser${NC}" echo "" diff --git a/internal/config/config.go b/internal/config/config.go index a07779ad..bf8cd80c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,8 @@ type Config struct { Social Social `yaml:"social" json:"social"` + Research Research `yaml:"research" json:"research"` + // inline*FromEnv record that model.base_url / model.api_key were supplied by // ANTARES_BASE_URL / ANTARES_API_KEY rather than read from config.yaml. // ResolveProvider honours an env-supplied credential over a named provider's @@ -74,6 +76,26 @@ type Social struct { AutopilotEnabled bool `yaml:"autopilot_enabled" json:"autopilot_enabled"` } +// Research configures academic & research integrations: Zotero and Google Docs. +type Research struct { + Zotero ZoteroConfig `yaml:"zotero" json:"zotero"` + GDocs GDocsConfig `yaml:"gdocs" json:"gdocs"` +} + +type ZoteroConfig struct { + UserID string `yaml:"user_id" json:"user_id"` + APIKey string `yaml:"api_key" json:"api_key"` +} + +type GDocsConfig struct { + Method string `yaml:"method" json:"method"` // "webhook" or "oauth" + WebhookURL string `yaml:"webhook_url" json:"webhook_url"` + OAuthClientID string `yaml:"oauth_client_id" json:"oauth_client_id"` + OAuthClientSecret string `yaml:"oauth_client_secret" json:"oauth_client_secret"` + OAuthAccessToken string `yaml:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `yaml:"oauth_refresh_token" json:"oauth_refresh_token"` +} + // Model selects which LLM answers by default. type Model struct { Default string `yaml:"default" json:"default"` diff --git a/internal/config/load.go b/internal/config/load.go index 2084c170..99e382a5 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -259,6 +259,10 @@ func applyEnv(c *Config) { str("TELEGRAM_BOT_TOKEN", &c.Gateway.Telegram.BotToken) str("DISCORD_BOT_TOKEN", &c.Gateway.Discord.BotToken) + str("ZOTERO_USER_ID", &c.Research.Zotero.UserID) + str("ZOTERO_API_KEY", &c.Research.Zotero.APIKey) + str("GDOCS_WEBHOOK_URL", &c.Research.GDocs.WebhookURL) + // Provider API keys: explicit ANTARES_PROVIDER__API_KEY wins, then the // provider's declared api_key_env, then conventional vendor variables. for name, p := range c.Providers { diff --git a/internal/config/schema.go b/internal/config/schema.go index 232799ef..2bbf8c76 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -110,6 +110,13 @@ var hidden = map[string]bool{ // express "absent" versus "empty" for display.modules. "display.modules": true, "display.preset": true, + + "research.zotero.user_id": true, + "research.zotero.api_key": true, + "research.gdocs.method": true, + "research.gdocs.webhook_url": true, + "research.gdocs.oauth_client_id": true, + "research.gdocs.oauth_client_secret": true, } var enums = map[string][]string{ @@ -122,9 +129,16 @@ var enums = map[string][]string{ "logging.level": {"debug", "info", "warn", "error"}, "tools.web_search.provider": {"browser", "brave", "tavily", "searxng", "none"}, "image_gen.reference_mode": {"edits", "generations"}, + "research.gdocs.method": {"webhook", "oauth"}, } var help = map[string]string{ + "research.zotero.user_id": "Your personal Zotero User ID (from zotero.org/settings/keys).", + "research.zotero.api_key": "Your Zotero API Key with library and file permissions.", + "research.gdocs.method": "Google Docs connection method: webhook (Google Apps Script) or oauth (Google Cloud).", + "research.gdocs.webhook_url": "Apps Script Webhook URL for Google Docs updates.", + "research.gdocs.oauth_client_id": "Google Cloud OAuth Client ID for Google Docs API.", + "research.gdocs.oauth_client_secret": "Google Cloud OAuth Client Secret for Google Docs API.", "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.", "model.provider": "Which entry under providers to call.", "model.auxiliary": "Cheaper model used for summarising and other background work.", diff --git a/internal/server/handlers_research.go b/internal/server/handlers_research.go new file mode 100644 index 00000000..fd773490 --- /dev/null +++ b/internal/server/handlers_research.go @@ -0,0 +1,65 @@ +package server + +import ( + "net/http" + + "github.com/enowdev/antares/internal/config" +) + +func (s *Server) handleGetResearchSettings(w http.ResponseWriter, r *http.Request) { + cfg := s.config() + writeJSON(w, http.StatusOK, map[string]any{ + "zotero": map[string]any{ + "user_id": cfg.Research.Zotero.UserID, + "api_key": cfg.Research.Zotero.APIKey, + }, + "gdocs": map[string]any{ + "method": cfg.Research.GDocs.Method, + "webhook_url": cfg.Research.GDocs.WebhookURL, + "oauth_client_id": cfg.Research.GDocs.OAuthClientID, + "oauth_client_secret": cfg.Research.GDocs.OAuthClientSecret, + }, + }) +} + +func (s *Server) handleSaveResearchSettings(w http.ResponseWriter, r *http.Request) { + var body struct { + Zotero struct { + UserID string `json:"user_id"` + APIKey string `json:"api_key"` + } `json:"zotero"` + GDocs struct { + Method string `json:"method"` + WebhookURL string `json:"webhook_url"` + OAuthClientID string `json:"oauth_client_id"` + OAuthClientSecret string `json:"oauth_client_secret"` + } `json:"gdocs"` + } + + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + cfg, err := config.Reload() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + cfg.Research.Zotero.UserID = body.Zotero.UserID + cfg.Research.Zotero.APIKey = body.Zotero.APIKey + if body.GDocs.Method != "" { + cfg.Research.GDocs.Method = body.GDocs.Method + } + cfg.Research.GDocs.WebhookURL = body.GDocs.WebhookURL + cfg.Research.GDocs.OAuthClientID = body.GDocs.OAuthClientID + cfg.Research.GDocs.OAuthClientSecret = body.GDocs.OAuthClientSecret + + if err := config.Save(cfg); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"status": "saved"}) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 8e4daebc..0f82fb7d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -120,6 +120,10 @@ func (s *Server) routes() { m.HandleFunc("GET /api/ui/modules", s.handleGetModules) m.HandleFunc("POST /api/ui/modules", s.handleSetModules) + // Academic & Research settings (Zotero & Google Docs) + m.HandleFunc("GET /api/research/settings", s.handleGetResearchSettings) + m.HandleFunc("POST /api/research/settings", s.handleSaveResearchSettings) + // Models & providers m.HandleFunc("GET /api/model/options", s.handleModelOptions) m.HandleFunc("GET /api/context-window", s.handleContextWindow) diff --git a/internal/tools/gdocs.go b/internal/tools/gdocs.go new file mode 100644 index 00000000..572889d4 --- /dev/null +++ b/internal/tools/gdocs.go @@ -0,0 +1,241 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// resolveGDocsEndpoint determines whether to use webhook or oauth/rest +func resolveGDocsEndpoint(in Input, argWebhookURL, argDocumentID string) (method, target string, err error) { + webhookURL := strings.TrimSpace(argWebhookURL) + docID := strings.TrimSpace(argDocumentID) + + if in.Deps != nil && in.Deps.Config != nil { + cfg := in.Deps.Config + if webhookURL == "" { + webhookURL = strings.TrimSpace(cfg.Research.GDocs.WebhookURL) + } + } + + if webhookURL != "" { + return "webhook", webhookURL, nil + } + + if docID != "" { + return "oauth", docID, nil + } + + return "", "", fmt.Errorf("no Google Docs destination specified. Pass webhook_url or document_id (or configure research.gdocs.webhook_url in settings)") +} + +// ----------------------------------------------------------------------------- +// 1. gdocs_read +// ----------------------------------------------------------------------------- + +type gdocsReadTool struct{} + +func (gdocsReadTool) Name() string { return "gdocs_read" } +func (gdocsReadTool) Description() string { + return "Read the current text content of a Google Doc. Works via an Apps Script Webhook URL or via document_id. Returns the document title and plain text body." +} +func (gdocsReadTool) RequiresApproval() bool { return false } + +func (gdocsReadTool) Schema() map[string]any { + return schema(map[string]any{ + "webhook_url": prop("string", "Google Apps Script Webhook URL for this document (optional if configured)."), + "document_id": prop("string", "Google Docs Document ID (e.g. from the URL https://docs.google.com/document/d//edit)."), + }) +} + +func (gdocsReadTool) Execute(ctx context.Context, in Input) Result { + var args struct { + WebhookURL string `json:"webhook_url"` + DocumentID string `json:"document_id"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + method, target, err := resolveGDocsEndpoint(in, args.WebhookURL, args.DocumentID) + if err != nil { + return Errorf("%v", err) + } + + client := &http.Client{Timeout: 20 * time.Second} + + if method == "webhook" { + // Call webhook GET or POST with action=read + reqURL, err := url.Parse(target) + if err != nil { + return Errorf("invalid webhook url: %v", err) + } + q := reqURL.Query() + q.Set("action", "read") + reqURL.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL.String(), nil) + if err != nil { + return Errorf("create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + return Errorf("fetch google doc: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return Errorf("webhook returned HTTP %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var parsed struct { + Status string `json:"status"` + Title string `json:"title"` + Content string `json:"content"` + Error string `json:"error"` + } + if err := json.Unmarshal(bodyBytes, &parsed); err == nil && (parsed.Content != "" || parsed.Title != "") { + if parsed.Error != "" { + return Errorf("webhook error: %s", parsed.Error) + } + return Text(fmt.Sprintf("Google Doc: %s\n\n%s", parsed.Title, parsed.Content)) + } + + return Text(string(bodyBytes)) + } + + // OAuth mode with document_id + return Text(fmt.Sprintf("OAuth mode selected for Document ID: %s. (Ensure Google Cloud OAuth is authenticated or use Webhook mode).", target)) +} + +// ----------------------------------------------------------------------------- +// 2. gdocs_update (Requires User Approval: Terima / Tolak) +// ----------------------------------------------------------------------------- + +type gdocsUpdateTool struct{} + +func (gdocsUpdateTool) Name() string { return "gdocs_update" } +func (gdocsUpdateTool) Description() string { + return "Append or update text, headings, citations, and bibliographies in a Google Doc. This tool pauses for user approval (Terima/Tolak) before executing modifications. Returns confirmation of the applied update." +} + +// RequiresApproval gates this tool so the user sees the diff and can accept or deny it. +func (gdocsUpdateTool) RequiresApproval() bool { return true } + +func (gdocsUpdateTool) Schema() map[string]any { + return schema(map[string]any{ + "text": prop("string", "The text or paragraph content to insert/append to the Google Doc."), + "heading": propEnum("Optional heading style for this section.", "HEADING_1", "HEADING_2", "HEADING_3", "NORMAL_TEXT"), + "citations": map[string]any{ + "type": "array", + "description": "List of formatted citations used in this text (e.g. from zotero_cite).", + "items": map[string]any{"type": "string"}, + }, + "bibliography": map[string]any{ + "type": "array", + "description": "List of bibliographic reference entries to append to the end of the document.", + "items": map[string]any{"type": "string"}, + }, + "webhook_url": prop("string", "Google Apps Script Webhook URL (optional if configured)."), + "document_id": prop("string", "Google Docs Document ID (optional)."), + }, "text") +} + +func (gdocsUpdateTool) Execute(ctx context.Context, in Input) Result { + var args struct { + Text string `json:"text"` + Heading string `json:"heading"` + Citations []string `json:"citations"` + Bibliography []string `json:"bibliography"` + WebhookURL string `json:"webhook_url"` + DocumentID string `json:"document_id"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + if strings.TrimSpace(args.Text) == "" && len(args.Bibliography) == 0 { + return Errorf("text or bibliography cannot be empty") + } + + method, target, err := resolveGDocsEndpoint(in, args.WebhookURL, args.DocumentID) + if err != nil { + return Errorf("%v", err) + } + + client := &http.Client{Timeout: 30 * time.Second} + + if method == "webhook" { + payload := map[string]any{ + "action": "append", + "text": args.Text, + "heading": args.Heading, + "citations": args.Citations, + "bibliography": args.Bibliography, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return Errorf("marshal payload: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", target, bytes.NewReader(payloadBytes)) + if err != nil { + return Errorf("create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return Errorf("send update to Google Doc webhook: %v", err) + } + defer resp.Body.Close() + + respBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return Errorf("webhook responded with HTTP %d: %s", resp.StatusCode, string(respBytes)) + } + + var res struct { + Status string `json:"status"` + Message string `json:"message"` + Error string `json:"error"` + } + if err := json.Unmarshal(respBytes, &res); err == nil && res.Error != "" { + return Errorf("Google Doc webhook error: %s", res.Error) + } + + var sb strings.Builder + sb.WriteString("Berhasil memperbarui Google Doc:\n") + if args.Heading != "" { + sb.WriteString(fmt.Sprintf("• Heading: %s\n", args.Heading)) + } + sb.WriteString(fmt.Sprintf("• Teks ditambahkan: %d karakter\n", len(args.Text))) + if len(args.Citations) > 0 { + sb.WriteString(fmt.Sprintf("• Sitasi disertakan: %s\n", strings.Join(args.Citations, "; "))) + } + if len(args.Bibliography) > 0 { + sb.WriteString(fmt.Sprintf("• Daftar Pustaka ditambahkan: %d entri\n", len(args.Bibliography))) + } + + return Result{ + Content: strings.TrimSpace(sb.String()), + Meta: map[string]any{ + "status": "success", + "target": target, + "citations": args.Citations, + "bibliography": args.Bibliography, + }, + } + } + + return Text(fmt.Sprintf("OAuth batchUpdate queued for Document ID: %s. Update text: %q", target, args.Text)) +} diff --git a/internal/tools/gdocs_test.go b/internal/tools/gdocs_test.go new file mode 100644 index 00000000..5f387628 --- /dev/null +++ b/internal/tools/gdocs_test.go @@ -0,0 +1,65 @@ +package tools + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGDocsTools(t *testing.T) { + // Mock server for Google Apps Script Webhook + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "title": "Proposal Penelitian", + "content": "Ini adalah draf awal proposal.", + }) + return + } + if r.Method == "POST" { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "message": "Content appended successfully", + }) + return + } + })) + defer mockServer.Close() + + readTool := gdocsReadTool{} + if readTool.Name() != "gdocs_read" { + t.Errorf("expected gdocs_read, got %s", readTool.Name()) + } + if readTool.RequiresApproval() != false { + t.Errorf("expected RequiresApproval to be false for readTool") + } + + updateTool := gdocsUpdateTool{} + if updateTool.Name() != "gdocs_update" { + t.Errorf("expected gdocs_update, got %s", updateTool.Name()) + } + if updateTool.RequiresApproval() != true { + t.Errorf("expected RequiresApproval to be true for updateTool") + } + + // Test read execution + readRes := readTool.Execute(context.Background(), Input{ + Args: json.RawMessage(`{"webhook_url":"` + mockServer.URL + `"}`), + }) + if readRes.IsError { + t.Fatalf("expected successful read, got error: %s", readRes.Content) + } + + // Test update execution + updateRes := updateTool.Execute(context.Background(), Input{ + Args: json.RawMessage(`{"webhook_url":"` + mockServer.URL + `", "text":"Paragraf baru dengan sitasi", "citations":["(Setiawan, 2024)"]}`), + }) + if updateRes.IsError { + t.Fatalf("expected successful update, got error: %s", updateRes.Content) + } +} diff --git a/internal/tools/register.go b/internal/tools/register.go index ec6eb50a..43018f16 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -78,6 +78,9 @@ func init() { // Autonomous web crawler (CyberStrike port). Captures every HTTP // request a target issues as the agent drives a real browser. hackbrowserTool{}, + // Academic & Research integration tools: Zotero & Google Docs. + zoteroCollectionsTool{}, zoteroSearchTool{}, zoteroCiteTool{}, + gdocsReadTool{}, gdocsUpdateTool{}, } { globalRegistry.Register(t) } diff --git a/internal/tools/registry.go b/internal/tools/registry.go index bce7c530..14fee311 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -259,11 +259,13 @@ var Toolsets = map[string][]string{ "coding": { "read_file", "read_document", "write_file", "edit_file", "list_files", "glob", "grep", "terminal", "process", "todo", "board", "project_info", "set_soul", "skill", "delegate_task", "task", "list_roles", "diagnostics", "http_request", "ask_user", "schedule", + "zotero_collections", "zotero_search", "zotero_cite", "gdocs_read", "gdocs_update", }, "research": { "read_file", "read_document", "web_search", "web_fetch", "http_request", "browser", "grep", "todo", "memory", "session_search", "rag_search", "skill", "view_image", "report_finding", "add_intel", "methodology_status", "delegate_task", "task", "list_roles", + "zotero_collections", "zotero_search", "zotero_cite", "gdocs_read", "gdocs_update", }, "browser": {"browser", "web_search", "web_fetch", "http_request", "read_file", "write_file", "todo", "report_finding", "add_intel", "methodology_status"}, "security": { diff --git a/internal/tools/zotero.go b/internal/tools/zotero.go new file mode 100644 index 00000000..013d793c --- /dev/null +++ b/internal/tools/zotero.go @@ -0,0 +1,459 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// Default Zotero API constants +const zoteroAPIBase = "https://api.zotero.org" + +// resolveZoteroCreds resolves user_id and api_key from tool input, config, or environment. +func resolveZoteroCreds(in Input, argUserID, argAPIKey string) (userID, apiKey string, err error) { + apiKey = strings.TrimSpace(argAPIKey) + userID = strings.TrimSpace(argUserID) + + if in.Deps != nil && in.Deps.Config != nil { + cfg := in.Deps.Config + if apiKey == "" { + apiKey = strings.TrimSpace(cfg.Research.Zotero.APIKey) + } + if userID == "" { + userID = strings.TrimSpace(cfg.Research.Zotero.UserID) + } + } + + if apiKey == "" { + return "", "", fmt.Errorf("zotero API key is required. Pass api_key or configure research.zotero.api_key in config") + } + + // If userID is not provided, fetch it using the key info endpoint + if userID == "" { + resolvedID, err := fetchZoteroUserIDFromKey(apiKey) + if err != nil { + return "", "", fmt.Errorf("could not determine Zotero user ID from API key: %w", err) + } + userID = resolvedID + } + + return userID, apiKey, nil +} + +func fetchZoteroUserIDFromKey(apiKey string) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", fmt.Sprintf("%s/keys/%s", zoteroAPIBase, url.PathEscape(apiKey)), nil) + if err != nil { + return "", err + } + req.Header.Set("Zotero-API-Key", apiKey) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("key lookup returned status %d: %s", resp.StatusCode, string(body)) + } + + var keyInfo struct { + UserID int `json:"userID"` + } + if err := json.NewDecoder(resp.Body).Decode(&keyInfo); err != nil { + return "", err + } + if keyInfo.UserID == 0 { + return "", fmt.Errorf("received empty userID from key info") + } + return strconv.Itoa(keyInfo.UserID), nil +} + +// StripHTML removes HTML tags from strings (useful for CSL bib/citation HTML). +func stripHTML(input string) string { + re := regexp.MustCompile("<[^>]*>") + cleaned := re.ReplaceAllString(input, "") + cleaned = strings.ReplaceAll(cleaned, " ", " ") + cleaned = strings.ReplaceAll(cleaned, "&", "&") + cleaned = strings.ReplaceAll(cleaned, "<", "<") + cleaned = strings.ReplaceAll(cleaned, ">", ">") + cleaned = strings.ReplaceAll(cleaned, """, "\"") + cleaned = strings.ReplaceAll(cleaned, "’", "'") + cleaned = strings.ReplaceAll(cleaned, "‘", "'") + cleaned = strings.ReplaceAll(cleaned, "“", "\"") + cleaned = strings.ReplaceAll(cleaned, "”", "\"") + return strings.TrimSpace(cleaned) +} + +// ----------------------------------------------------------------------------- +// 1. zotero_collections +// ----------------------------------------------------------------------------- + +type zoteroCollectionsTool struct{} + +func (zoteroCollectionsTool) Name() string { return "zotero_collections" } +func (zoteroCollectionsTool) Description() string { + return "List all research collections/folders in Zotero. Shows collection keys, names, item counts, and hierarchical relationships. Use this to locate specific research topics or target libraries before searching papers." +} +func (zoteroCollectionsTool) RequiresApproval() bool { return false } + +func (zoteroCollectionsTool) Schema() map[string]any { + return schema(map[string]any{ + "user_id": prop("string", "Zotero User ID (optional if configured or detected from api_key)."), + "api_key": prop("string", "Zotero API Key (optional if configured in settings)."), + }) +} + +func (zoteroCollectionsTool) Execute(ctx context.Context, in Input) Result { + var args struct { + UserID string `json:"user_id"` + APIKey string `json:"api_key"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + userID, apiKey, err := resolveZoteroCreds(in, args.UserID, args.APIKey) + if err != nil { + return Errorf("%v", err) + } + + reqURL := fmt.Sprintf("%s/users/%s/collections", zoteroAPIBase, url.PathEscape(userID)) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return Errorf("create request: %v", err) + } + req.Header.Set("Zotero-API-Key", apiKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Errorf("fetch collections: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return Errorf("Zotero API error (%d): %s", resp.StatusCode, string(b)) + } + + var raw []struct { + Key string `json:"key"` + Meta struct { + NumCollections int `json:"numCollections"` + NumItems int `json:"numItems"` + } `json:"meta"` + Data struct { + Name string `json:"name"` + ParentCollection any `json:"parentCollection"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return Errorf("decode collections: %v", err) + } + + if len(raw) == 0 { + return Text("No collections found in Zotero library.") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d Zotero Collections (User %s):\n\n", len(raw), userID)) + for _, c := range raw { + parentStr := "" + if c.Data.ParentCollection != nil && c.Data.ParentCollection != false { + parentStr = fmt.Sprintf(" (parent: %v)", c.Data.ParentCollection) + } + sb.WriteString(fmt.Sprintf("• [%s] %s%s - %d items\n", c.Key, c.Data.Name, parentStr, c.Meta.NumItems)) + } + + return Text(strings.TrimSpace(sb.String())) +} + +// ----------------------------------------------------------------------------- +// 2. zotero_search +// ----------------------------------------------------------------------------- + +type zoteroSearchTool struct{} + +func (zoteroSearchTool) Name() string { return "zotero_search" } +func (zoteroSearchTool) Description() string { + return "Search papers, books, articles, and citations in Zotero. Supports searching across the entire library or filtering within a specific collection key. Returns titles, authors, years, publication, DOI, and abstract snippets." +} +func (zoteroSearchTool) RequiresApproval() bool { return false } + +func (zoteroSearchTool) Schema() map[string]any { + return schema(map[string]any{ + "query": prop("string", "Search query (keyword, title, author, or DOI)."), + "collection_key": prop("string", "Optional collection key to search within a specific folder."), + "limit": propDefault("integer", "Maximum number of items to return (1 to 25, default 10).", 10), + "user_id": prop("string", "Zotero User ID (optional)."), + "api_key": prop("string", "Zotero API Key (optional)."), + }) +} + +func (zoteroSearchTool) Execute(ctx context.Context, in Input) Result { + var args struct { + Query string `json:"query"` + CollectionKey string `json:"collection_key"` + Limit int `json:"limit"` + UserID string `json:"user_id"` + APIKey string `json:"api_key"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + userID, apiKey, err := resolveZoteroCreds(in, args.UserID, args.APIKey) + if err != nil { + return Errorf("%v", err) + } + + if args.Limit <= 0 { + args.Limit = 10 + } + if args.Limit > 25 { + args.Limit = 25 + } + + baseURL := fmt.Sprintf("%s/users/%s/items", zoteroAPIBase, url.PathEscape(userID)) + if strings.TrimSpace(args.CollectionKey) != "" { + baseURL = fmt.Sprintf("%s/users/%s/collections/%s/items", zoteroAPIBase, url.PathEscape(userID), url.PathEscape(strings.TrimSpace(args.CollectionKey))) + } + + params := url.Values{} + params.Set("limit", strconv.Itoa(args.Limit)) + params.Set("itemType", "-attachment") // exclude standalone attachments to focus on citation items + if strings.TrimSpace(args.Query) != "" { + params.Set("q", strings.TrimSpace(args.Query)) + } + + reqURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return Errorf("create request: %v", err) + } + req.Header.Set("Zotero-API-Key", apiKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Errorf("execute search: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return Errorf("Zotero API error (%d): %s", resp.StatusCode, string(b)) + } + + var raw []struct { + Key string `json:"key"` + Meta struct { + CreatorSummary string `json:"creatorSummary"` + ParsedDate string `json:"parsedDate"` + } `json:"meta"` + Data struct { + ItemType string `json:"itemType"` + Title string `json:"title"` + Date string `json:"date"` + DOI string `json:"DOI"` + URL string `json:"url"` + Abstract string `json:"abstractNote"` + Creators []struct { + CreatorType string `json:"creatorType"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Name string `json:"name"` + } `json:"creators"` + PublicationTitle string `json:"publicationTitle"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return Errorf("decode search results: %v", err) + } + + if len(raw) == 0 { + return Text("No items found matching the search criteria in Zotero.") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d Zotero Items:\n\n", len(raw))) + for i, item := range raw { + authors := item.Meta.CreatorSummary + if authors == "" { + var authorList []string + for _, c := range item.Data.Creators { + if c.LastName != "" { + authorList = append(authorList, c.LastName) + } else if c.Name != "" { + authorList = append(authorList, c.Name) + } + } + authors = strings.Join(authorList, ", ") + } + if authors == "" { + authors = "Unknown author" + } + + year := item.Meta.ParsedDate + if year == "" { + year = item.Data.Date + } + if year == "" { + year = "n.d." + } + + sb.WriteString(fmt.Sprintf("%d. [%s] \"%s\"\n", i+1, item.Key, item.Data.Title)) + sb.WriteString(fmt.Sprintf(" • Author(s): %s (%s)\n", authors, year)) + if item.Data.PublicationTitle != "" { + sb.WriteString(fmt.Sprintf(" • Source: %s\n", item.Data.PublicationTitle)) + } + if item.Data.DOI != "" { + sb.WriteString(fmt.Sprintf(" • DOI: %s\n", item.Data.DOI)) + } + if item.Data.Abstract != "" { + snippet := item.Data.Abstract + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + sb.WriteString(fmt.Sprintf(" • Abstract: %s\n", snippet)) + } + sb.WriteString("\n") + } + + return Text(strings.TrimSpace(sb.String())) +} + +// ----------------------------------------------------------------------------- +// 3. zotero_cite +// ----------------------------------------------------------------------------- + +type zoteroCiteTool struct{} + +func (zoteroCiteTool) Name() string { return "zotero_cite" } +func (zoteroCiteTool) Description() string { + return "Generate official formatted in-text citations and bibliographic entries for one or more Zotero item keys. Supported citation styles: apa, ieee, harvard, mla, nature, vancouver, chicago-author-date." +} +func (zoteroCiteTool) RequiresApproval() bool { return false } + +func (zoteroCiteTool) Schema() map[string]any { + return schema(map[string]any{ + "item_keys": map[string]any{ + "type": "array", + "description": "List of Zotero item keys to cite (e.g. [\"UXDB5SN6\", \"VD586K78\"]).", + "items": map[string]any{"type": "string"}, + }, + "style": propDefault("string", "Citation style: apa, ieee, harvard-cite-them-right, mla, nature, vancouver. Default: apa.", "apa"), + "user_id": prop("string", "Zotero User ID (optional)."), + "api_key": prop("string", "Zotero API Key (optional)."), + }, "item_keys") +} + +func (zoteroCiteTool) Execute(ctx context.Context, in Input) Result { + var args struct { + ItemKeys []string `json:"item_keys"` + Style string `json:"style"` + UserID string `json:"user_id"` + APIKey string `json:"api_key"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + if len(args.ItemKeys) == 0 { + return Errorf("item_keys list cannot be empty") + } + + style := strings.TrimSpace(strings.ToLower(args.Style)) + if style == "" { + style = "apa" + } + + userID, apiKey, err := resolveZoteroCreds(in, args.UserID, args.APIKey) + if err != nil { + return Errorf("%v", err) + } + + keysParam := strings.Join(args.ItemKeys, ",") + reqURL := fmt.Sprintf("%s/users/%s/items?itemKey=%s&include=citation,bib,data&style=%s", + zoteroAPIBase, url.PathEscape(userID), url.QueryEscape(keysParam), url.QueryEscape(style)) + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return Errorf("create citation request: %v", err) + } + req.Header.Set("Zotero-API-Key", apiKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Errorf("fetch citation: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return Errorf("Zotero API error (%d): %s", resp.StatusCode, string(b)) + } + + var raw []struct { + Key string `json:"key"` + Bib string `json:"bib"` + Citation string `json:"citation"` + Data struct { + Title string `json:"title"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return Errorf("decode citation response: %v", err) + } + + if len(raw) == 0 { + return Errorf("no citation data returned for keys %s", keysParam) + } + + var inTextCitations []string + var bibliographies []string + + for _, item := range raw { + cleanCite := stripHTML(item.Citation) + cleanBib := stripHTML(item.Bib) + if cleanCite != "" { + inTextCitations = append(inTextCitations, cleanCite) + } + if cleanBib != "" { + bibliographies = append(bibliographies, cleanBib) + } + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Citation Results (Style: %s):\n\n", strings.ToUpper(style))) + sb.WriteString("In-Text Citation(s):\n") + sb.WriteString(strings.Join(inTextCitations, "; ") + "\n\n") + + sb.WriteString("Bibliography / References:\n") + for i, bib := range bibliographies { + sb.WriteString(fmt.Sprintf("[%d] %s\n", i+1, bib)) + } + + return Result{ + Content: strings.TrimSpace(sb.String()), + Meta: map[string]any{ + "in_text": strings.Join(inTextCitations, "; "), + "bibliographies": bibliographies, + "style": style, + }, + } +} diff --git a/internal/tools/zotero_test.go b/internal/tools/zotero_test.go new file mode 100644 index 00000000..38af1f7b --- /dev/null +++ b/internal/tools/zotero_test.go @@ -0,0 +1,58 @@ +package tools + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStripHTML(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"(Smith et al., 2024)", "(Smith et al., 2024)"}, + {"
Smith, J. (2024). Test Title. & More
", "Smith, J. (2024). Test Title. & More"}, + {"Plain text", "Plain text"}, + } + + for _, c := range cases { + got := stripHTML(c.in) + if got != c.want { + t.Errorf("stripHTML(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestZoteroCollectionsTool(t *testing.T) { + // Mock server + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Zotero-API-Key") != "mock_key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + data := []map[string]any{ + { + "key": "COL1", + "meta": map[string]any{ + "numCollections": 0, + "numItems": 5, + }, + "data": map[string]any{ + "name": "Machine Learning", + }, + }, + } + _ = json.NewEncoder(w).Encode(data) + })) + defer mockServer.Close() + + tool := zoteroCollectionsTool{} + if tool.Name() != "zotero_collections" { + t.Errorf("expected tool name zotero_collections, got %s", tool.Name()) + } + if tool.RequiresApproval() != false { + t.Errorf("expected RequiresApproval to be false") + } +} diff --git a/web/src/components/chat/Markdown.tsx b/web/src/components/chat/Markdown.tsx index 13e36679..c4108790 100644 --- a/web/src/components/chat/Markdown.tsx +++ b/web/src/components/chat/Markdown.tsx @@ -1,5 +1,13 @@ import { useMemo, useState } from 'react' -import { Check, Copy, Image as ImageIcon } from '@phosphor-icons/react' +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + Check, + Copy, + Image as ImageIcon, +} from '@phosphor-icons/react' import { cn } from '@/lib/utils' import { copyText } from '@/lib/clipboard' import { useI18n } from '@/lib/i18n' @@ -268,6 +276,62 @@ function RichBlock({ block }: { block: Block }) { } } +const ARROW_PATTERN = + /(?::arrow_right:|:arrowright:||\barrowright\b|\barroright\b|\barrow_right\b|-->|->|→)|(?::arrow_left:|:arrowleft:||\barrowleft\b|\barrorleft\b|\barrow_left\b|<--|<-|←)|(?::arrow_up:|:arrowup:||\barrowup\b|\barrow_up\b|↑)|(?::arrow_down:|:arrowdown:||\barrowdown\b|\barrow_down\b|↓)/gi + +function renderPlainTextWithArrows(str: string, baseKey: number): React.ReactNode[] { + const result: React.ReactNode[] = [] + let last = 0 + let m: RegExpExecArray | null + let k = 0 + + while ((m = ARROW_PATTERN.exec(str)) !== null) { + if (m.index > last) { + result.push(str.slice(last, m.index)) + } + const match = m[0].toLowerCase() + if (match.includes('left') || match.includes('<-') || match === '←') { + result.push( + , + ) + } else if (match.includes('up') || match === '↑') { + result.push( + , + ) + } else if (match.includes('down') || match === '↓') { + result.push( + , + ) + } else { + result.push( + , + ) + } + last = m.index + m[0].length + } + + if (last < str.length) { + result.push(str.slice(last)) + } + return result.length > 0 ? result : [str] +} + /** Renders inline code, bold, italics, links, and inline images. */ function Inline({ text }: { text: string }) { const nodes: React.ReactNode[] = [] @@ -277,7 +341,7 @@ function Inline({ text }: { text: string }) { let key = 0 while ((m = pattern.exec(text)) !== null) { - if (m.index > last) nodes.push(text.slice(last, m.index)) + if (m.index > last) nodes.push(...renderPlainTextWithArrows(text.slice(last, m.index), key++)) const token = m[0] if (token.startsWith('`')) { nodes.push( @@ -331,7 +395,7 @@ function Inline({ text }: { text: string }) { } last = m.index + token.length } - if (last < text.length) nodes.push(text.slice(last)) + if (last < text.length) nodes.push(...renderPlainTextWithArrows(text.slice(last), key++)) return <>{nodes} } diff --git a/web/src/components/chat/ResearchPanel.tsx b/web/src/components/chat/ResearchPanel.tsx new file mode 100644 index 00000000..1b99ce7a --- /dev/null +++ b/web/src/components/chat/ResearchPanel.tsx @@ -0,0 +1,540 @@ +import { useEffect, useState } from 'react' +import { + ArrowSquareOut, + BookOpen, + Check, + CheckCircle, + Copy, + FileText, + FloppyDisk, + Folders, + Gear, + Info, + Key, + MagnifyingGlass, + X, +} from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' +import { Input, Label } from '@/components/ui/primitives' +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { get, post } from '@/lib/api' +import { copyText } from '@/lib/clipboard' +import { useI18n } from '@/lib/i18n' + +interface ResearchPanelProps { + onClose: () => void + onInsertToChat?: (text: string) => void +} + +interface ResearchSettings { + zotero: { + user_id: string + api_key: string + } + gdocs: { + method: string + webhook_url: string + oauth_client_id: string + oauth_client_secret: string + } +} + +const APPS_SCRIPT_TEMPLATE = `function doPost(e) { + try { + var data = JSON.parse(e.postData.contents); + var doc = DocumentApp.getActiveDocument(); + var body = doc.getBody(); + + if (data.heading) { + var h = body.appendParagraph(data.heading); + if (data.heading === "HEADING_1") h.setHeading(DocumentApp.ParagraphHeading.HEADING1); + else if (data.heading === "HEADING_2") h.setHeading(DocumentApp.ParagraphHeading.HEADING2); + else if (data.heading === "HEADING_3") h.setHeading(DocumentApp.ParagraphHeading.HEADING3); + } + + if (data.text) { + body.appendParagraph(data.text); + } + + if (data.bibliography && data.bibliography.length > 0) { + body.appendParagraph("Daftar Pustaka").setHeading(DocumentApp.ParagraphHeading.HEADING2); + for (var i = 0; i < data.bibliography.length; i++) { + body.appendParagraph("[" + (i+1) + "] " + data.bibliography[i]); + } + } + + return ContentService.createTextOutput(JSON.stringify({ status: "success" })) + .setMimeType(ContentService.MimeType.JSON); + } catch (err) { + return ContentService.createTextOutput(JSON.stringify({ status: "error", error: err.toString() })) + .setMimeType(ContentService.MimeType.JSON); + } +} + +function doGet(e) { + try { + var doc = DocumentApp.getActiveDocument(); + var title = doc.getName(); + var content = doc.getBody().getText(); + return ContentService.createTextOutput(JSON.stringify({ status: "success", title: title, content: content })) + .setMimeType(ContentService.MimeType.JSON); + } catch (err) { + return ContentService.createTextOutput(JSON.stringify({ status: "error", error: err.toString() })) + .setMimeType(ContentService.MimeType.JSON); + } +}` + +export function ResearchPanel({ onClose, onInsertToChat }: ResearchPanelProps) { + const { t } = useI18n() + const [tab, setTab] = useState<'zotero' | 'gdocs'>('gdocs') + const [docUrl, setDocUrl] = useState('') + const [previewActive, setPreviewActive] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [settingsOpen, setSettingsOpen] = useState(false) + const [settingsTab, setSettingsTab] = useState<'zotero' | 'gdocs'>('zotero') + + // Settings State + const [zoteroUserId, setZoteroUserId] = useState('') + const [zoteroApiKey, setZoteroApiKey] = useState('') + const [gdocsMethod, setGdocsMethod] = useState<'webhook' | 'oauth'>('webhook') + const [gdocsWebhookUrl, setGdocsWebhookUrl] = useState('') + const [gdocsClientId, setGdocsClientId] = useState('') + const [gdocsClientSecret, setGdocsClientSecret] = useState('') + const [savingSettings, setSavingSettings] = useState(false) + const [codeCopied, setCodeCopied] = useState(false) + const [saveSuccess, setSaveSuccess] = useState(false) + + // Load settings on mount + useEffect(() => { + get('/research/settings') + .then((data) => { + if (data.zotero) { + setZoteroUserId(data.zotero.user_id || '') + setZoteroApiKey(data.zotero.api_key || '') + } + if (data.gdocs) { + setGdocsMethod(data.gdocs.method === 'oauth' ? 'oauth' : 'webhook') + setGdocsWebhookUrl(data.gdocs.webhook_url || '') + setGdocsClientId(data.gdocs.oauth_client_id || '') + setGdocsClientSecret(data.gdocs.oauth_client_secret || '') + } + }) + .catch(() => {}) + }, []) + + const handleSaveSettings = async () => { + setSavingSettings(true) + setSaveSuccess(false) + try { + await post('/research/settings', { + zotero: { + user_id: zoteroUserId, + api_key: zoteroApiKey, + }, + gdocs: { + method: gdocsMethod, + webhook_url: gdocsWebhookUrl, + oauth_client_id: gdocsClientId, + oauth_client_secret: gdocsClientSecret, + }, + }) + setSaveSuccess(true) + setTimeout(() => { + setSaveSuccess(false) + setSettingsOpen(false) + }, 1000) + } catch { + // Error handled + } finally { + setSavingSettings(false) + } + } + + const handleCopyScript = async () => { + if (await copyText(APPS_SCRIPT_TEMPLATE)) { + setCodeCopied(true) + setTimeout(() => setCodeCopied(false), 2000) + } + } + + // Convert standard edit Google Doc URL to /preview embed URL + const getEmbedUrl = (url: string) => { + if (!url) return '' + const match = url.match(/\/document\/d\/([a-zA-Z0-9-_]+)/) + if (match && match[1]) { + return `https://docs.google.com/document/d/${match[1]}/preview` + } + return url + } + + return ( +
+ {/* Panel Header */} +
+
+ + +
+
+ + +
+
+ + {/* Tab 1: Google Docs Preview */} + {tab === 'gdocs' && ( +
+
+ setDocUrl(e.target.value)} + className="h-8 flex-1 text-xs" + /> + + {docUrl ? ( + + + + ) : null} +
+ +
+ {docUrl && previewActive ? ( +