From 59d1977699125ea494e5f0922e76cb98532ade5a Mon Sep 17 00:00:00 2001 From: Masayoshi Mizutani Date: Fri, 19 Jun 2026 15:38:34 +0900 Subject: [PATCH 1/2] feat: add workspace-wide Knowledge feature with agent tools and WebUI --- Taskfile.yml | 5 + docs/concepts.md | 13 +- docs/eval.md | 7 + docs/user_guide.md | 55 + frontend/src/App.tsx | 4 + frontend/src/components/Icons.tsx | 1 + frontend/src/components/Sidebar.tsx | 9 + .../KnowledgeMarkdownView.module.css | 83 ++ .../knowledge/KnowledgeMarkdownView.tsx | 22 + .../components/knowledge/TagInput.module.css | 85 ++ .../components/knowledge/TagInput.test.tsx | 98 ++ .../src/components/knowledge/TagInput.tsx | 97 ++ frontend/src/graphql/knowledge.ts | 69 + frontend/src/i18n/en.ts | 26 + frontend/src/i18n/ja.ts | 26 + frontend/src/i18n/keys.ts | 26 + frontend/src/pages/KnowledgeDetail.tsx | 345 +++++ frontend/src/pages/KnowledgeList.tsx | 229 +++ gqlgen.yml | 6 - graphql/schema.graphql | 43 + pkg/agent/tool/core/core_test.go | 3 + pkg/agent/tool/knowledge/tools.go | 381 +++++ pkg/agent/tool/knowledge/tools_test.go | 187 +++ pkg/cli/job_runtime.go | 15 + pkg/controller/graphql/converter.go | 18 + pkg/controller/graphql/generated.go | 1298 ++++++++++++++++- pkg/controller/graphql/schema.resolvers.go | 93 ++ pkg/controller/http/graphql_test.go | 125 ++ pkg/domain/interfaces/knowledge.go | 39 + pkg/domain/interfaces/repository.go | 1 + pkg/domain/model/graphql/models.go | 16 - pkg/domain/model/graphql/models_gen.go | 22 + pkg/domain/model/knowledge.go | 144 ++ pkg/domain/model/knowledge_test.go | 104 ++ pkg/repository/action_event_test.go | 2 + pkg/repository/action_message_test.go | 2 + pkg/repository/action_step_test.go | 2 + pkg/repository/action_test.go | 2 + pkg/repository/assist_log_test.go | 2 + pkg/repository/auth_test.go | 2 + pkg/repository/case_message_test.go | 2 + pkg/repository/case_proposal_test.go | 2 + pkg/repository/case_test.go | 2 + pkg/repository/firestore/firestore.go | 6 + pkg/repository/firestore/knowledge.go | 164 +++ pkg/repository/import_test.go | 4 + pkg/repository/job_run_test.go | 6 + pkg/repository/knowledge_test.go | 224 +++ pkg/repository/memo_test.go | 2 + pkg/repository/memory/knowledge.go | 169 +++ pkg/repository/memory/memory.go | 6 + pkg/repository/notification_slot_test.go | 2 + pkg/repository/session_test.go | 2 + pkg/repository/slack_test.go | 2 + pkg/repository/slack_user_test.go | 8 + pkg/repository/source_test.go | 2 + pkg/usecase/agent.go | 6 + pkg/usecase/agent/agent.go | 12 +- pkg/usecase/agent/casebound/casebound.go | 22 +- pkg/usecase/agent/threadcase/threadcase.go | 5 + pkg/usecase/agent/toolset.go | 47 +- pkg/usecase/eval/env/env.go | 14 +- pkg/usecase/knowledge.go | 340 +++++ pkg/usecase/knowledge_test.go | 217 +++ pkg/usecase/knowledge_tool_adapter.go | 61 + pkg/usecase/usecase.go | 5 + 66 files changed, 4931 insertions(+), 108 deletions(-) create mode 100644 frontend/src/components/knowledge/KnowledgeMarkdownView.module.css create mode 100644 frontend/src/components/knowledge/KnowledgeMarkdownView.tsx create mode 100644 frontend/src/components/knowledge/TagInput.module.css create mode 100644 frontend/src/components/knowledge/TagInput.test.tsx create mode 100644 frontend/src/components/knowledge/TagInput.tsx create mode 100644 frontend/src/graphql/knowledge.ts create mode 100644 frontend/src/pages/KnowledgeDetail.tsx create mode 100644 frontend/src/pages/KnowledgeList.tsx create mode 100644 pkg/agent/tool/knowledge/tools.go create mode 100644 pkg/agent/tool/knowledge/tools_test.go create mode 100644 pkg/domain/interfaces/knowledge.go create mode 100644 pkg/domain/model/knowledge.go create mode 100644 pkg/domain/model/knowledge_test.go create mode 100644 pkg/repository/firestore/knowledge.go create mode 100644 pkg/repository/knowledge_test.go create mode 100644 pkg/repository/memory/knowledge.go create mode 100644 pkg/usecase/knowledge.go create mode 100644 pkg/usecase/knowledge_test.go create mode 100644 pkg/usecase/knowledge_tool_adapter.go diff --git a/Taskfile.yml b/Taskfile.yml index 97a19d11..6b6e71db 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -20,6 +20,11 @@ tasks: cmds: - echo "Mock generation will be added when interfaces are defined" + test: + desc: "Run the full Go test suite. Uses an extended timeout because the repository tests run against real Firestore (the per-package default of 10m is not enough); the *_Firestore tests run in parallel to keep wall-clock down. Run as `zenv task test` so TEST_FIRESTORE_PROJECT_ID is loaded." + cmds: + - go test ./... -timeout 30m + test:e2e: desc: "Run E2E tests with Playwright (includes backend startup and teardown)" cmds: diff --git a/docs/concepts.md b/docs/concepts.md index bb667258..88f2e1c4 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -62,12 +62,19 @@ configured per Workspace under `[[action.status]]`. See ### Source An external origin of information (e.g. a Notion page, a GitHub resource, a -Slack message) that the agent tools can read to extract Knowledge. See +Slack message) that the agent tools can read while investigating a Case. See [Integrations](integrations.md). ### Knowledge -A piece of information extracted from a Source and associated with a Case. The -AI assist agent and agent tools produce and consume Knowledge. +A **workspace-wide shared knowledge entry**: organization-specific information +that does not exist in the LLM's general knowledge (operating rules, internal +proper nouns, past judgements, threat intel, …), captured so it can be reused on +future Case processing. A Knowledge entry has a title, a single Markdown `claim` +body, and one or more free-form `tags`; it is **not** tied to a Case and carries +no custom fields. Both humans (via the WebUI **Knowledge** section) and AI agents +(via the `knowledge__*` tools) read and write it. Entries are retrieved by +semantic search (embedding-based, with a substring fallback) and by tag filter. +See [User Guide → Knowledge](user_guide.md#knowledge). ### Agent Session A persistent AI conversation tied to a Slack thread on a Case. History and diff --git a/docs/eval.md b/docs/eval.md index 9707f655..3ddd91b0 100644 --- a/docs/eval.md +++ b/docs/eval.md @@ -229,6 +229,13 @@ from the eval job tool set — action creation is the primary observable.) real API (require the matching credentials). Every tool call — sim or live — is recorded so checks can verify tool usage and dumps can show the trajectory. +The catalog enumerates only the **external-service** tools the harness can +simulate or declare. In-process, always-present tools that operate on the local +repository — `core__*` (actions), `memo__*` (memos), and `knowledge__*` +(workspace knowledge) — are not listed in the catalog and are not +scenario-configurable; the eval agent is wired with them directly (knowledge +write is withheld for private cases, as in production). + ## Diagnostic dumps Scenarios with a failing check (or all scenarios with `--dump-all`) are dumped diff --git a/docs/user_guide.md b/docs/user_guide.md index 79107e4a..3c48a48f 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -553,6 +553,55 @@ All mutations go through GraphQL (WebUI) or agent tools (LLM). Domain models, repository backends, use cases, and Firestore layout for Action Steps are documented in [develop/architecture.md](develop/architecture.md). +## Knowledge + +The **Knowledge** section (sidebar → Knowledge) is a workspace-wide, shared +knowledge base for capturing organization-specific information that the LLM does +not know on its own — operating rules, internal proper nouns, past judgements, +threat intel, and so on — so it can be reused on future cases. See +[Concepts → Knowledge](concepts.md#knowledge) for the definition. + +A Knowledge entry has three parts: + +- **Title** — a one-line heading (required). +- **Claim** — a single **Markdown** body. Headings, lists, code blocks, and + links are rendered in the viewer. Up to ~8,000 characters. +- **Tags** — one or more free-form labels (**at least one is required**). Tags + drive filtering and are suggested from those already used in the workspace. + +### Browsing and searching + +The list shows each entry as a card (title, a plain-text preview of the claim, +its tags, author, and last-updated time). You can: + +- **Filter by tag** — pick one or more tags; only entries carrying *all* of them + are shown. +- **Search** — type in the search box for semantic search (results ranked by + relevance, not just exact keyword match). When the deployment has no embedding + client configured, search falls back to substring matching. + +### Editing + +Open an entry to view the rendered claim; switch to **Edit** to change the raw +Markdown (a character counter enforces the length limit), edit the title, and +add/remove tags. **Create** and **Delete** are available from the list and +detail views. Deletion is permanent and asks for confirmation. + +### Who can write + +Workspace members can read and write all Knowledge through the WebUI. AI agents +can also read it always and **write it during case processing — except while +working a private case**, because shared knowledge is visible to the whole +workspace and a private case's contents must not leak into it. See +[the agent tool table](#available-agent-tools). + +### Embedding (semantic search) + +Semantic search reuses the existing embedding client configured via +`--embedding-gemini-project-id` / `--embedding-gemini-location` / +`--embedding-model` (see [CLI Reference](cli.md)). No Knowledge-specific flags or +environment variables are introduced, and no new Firestore index is required. + ## Chat with the AI in a Slack thread The agent that responds to `@mention` in Slack threads treats each thread as @@ -606,10 +655,16 @@ own configuration; missing config silently disables that namespace's tools | Namespace | Tools | Gate | | --- | --- | --- | | `core__*` | Action read/create/update/archive | Always on | +| `knowledge__*` | Workspace knowledge: `search`/`get`/`list_tags` (read), `create`/`update` (write) | Read always on; **write tools are withheld while processing a private Case** (shared knowledge is workspace-visible, so a private Case's contents must not leak into it) | | `slack__*` | Workspace search (read-only), bulk message fetch | Slack bot token; search additionally requires the User OAuth token with `search:read` | | `notion__*` | Page/database search, page Markdown fetch | `--notion-api-token` | | `github__*` | Issue/PR search, single Issue/PR fetch, file content, commit history | All three `--github-app-*` flags | +The `knowledge__*` tools share the workspace-wide [Knowledge](#knowledge) base +with the WebUI. Semantic search uses the embedding client (the same one +configured by `--embedding-*`); when no embedding client is configured the agent +still works and search falls back to substring matching. + The mention flow uses the **read-only** Slack tool set (no `post_message` — the trace UI handles outbound messages). The `assist` flow uses the full Slack tool set including `slack__post_message`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 50a7ebaf..9f6716ce 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,8 @@ import SourceList from './pages/SourceList' import SourceDetail from './pages/SourceDetail' import ImportNew from './pages/ImportNew' import ImportDetail from './pages/ImportDetail' +import KnowledgeList from './pages/KnowledgeList' +import KnowledgeDetail from './pages/KnowledgeDetail' import WorkspaceSelector from './pages/WorkspaceSelector' import WorkspaceGuard from './components/WorkspaceGuard' import { AuthGuard } from './components/auth/auth-guard' @@ -46,6 +48,8 @@ function App() { } /> } /> } /> + } /> + } /> {/* /imports has no list page by design — sessions are addressable only by ID. A bare /imports navigation lands here from the breadcrumb Layout renders, so redirect it to the new-import diff --git a/frontend/src/components/Icons.tsx b/frontend/src/components/Icons.tsx index 146e2d71..a85e6d99 100644 --- a/frontend/src/components/Icons.tsx +++ b/frontend/src/components/Icons.tsx @@ -49,6 +49,7 @@ export const IconRefresh = (p: P) => export const IconSparkle = (p: P) => export const IconEdit = (p: P) => +export const IconKnowledge = (p: P) => // IconRobot — a friendly "bot" head used as the Agent surface mark. // Built as a multi-shape SVG (head rect + antenna + ear stubs + eyes diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6634b847..81b1b9eb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,12 +6,14 @@ import { useTranslation } from '../i18n' import { GET_CASES } from '../graphql/case' import { GET_ACTIONS } from '../graphql/action' import { GET_SOURCES } from '../graphql/source' +import { GET_KNOWLEDGES } from '../graphql/knowledge' import { useCaseStatuses } from '../hooks/useCaseStatuses' import { Avatar } from './Primitives' import { IconCases, IconActions, IconSources, + IconKnowledge, IconSettings, IconUser, } from './Icons' @@ -20,6 +22,7 @@ interface NavCount { cases: number | null actions: number | null sources: number | null + knowledge: number | null } function useNavCounts(workspaceId: string | undefined): NavCount { @@ -35,10 +38,15 @@ function useNavCounts(workspaceId: string | undefined): NavCount { variables: { workspaceId }, skip: !workspaceId, }) + const { data: knowledges } = useQuery(GET_KNOWLEDGES, { + variables: { workspaceId }, + skip: !workspaceId, + }) return { cases: cases?.cases?.length ?? null, actions: actions?.actions?.length ?? null, sources: sources?.sources?.length ?? null, + knowledge: knowledges?.knowledges?.length ?? null, } } @@ -62,6 +70,7 @@ export default function Sidebar() { count: isThreadMode ? counts.cases : counts.actions, }, { id: 'sources', label: t('navSources'), Icon: IconSources, to: `${wsPrefix}/sources`, count: counts.sources }, + { id: 'knowledge', label: t('navKnowledge'), Icon: IconKnowledge, to: `${wsPrefix}/knowledge`, count: counts.knowledge }, ] return ( diff --git a/frontend/src/components/knowledge/KnowledgeMarkdownView.module.css b/frontend/src/components/knowledge/KnowledgeMarkdownView.module.css new file mode 100644 index 00000000..94e1f775 --- /dev/null +++ b/frontend/src/components/knowledge/KnowledgeMarkdownView.module.css @@ -0,0 +1,83 @@ +.placeholder { + color: var(--fg-soft); + font-style: italic; + font-size: var(--t-sm); +} + +.body { + font-size: var(--t-sm); + line-height: 1.7; + color: var(--fg); +} + +.body p { + margin: 0 0 0.75rem; +} + +.body p:last-child { + margin-bottom: 0; +} + +.body h1, +.body h2, +.body h3, +.body h4, +.body h5, +.body h6 { + font-weight: 600; + margin: 1rem 0 0.375rem; + line-height: 1.3; +} + +.body h1 { font-size: var(--t-xl); } +.body h2 { font-size: var(--t-lg); } +.body h3 { font-size: var(--t-base); } + +.body ul, +.body ol { + margin: 0 0 0.75rem; + padding-left: 1.375rem; +} + +.body li { + margin-bottom: 0.25rem; +} + +.body code { + font-family: var(--font-mono, monospace); + font-size: 0.875em; + background: var(--bg-sunken); + border-radius: 3px; + padding: 0.1em 0.3em; +} + +.body pre { + background: var(--bg-sunken); + border-radius: 6px; + padding: var(--spacing-sm) var(--spacing-md); + overflow-x: auto; + margin: 0 0 0.75rem; +} + +.body pre code { + background: none; + padding: 0; +} + +.body blockquote { + border-left: 3px solid var(--line-strong); + margin: 0 0 0.75rem; + padding: 0.25rem 0 0.25rem var(--spacing-md); + color: var(--fg-muted); +} + +.body a { + color: var(--accent); + text-decoration: underline; +} + +.body hr { + border: none; + border-top: 1px solid var(--line); + margin: 1rem 0; +} diff --git a/frontend/src/components/knowledge/KnowledgeMarkdownView.tsx b/frontend/src/components/knowledge/KnowledgeMarkdownView.tsx new file mode 100644 index 00000000..df863052 --- /dev/null +++ b/frontend/src/components/knowledge/KnowledgeMarkdownView.tsx @@ -0,0 +1,22 @@ +import ReactMarkdown, { type Components } from 'react-markdown' +import styles from './KnowledgeMarkdownView.module.css' + +const components: Components = { + a: ({ node: _node, ...rest }) => , +} + +interface Props { + source: string + placeholder?: string +} + +export default function KnowledgeMarkdownView({ source, placeholder = '—' }: Props) { + if (!source.trim()) { + return
{placeholder}
+ } + return ( +
+ {source} +
+ ) +} diff --git a/frontend/src/components/knowledge/TagInput.module.css b/frontend/src/components/knowledge/TagInput.module.css new file mode 100644 index 00000000..46f2f817 --- /dev/null +++ b/frontend/src/components/knowledge/TagInput.module.css @@ -0,0 +1,85 @@ +.root { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + align-items: center; + min-height: 2.25rem; + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg-sunken); + cursor: text; + position: relative; +} + +.root:focus-within { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +.error { + border-color: var(--danger); +} + +.removeBtn { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0 0 0 var(--spacing-xs); + cursor: pointer; + color: inherit; + opacity: 0.6; + line-height: 1; +} + +.removeBtn:hover { + opacity: 1; +} + +.inputWrap { + position: relative; + flex: 1; + min-width: 8rem; +} + +.input { + width: 100%; + background: none; + border: none; + outline: none; + font-size: var(--t-sm); + color: var(--fg); + padding: 0; +} + +.input::placeholder { + color: var(--fg-soft); +} + +.suggestions { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: var(--shadow); + list-style: none; + margin: 0; + padding: var(--spacing-xs) 0; + z-index: 100; +} + +.suggestion { + padding: var(--spacing-xs) var(--spacing-sm); + cursor: pointer; + font-size: var(--t-sm); + color: var(--fg); +} + +.suggestion:hover { + background: var(--bg-subtle); +} diff --git a/frontend/src/components/knowledge/TagInput.test.tsx b/frontend/src/components/knowledge/TagInput.test.tsx new file mode 100644 index 00000000..257d50c6 --- /dev/null +++ b/frontend/src/components/knowledge/TagInput.test.tsx @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' +import TagInput from './TagInput' + +// Minimal i18n mock — the component only uses placeholderKnowledgeTagInput +vi.mock('../../i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + lang: 'en', + setLang: vi.fn(), + }), +})) + +describe('TagInput', () => { + it('adds a tag on Enter when not composing', () => { + const onChange = vi.fn() + render() + const input = screen.getByTestId('tag-input') + + fireEvent.change(input, { target: { value: 'security' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onChange).toHaveBeenCalledWith(['security']) + }) + + it('does NOT add a tag on Enter while IME is composing (keyCode 229 — legacy Safari signal, also tested by commitOnEnter)', () => { + const onChange = vi.fn() + render() + const input = screen.getByTestId('tag-input') + + fireEvent.change(input, { target: { value: 'セキュリティ' } }) + // keyCode 229 is the legacy Safari IME-composition marker checked by commitOnEnter. + // jsdom does not propagate nativeEvent.isComposing through fireEvent, so we + // use the keyCode path — commitOnEnter checks both, ensuring full coverage. + fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('adds a tag on comma input', () => { + const onChange = vi.fn() + render() + const input = screen.getByTestId('tag-input') + + fireEvent.change(input, { target: { value: 'security,' } }) + + expect(onChange).toHaveBeenCalledWith(['security']) + }) + + it('does not add a duplicate tag', () => { + const onChange = vi.fn() + render() + const input = screen.getByTestId('tag-input') + + fireEvent.change(input, { target: { value: 'security' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + // onChange should not be called for a duplicate + expect(onChange).not.toHaveBeenCalled() + }) + + it('does not add an empty or whitespace-only tag', () => { + const onChange = vi.fn() + render() + const input = screen.getByTestId('tag-input') + + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('communicates zero-tags state (renders no chips when tags is empty)', () => { + const { container } = render() + const chips = container.querySelectorAll('.chip') + expect(chips.length).toBe(0) + }) + + it('renders chips for each provided tag and removes on click', () => { + const onChange = vi.fn() + render() + + expect(screen.getByText('alpha')).toBeInTheDocument() + expect(screen.getByText('beta')).toBeInTheDocument() + + const removeAlpha = screen.getByLabelText('Remove alpha') + fireEvent.click(removeAlpha) + + expect(onChange).toHaveBeenCalledWith(['beta']) + }) + + it('applies error styling when error prop is true', () => { + const { container } = render() + const root = container.firstChild as HTMLElement + expect(root.className).toMatch(/error/) + }) +}) diff --git a/frontend/src/components/knowledge/TagInput.tsx b/frontend/src/components/knowledge/TagInput.tsx new file mode 100644 index 00000000..b5b40608 --- /dev/null +++ b/frontend/src/components/knowledge/TagInput.tsx @@ -0,0 +1,97 @@ +import { useRef, useState } from 'react' +import { commitOnEnter } from '../../utils/keyboard' +import { useTranslation } from '../../i18n' +import { IconX } from '../Icons' +import styles from './TagInput.module.css' + +interface TagInputProps { + tags: string[] + onChange: (tags: string[]) => void + suggestions?: string[] + error?: boolean + placeholder?: string +} + +export default function TagInput({ tags, onChange, suggestions = [], error, placeholder }: TagInputProps) { + const { t } = useTranslation() + const [inputValue, setInputValue] = useState('') + const inputRef = useRef(null) + + const addTag = (raw: string) => { + const trimmed = raw.trim() + if (!trimmed || tags.includes(trimmed)) { + setInputValue('') + return + } + onChange([...tags, trimmed]) + setInputValue('') + } + + const removeTag = (tag: string) => { + onChange(tags.filter((t) => t !== tag)) + } + + const handleChange = (e: React.ChangeEvent) => { + const val = e.target.value + // Comma immediately adds the tag + if (val.endsWith(',')) { + addTag(val.slice(0, -1)) + } else { + setInputValue(val) + } + } + + const handleKeyDown = commitOnEnter({ + onCommit: () => addTag(inputValue), + }) + + // Filtered suggestions: not already added and matching input prefix + const filteredSuggestions = inputValue.trim() + ? suggestions.filter( + (s) => !tags.includes(s) && s.toLowerCase().startsWith(inputValue.trim().toLowerCase()), + ) + : [] + + return ( +
inputRef.current?.focus()}> + {tags.map((tag) => ( + + {tag} + + + ))} +
+ + {filteredSuggestions.length > 0 && ( +
    + {filteredSuggestions.slice(0, 6).map((s) => ( +
  • { e.preventDefault(); addTag(s) }} + > + {s} +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/graphql/knowledge.ts b/frontend/src/graphql/knowledge.ts new file mode 100644 index 00000000..b3fda64f --- /dev/null +++ b/frontend/src/graphql/knowledge.ts @@ -0,0 +1,69 @@ +import { gql } from '@apollo/client' + +const KNOWLEDGE_FIELDS = gql` + fragment KnowledgeFields on Knowledge { + id + title + claim + tags + createdAt + updatedAt + } +` + +export const GET_KNOWLEDGES = gql` + ${KNOWLEDGE_FIELDS} + query GetKnowledges($workspaceId: String!, $tags: [String!]) { + knowledges(workspaceId: $workspaceId, tags: $tags) { + ...KnowledgeFields + } + } +` + +export const GET_KNOWLEDGE = gql` + ${KNOWLEDGE_FIELDS} + query GetKnowledge($workspaceId: String!, $id: ID!) { + knowledge(workspaceId: $workspaceId, id: $id) { + ...KnowledgeFields + } + } +` + +export const GET_KNOWLEDGE_TAGS = gql` + query GetKnowledgeTags($workspaceId: String!) { + knowledgeTags(workspaceId: $workspaceId) + } +` + +export const SEARCH_KNOWLEDGE = gql` + ${KNOWLEDGE_FIELDS} + query SearchKnowledge($workspaceId: String!, $query: String!, $tags: [String!], $limit: Int) { + searchKnowledge(workspaceId: $workspaceId, query: $query, tags: $tags, limit: $limit) { + ...KnowledgeFields + } + } +` + +export const CREATE_KNOWLEDGE = gql` + ${KNOWLEDGE_FIELDS} + mutation CreateKnowledge($workspaceId: String!, $input: CreateKnowledgeInput!) { + createKnowledge(workspaceId: $workspaceId, input: $input) { + ...KnowledgeFields + } + } +` + +export const UPDATE_KNOWLEDGE = gql` + ${KNOWLEDGE_FIELDS} + mutation UpdateKnowledge($workspaceId: String!, $input: UpdateKnowledgeInput!) { + updateKnowledge(workspaceId: $workspaceId, input: $input) { + ...KnowledgeFields + } + } +` + +export const DELETE_KNOWLEDGE = gql` + mutation DeleteKnowledge($workspaceId: String!, $id: ID!) { + deleteKnowledge(workspaceId: $workspaceId, id: $id) + } +` diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index d0efc44a..a59e31dc 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -622,4 +622,30 @@ export const en: Messages = { memoIdLabel: 'ID', memoCreatorLabel: 'Creator', memoCountLabel: '{count} memos', + + // Knowledge + navKnowledge: 'Knowledge', + titleKnowledge: 'Knowledge', + subtitleKnowledge: 'Manage knowledge base entries for this workspace', + btnAddKnowledge: 'Add Knowledge', + placeholderKnowledgeSearch: 'Search knowledge...', + labelKnowledgeTags: 'Tags', + labelKnowledgeClaim: 'Claim', + labelKnowledgeTagsRequired: 'Tags *', + placeholderKnowledgeTitle: 'Enter knowledge title', + placeholderKnowledgeClaim: 'Write claim in Markdown...', + placeholderKnowledgeTagInput: 'Type a tag and press Enter or comma', + errorKnowledgeTitleRequired: 'Title is required', + errorKnowledgeTagsRequired: 'At least one tag is required', + errorKnowledgeClaimTooLong: 'Claim must be 8000 characters or less', + errorKnowledgeNotFound: 'Knowledge not found', + emptyKnowledge: 'No knowledge entries yet. Add the first one.', + emptyKnowledgeSearch: 'No knowledge entries match your search.', + knowledgeFilterAll: 'All', + knowledgeCharCount: '{count} / {max}', + knowledgeCharWarn: 'Approaching character limit', + titleDeleteKnowledge: 'Delete Knowledge', + msgDeleteKnowledgeConfirm: 'Are you sure you want to delete {title}?', + warningDeleteKnowledgePermanent: 'This action cannot be undone.', + btnSaveHint: '⌘+Enter to save', } diff --git a/frontend/src/i18n/ja.ts b/frontend/src/i18n/ja.ts index 9b7cc454..defe0837 100644 --- a/frontend/src/i18n/ja.ts +++ b/frontend/src/i18n/ja.ts @@ -622,4 +622,30 @@ export const ja: Messages = { memoIdLabel: 'ID', memoCreatorLabel: '作成者', memoCountLabel: '{count} 件', + + // Knowledge + navKnowledge: 'Knowledge', + titleKnowledge: 'Knowledge', + subtitleKnowledge: 'このワークスペースのナレッジベースを管理', + btnAddKnowledge: 'Knowledge を追加', + placeholderKnowledgeSearch: 'Knowledge を検索...', + labelKnowledgeTags: 'タグ', + labelKnowledgeClaim: 'Claim', + labelKnowledgeTagsRequired: 'タグ *', + placeholderKnowledgeTitle: 'Knowledge のタイトルを入力', + placeholderKnowledgeClaim: 'Claim を Markdown で記述...', + placeholderKnowledgeTagInput: 'タグを入力して Enter またはカンマで追加', + errorKnowledgeTitleRequired: 'タイトルは必須です', + errorKnowledgeTagsRequired: 'タグを1つ以上追加してください', + errorKnowledgeClaimTooLong: 'Claim は8000文字以内で入力してください', + errorKnowledgeNotFound: 'Knowledge が見つかりません', + emptyKnowledge: 'Knowledge がまだありません。最初の1件を追加してください。', + emptyKnowledgeSearch: '検索条件に一致する Knowledge がありません。', + knowledgeFilterAll: 'すべて', + knowledgeCharCount: '{count} / {max}', + knowledgeCharWarn: '文字数制限に近づいています', + titleDeleteKnowledge: 'Knowledge を削除', + msgDeleteKnowledgeConfirm: '{title} を削除してもよろしいですか?', + warningDeleteKnowledgePermanent: 'この操作は取り消せません。', + btnSaveHint: '⌘+Enter で保存', } diff --git a/frontend/src/i18n/keys.ts b/frontend/src/i18n/keys.ts index 4d712a2d..aabd19c3 100644 --- a/frontend/src/i18n/keys.ts +++ b/frontend/src/i18n/keys.ts @@ -617,6 +617,32 @@ export const msgKeys = { memoIdLabel: 'memoIdLabel', memoCreatorLabel: 'memoCreatorLabel', memoCountLabel: 'memoCountLabel', + + // Knowledge + navKnowledge: 'navKnowledge', + titleKnowledge: 'titleKnowledge', + subtitleKnowledge: 'subtitleKnowledge', + btnAddKnowledge: 'btnAddKnowledge', + placeholderKnowledgeSearch: 'placeholderKnowledgeSearch', + labelKnowledgeTags: 'labelKnowledgeTags', + labelKnowledgeClaim: 'labelKnowledgeClaim', + labelKnowledgeTagsRequired: 'labelKnowledgeTagsRequired', + placeholderKnowledgeTitle: 'placeholderKnowledgeTitle', + placeholderKnowledgeClaim: 'placeholderKnowledgeClaim', + placeholderKnowledgeTagInput: 'placeholderKnowledgeTagInput', + errorKnowledgeTitleRequired: 'errorKnowledgeTitleRequired', + errorKnowledgeTagsRequired: 'errorKnowledgeTagsRequired', + errorKnowledgeClaimTooLong: 'errorKnowledgeClaimTooLong', + errorKnowledgeNotFound: 'errorKnowledgeNotFound', + emptyKnowledge: 'emptyKnowledge', + emptyKnowledgeSearch: 'emptyKnowledgeSearch', + knowledgeFilterAll: 'knowledgeFilterAll', + knowledgeCharCount: 'knowledgeCharCount', + knowledgeCharWarn: 'knowledgeCharWarn', + titleDeleteKnowledge: 'titleDeleteKnowledge', + msgDeleteKnowledgeConfirm: 'msgDeleteKnowledgeConfirm', + warningDeleteKnowledgePermanent: 'warningDeleteKnowledgePermanent', + btnSaveHint: 'btnSaveHint', } as const export type MsgKey = keyof typeof msgKeys diff --git a/frontend/src/pages/KnowledgeDetail.tsx b/frontend/src/pages/KnowledgeDetail.tsx new file mode 100644 index 00000000..70d92288 --- /dev/null +++ b/frontend/src/pages/KnowledgeDetail.tsx @@ -0,0 +1,345 @@ +import { useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useMutation, useQuery } from '@apollo/client' +import { + GET_KNOWLEDGE, + GET_KNOWLEDGES, + GET_KNOWLEDGE_TAGS, + CREATE_KNOWLEDGE, + UPDATE_KNOWLEDGE, + DELETE_KNOWLEDGE, +} from '../graphql/knowledge' +import { useWorkspace } from '../contexts/workspace-context' +import { useTranslation } from '../i18n' +import Button from '../components/Button' +import Modal from '../components/Modal' +import { IconChevLeft } from '../components/Icons' +import TagInput from '../components/knowledge/TagInput' +import KnowledgeMarkdownView from '../components/knowledge/KnowledgeMarkdownView' +import { commitOnEnter } from '../utils/keyboard' + +const CLAIM_MAX = 8000 + +function formatDateTime(iso: string) { + if (!iso) return '—' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '—' + return d.toLocaleString() +} + +export default function KnowledgeDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { currentWorkspace } = useWorkspace() + const { t } = useTranslation() + + const isNew = id === 'new' + + // form state + const [title, setTitle] = useState('') + const [claim, setClaim] = useState('') + const [tags, setTags] = useState([]) + const [previewMode, setPreviewMode] = useState(false) + const [showDelete, setShowDelete] = useState(false) + const [saving, setSaving] = useState(false) + + // Track if we've loaded existing data into the form + const [initialized, setInitialized] = useState(isNew) + + const { data: tagsData } = useQuery(GET_KNOWLEDGE_TAGS, { + variables: { workspaceId: currentWorkspace?.id }, + skip: !currentWorkspace, + }) + const allTags: string[] = tagsData?.knowledgeTags ?? [] + + const { data, loading } = useQuery(GET_KNOWLEDGE, { + variables: { workspaceId: currentWorkspace?.id, id }, + skip: !currentWorkspace || !id || isNew, + onCompleted(d) { + if (!initialized && d.knowledge) { + setTitle(d.knowledge.title) + setClaim(d.knowledge.claim ?? '') + setTags(d.knowledge.tags) + setInitialized(true) + } + }, + }) + + const refetchList = [ + { query: GET_KNOWLEDGES, variables: { workspaceId: currentWorkspace?.id } }, + { query: GET_KNOWLEDGE_TAGS, variables: { workspaceId: currentWorkspace?.id } }, + ] + const refetchDetail = isNew + ? refetchList + : [ + ...refetchList, + { query: GET_KNOWLEDGE, variables: { workspaceId: currentWorkspace?.id, id } }, + ] + + const [createKnowledge] = useMutation(CREATE_KNOWLEDGE, { refetchQueries: refetchList }) + const [updateKnowledge] = useMutation(UPDATE_KNOWLEDGE, { refetchQueries: refetchDetail }) + const [deleteKnowledge] = useMutation(DELETE_KNOWLEDGE, { refetchQueries: refetchList }) + + const knowledge = data?.knowledge + + const titleError = title.trim() === '' + const tagsError = tags.length === 0 + const claimOverLimit = claim.length > CLAIM_MAX + const canSave = !titleError && !tagsError && !claimOverLimit && !saving + + const handleSave = async () => { + if (!canSave || !currentWorkspace) return + setSaving(true) + try { + if (isNew) { + const result = await createKnowledge({ + variables: { + workspaceId: currentWorkspace.id, + input: { title: title.trim(), claim: claim || undefined, tags }, + }, + }) + const newId = result.data?.createKnowledge?.id + if (newId) { + navigate(`/ws/${currentWorkspace.id}/knowledge/${newId}`, { replace: true }) + } + } else { + await updateKnowledge({ + variables: { + workspaceId: currentWorkspace.id, + input: { id: id!, title: title.trim(), claim: claim || undefined, tags }, + }, + }) + } + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + if (!currentWorkspace || !id) return + await deleteKnowledge({ variables: { workspaceId: currentWorkspace.id, id } }) + navigate(`/ws/${currentWorkspace.id}/knowledge`) + } + + const titleKeyDown = commitOnEnter({ onCommit: () => { /* no-op: title is single-line input */ } }) + const claimKeyDown = commitOnEnter({ onCommit: handleSave, requireModifier: true }) + + if (!isNew && loading) { + return
{t('loading')}
+ } + if (!isNew && initialized && !knowledge) { + return
{t('errorKnowledgeNotFound')}
+ } + + const claimNearLimit = claim.length > CLAIM_MAX * 0.9 + + return ( +
+ {/* Back + action bar */} +
+ + + {!isNew && ( + + )} + +
+ + {/* Main content + right rail */} +
+ {/* Left: title + claim */} +
+ {/* Title */} +
+
{t('labelTitle')}
+ setTitle(e.target.value)} + onKeyDown={titleKeyDown} + placeholder={t('placeholderKnowledgeTitle')} + style={{ + width: '100%', + background: 'none', + border: 'none', + outline: 'none', + fontSize: 'var(--t-xl)', + fontWeight: 600, + color: 'var(--fg)', + padding: '4px 0', + }} + data-testid="knowledge-title-input" + /> + {titleError && title !== '' && ( +
+ {t('errorKnowledgeTitleRequired')} +
+ )} + {!isNew && knowledge && ( +
+
+ {t('labelCreated')} + + {formatDateTime(knowledge.createdAt)} + +
+
+ {t('labelUpdated')} + + {formatDateTime(knowledge.updatedAt)} + +
+
+ )} +
+ + {/* Claim editor */} +
+
+
{t('labelKnowledgeClaim')}
+ +
+ + +
+
+ + {previewMode ? ( +
+ +
+ ) : ( + <> +