diff --git a/server/hocuspocus/src/extractPlainTextFromYXml.test.ts b/server/hocuspocus/src/extractPlainTextFromYXml.test.ts new file mode 100644 index 00000000..edac4134 --- /dev/null +++ b/server/hocuspocus/src/extractPlainTextFromYXml.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; +import { buildContentPreview, extractTextFromYXml } from "./extractPlainTextFromYXml.js"; + +describe("extractTextFromYXml", () => { + it("does not insert a newline inside a paragraph between inline bold and following text", () => { + const doc = new Y.Doc(); + doc.transact(() => { + const fragment = doc.getXmlFragment("default"); + const paragraph = new Y.XmlElement("paragraph"); + fragment.push([paragraph]); + const t1 = new Y.XmlText(); + t1.insert(0, "Hello "); + paragraph.push([t1]); + const bold = new Y.XmlElement("bold"); + paragraph.push([bold]); + const tBold = new Y.XmlText(); + tBold.insert(0, "world"); + bold.push([tBold]); + const t2 = new Y.XmlText(); + t2.insert(0, "!"); + paragraph.push([t2]); + }); + + const plain = extractTextFromYXml(doc.getXmlFragment("default")).trim(); + expect(plain).not.toMatch(/world\s*\n\s*!/); + expect(plain.replace(/\s+/g, " ").trim()).toBe("Hello world !"); + }); + + it("separates block-level paragraphs with newlines", () => { + const doc = new Y.Doc(); + doc.transact(() => { + const fragment = doc.getXmlFragment("default"); + const p1 = new Y.XmlElement("paragraph"); + fragment.push([p1]); + const a = new Y.XmlText(); + a.insert(0, "First"); + p1.push([a]); + const p2 = new Y.XmlElement("paragraph"); + fragment.push([p2]); + const b = new Y.XmlText(); + b.insert(0, "Second"); + p2.push([b]); + }); + + const plain = extractTextFromYXml(doc.getXmlFragment("default")).trim(); + expect(plain).toMatch(/First/); + expect(plain).toMatch(/Second/); + expect(plain.includes("First") && plain.includes("Second")).toBe(true); + expect(/\n/.test(plain)).toBe(true); + }); +}); + +describe("buildContentPreview", () => { + it("collapses whitespace and truncates", () => { + expect(buildContentPreview(" a \n b ")).toBe("a b"); + const long = "x".repeat(200); + const prev = buildContentPreview(long); + expect(prev.endsWith("...")).toBe(true); + expect(prev.length).toBeLessThanOrEqual(124); + }); +}); diff --git a/server/hocuspocus/src/extractPlainTextFromYXml.ts b/server/hocuspocus/src/extractPlainTextFromYXml.ts new file mode 100644 index 00000000..06ae89ca --- /dev/null +++ b/server/hocuspocus/src/extractPlainTextFromYXml.ts @@ -0,0 +1,65 @@ +import * as Y from "yjs"; + +/** + * TipTap / ProseMirror のマーク相当で、Y.Xml 上に子要素として現れるインライン名。 + * 兄弟インライン間では改行ではなくスペースを挟み、プレビュー用テキストの不自然な改行を防ぐ。 + * + * Mark-like XmlElement names in the Y.Xml tree; use a space (not a newline) between + * inline siblings so plain-text extraction does not split words (e.g. `Hello world!`). + */ +const INLINE_XML_ELEMENT_NAMES = new Set([ + "bold", + "italic", + "strike", + "code", + "link", + "underline", + "highlight", + "subscript", + "superscript", + "textStyle", +]); + +/** + * インライン要素かどうかを nodeName で判定する。 + * Determine whether an XmlElement is inline-only (no trailing newline after its subtree). + */ +function isInlineXmlElement(node: Y.XmlElement): boolean { + return INLINE_XML_ELEMENT_NAMES.has(node.nodeName); +} + +/** + * Y.Doc の XmlFragment(または XmlElement 根)からプレーンテキストを再帰的に抽出する。 + * Recursively extract plain text from a Y.XmlFragment or Y.XmlElement subtree. + */ +export function extractTextFromYXml(node: Y.XmlFragment | Y.XmlElement): string { + let text = ""; + + for (let i = 0; i < node.length; i++) { + const child = node.get(i); + if (child instanceof Y.XmlText) { + text += child.toString(); + } else if (child instanceof Y.XmlElement) { + const inner = extractTextFromYXml(child); + const suffix = isInlineXmlElement(child) ? " " : "\n"; + text += inner + suffix; + } + } + return text; +} + +/** + * プレビュー文字列の最大長(DB の content_preview と一致させる)。 + * Max length for content preview (aligned with `pages.content_preview`). + */ +export const CONTENT_PREVIEW_MAX_LENGTH = 120; + +/** + * プレーンテキストからコンテンツプレビュー(先頭120文字)を生成する。 + * Generate content preview (first 120 chars) from plain text. + */ +export function buildContentPreview(text: string): string { + const trimmed = text.trim().replace(/\s+/g, " "); + if (trimmed.length <= CONTENT_PREVIEW_MAX_LENGTH) return trimmed; + return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH).trim() + "..."; +} diff --git a/server/hocuspocus/src/index.ts b/server/hocuspocus/src/index.ts index 21209113..03c8e6ac 100644 --- a/server/hocuspocus/src/index.ts +++ b/server/hocuspocus/src/index.ts @@ -9,6 +9,7 @@ import { isTruthyEnvFlag, warnDevAuthBypassOnce, } from "./dev-auth-bypass.js"; +import { buildContentPreview, extractTextFromYXml } from "./extractPlainTextFromYXml.js"; const PORT = parseInt(process.env.PORT || "1234", 10); const REDIS_URL = process.env.REDIS_URL; @@ -193,39 +194,9 @@ async function loadDocumentFromDb(pageId: string): Promise { } } -/** - * Y.Doc の XmlFragment からプレーンテキストを再帰的に抽出するヘルパー。 - * Recursively extract plain text from a Y.Doc XmlFragment. - */ -function extractTextFromFragment(node: Y.XmlFragment): string { - let text = ""; - - for (let i = 0; i < node.length; i++) { - const child = node.get(i); - if (child instanceof Y.XmlText) { - text += child.toString(); - } else if (child instanceof Y.XmlElement) { - text += extractTextFromFragment(child) + "\n"; - } - } - return text; -} - -const CONTENT_PREVIEW_MAX_LENGTH = 120; - -/** - * プレーンテキストからコンテンツプレビュー(先頭120文字)を生成する。 - * Generate content preview (first 120 chars) from plain text. - */ -function buildContentPreview(text: string): string { - const trimmed = text.trim().replace(/\s+/g, " "); - if (trimmed.length <= CONTENT_PREVIEW_MAX_LENGTH) return trimmed; - return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH).trim() + "..."; -} - async function saveDocumentToDb(pageId: string, document: Y.Doc): Promise { const encodedState = Buffer.from(Y.encodeStateAsUpdate(document)); - const contentText = extractTextFromFragment(document.getXmlFragment("default")); + const contentText = extractTextFromYXml(document.getXmlFragment("default")); const contentPreview = buildContentPreview(contentText); const client = await getPool().connect(); try { diff --git a/src/components/ai-chat/AIChatWikiLink.tsx b/src/components/ai-chat/AIChatWikiLink.tsx index a14dc5a2..5330374a 100644 --- a/src/components/ai-chat/AIChatWikiLink.tsx +++ b/src/components/ai-chat/AIChatWikiLink.tsx @@ -41,8 +41,8 @@ export function AIChatWikiLink({ title }: AIChatWikiLinkProps) { const [isOpen, setIsOpen] = useState(false); const contentRef = useRef(null); - const longPressTimerRef = useRef(); - const preventClickResetTimerRef = useRef(); + const longPressTimerRef = useRef(undefined); + const preventClickResetTimerRef = useRef(undefined); const preventClickRef = useRef(false); const handleTouchStart = useCallback(() => { diff --git a/src/lib/aiClient.test.ts b/src/lib/aiClient.test.ts index 14b9a178..33222a79 100644 --- a/src/lib/aiClient.test.ts +++ b/src/lib/aiClient.test.ts @@ -152,5 +152,11 @@ describe("aiClient", () => { expect(result.success).toBe(false); expect(result.message).toContain("APIキー"); }); + + it("returns Claude Code guidance when API key is empty (no API key required)", async () => { + const result = await testConnection("claude-code", ""); + expect(result.success).toBe(false); + expect(result.message).toContain("Claude Code"); + }); }); }); diff --git a/src/lib/aiClient.ts b/src/lib/aiClient.ts index 449deff1..e07d465c 100644 --- a/src/lib/aiClient.ts +++ b/src/lib/aiClient.ts @@ -311,8 +311,9 @@ export async function testConnection( provider: AIProviderType, apiKey: string, ): Promise { - // APIキーが必要 - if (!apiKey || apiKey.trim() === "") { + // Claude Code は API キー不要のため、空キーでも専用メッセージへ進める。 + // Claude Code does not use an API key; allow reaching the provider-specific branch. + if (provider !== "claude-code" && (!apiKey || apiKey.trim() === "")) { return { success: false, message: "APIキーを入力してください",