feat: add Ollama icon and modern chat UI styling
- src/chat-view.ts: Add getIcon() returning 'bot' for the view tab icon.
Improve render() with role-specific CSS classes (user vs assistant) and
message header structure for better styling hooks.
- src/main.ts: Add ribbon icon ('bot') in the left sidebar that opens the
chat view with a single click.
- styles.css (new): Modern chat UI with message bubbles, distinct user and
assistant themes using Obsidian CSS variables, sticky input bar, styled
send button with accent color, and emoji role indicators.
- install.sh: Copy styles.css into the plugin directory and verify its
presence during installation.
- README.md: Include styles.css in manual install instructions.
- __mocks__/obsidian.ts: Add addRibbonIcon() mock for test compatibility.
- tests/chat-view.test.ts: Add getIcon() assertion.
This commit is contained in:
@@ -66,6 +66,7 @@ Then copy the plugin into your vault:
|
|||||||
mkdir -p /path/to/vault/.obsidian/plugins/ollama-plugin
|
mkdir -p /path/to/vault/.obsidian/plugins/ollama-plugin
|
||||||
cp manifest.json /path/to/vault/.obsidian/plugins/ollama-plugin/
|
cp manifest.json /path/to/vault/.obsidian/plugins/ollama-plugin/
|
||||||
cp main.js /path/to/vault/.obsidian/plugins/ollama-plugin/
|
cp main.js /path/to/vault/.obsidian/plugins/ollama-plugin/
|
||||||
|
cp styles.css /path/to/vault/.obsidian/plugins/ollama-plugin/
|
||||||
# Remove old dist/ from previous installs (no longer needed with bundling)
|
# Remove old dist/ from previous installs (no longer needed with bundling)
|
||||||
rm -rf /path/to/vault/.obsidian/plugins/ollama-plugin/dist
|
rm -rf /path/to/vault/.obsidian/plugins/ollama-plugin/dist
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -110,4 +110,8 @@ export class Plugin {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.app = new App();
|
this.app = new App();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRibbonIcon(_icon: string, _title: string, _callback: () => void): HTMLElement {
|
||||||
|
return document.createElement('div');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -95,9 +95,10 @@ mkdir -p "$PLUGIN_DIR"
|
|||||||
# Remove old dist/ directory from previous installations (no longer needed with bundling)
|
# Remove old dist/ directory from previous installations (no longer needed with bundling)
|
||||||
rm -rf "$PLUGIN_DIR/dist"
|
rm -rf "$PLUGIN_DIR/dist"
|
||||||
|
|
||||||
# Copy manifest and bundled entry point
|
# Copy manifest, bundled entry point, and styles
|
||||||
cp "$SCRIPT_DIR/manifest.json" "$PLUGIN_DIR/"
|
cp "$SCRIPT_DIR/manifest.json" "$PLUGIN_DIR/"
|
||||||
cp "$SCRIPT_DIR/main.js" "$PLUGIN_DIR/"
|
cp "$SCRIPT_DIR/main.js" "$PLUGIN_DIR/"
|
||||||
|
cp "$SCRIPT_DIR/styles.css" "$PLUGIN_DIR/"
|
||||||
|
|
||||||
# All dependencies are now bundled into main.js; no runtime node_modules needed.
|
# All dependencies are now bundled into main.js; no runtime node_modules needed.
|
||||||
|
|
||||||
@@ -107,9 +108,10 @@ info "Plugin installed to $PLUGIN_DIR"
|
|||||||
|
|
||||||
step "Verifying installation"
|
step "Verifying installation"
|
||||||
|
|
||||||
if [ -f "$PLUGIN_DIR/manifest.json" ] && [ -f "$PLUGIN_DIR/main.js" ]; then
|
if [ -f "$PLUGIN_DIR/manifest.json" ] && [ -f "$PLUGIN_DIR/main.js" ] && [ -f "$PLUGIN_DIR/styles.css" ]; then
|
||||||
info "manifest.json ✓"
|
info "manifest.json ✓"
|
||||||
info "main.js ✓"
|
info "main.js ✓"
|
||||||
|
info "styles.css ✓"
|
||||||
else
|
else
|
||||||
error "Installation verification failed — missing files in $PLUGIN_DIR"
|
error "Installation verification failed — missing files in $PLUGIN_DIR"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -9005,6 +9005,9 @@ var ChatView = class extends import_obsidian3.ItemView {
|
|||||||
getDisplayText() {
|
getDisplayText() {
|
||||||
return "Ollama Chat";
|
return "Ollama Chat";
|
||||||
}
|
}
|
||||||
|
getIcon() {
|
||||||
|
return "bot";
|
||||||
|
}
|
||||||
async onOpen() {
|
async onOpen() {
|
||||||
try {
|
try {
|
||||||
await this.ollamaClient.initializeCache();
|
await this.ollamaClient.initializeCache();
|
||||||
@@ -9059,9 +9062,12 @@ var ChatView = class extends import_obsidian3.ItemView {
|
|||||||
contentEl.textContent = msg.content;
|
contentEl.textContent = msg.content;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const messageEl = container.createEl("div", { cls: "ollama-message" });
|
const messageEl = container.createEl("div", {
|
||||||
|
cls: `ollama-message ollama-message-${msg.role}`
|
||||||
|
});
|
||||||
messageEl.setAttribute("data-msg-id", msg.id);
|
messageEl.setAttribute("data-msg-id", msg.id);
|
||||||
messageEl.createEl("div", { cls: "ollama-message-role", text: msg.role });
|
const headerEl = messageEl.createEl("div", { cls: "ollama-message-header" });
|
||||||
|
headerEl.createEl("span", { cls: "ollama-message-role", text: msg.role });
|
||||||
const contentEl = messageEl.createEl("div", { cls: "ollama-message-content" });
|
const contentEl = messageEl.createEl("div", { cls: "ollama-message-content" });
|
||||||
contentEl.textContent = msg.content;
|
contentEl.textContent = msg.content;
|
||||||
}
|
}
|
||||||
@@ -9307,7 +9313,10 @@ var ChatView = class extends import_obsidian3.ItemView {
|
|||||||
this.lastMessageEl = previousStreamingEl;
|
this.lastMessageEl = previousStreamingEl;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const entries = await this.vaultIndexer.searchVault(userMessage, this.settings.vaultSearchLimit);
|
const entries = await this.vaultIndexer.searchVault(
|
||||||
|
userMessage,
|
||||||
|
this.settings.vaultSearchLimit
|
||||||
|
);
|
||||||
const context = entries.map((entry) => `${entry.title}
|
const context = entries.map((entry) => `${entry.title}
|
||||||
${entry.content}`).join("\n\n").slice(0, MAX_CONTEXT_LENGTH);
|
${entry.content}`).join("\n\n").slice(0, MAX_CONTEXT_LENGTH);
|
||||||
const userMessageWithContext = context ? `Relevant vault context:
|
const userMessageWithContext = context ? `Relevant vault context:
|
||||||
@@ -9402,6 +9411,9 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
|||||||
"ollama-chat-view",
|
"ollama-chat-view",
|
||||||
(leaf) => new ChatView(leaf, this.settings)
|
(leaf) => new ChatView(leaf, this.settings)
|
||||||
);
|
);
|
||||||
|
this.addRibbonIcon("bot", "Open Ollama Chat", async () => {
|
||||||
|
await this.activateChatView();
|
||||||
|
});
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "open-ollama-chat",
|
id: "open-ollama-chat",
|
||||||
name: "Open Ollama Chat",
|
name: "Open Ollama Chat",
|
||||||
|
|||||||
+26
-13
@@ -76,6 +76,10 @@ export class ChatView extends ItemView {
|
|||||||
return 'Ollama Chat';
|
return 'Ollama Chat';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getIcon(): string {
|
||||||
|
return 'bot';
|
||||||
|
}
|
||||||
|
|
||||||
async onOpen(): Promise<void> {
|
async onOpen(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.ollamaClient.initializeCache();
|
await this.ollamaClient.initializeCache();
|
||||||
@@ -145,9 +149,14 @@ export class ChatView extends ItemView {
|
|||||||
contentEl.textContent = msg.content;
|
contentEl.textContent = msg.content;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const messageEl = container.createEl('div', { cls: 'ollama-message' });
|
const messageEl = container.createEl('div', {
|
||||||
|
cls: `ollama-message ollama-message-${msg.role}`,
|
||||||
|
});
|
||||||
messageEl.setAttribute('data-msg-id', msg.id);
|
messageEl.setAttribute('data-msg-id', msg.id);
|
||||||
messageEl.createEl('div', { cls: 'ollama-message-role', text: msg.role });
|
|
||||||
|
const headerEl = messageEl.createEl('div', { cls: 'ollama-message-header' });
|
||||||
|
headerEl.createEl('span', { cls: 'ollama-message-role', text: msg.role });
|
||||||
|
|
||||||
const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' });
|
const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' });
|
||||||
contentEl.textContent = msg.content;
|
contentEl.textContent = msg.content;
|
||||||
}
|
}
|
||||||
@@ -366,15 +375,15 @@ export class ChatView extends ItemView {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const toolResults = (
|
const toolResults = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
|
toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
|
||||||
try {
|
try {
|
||||||
const toolResult = await this.toolExecutor.handleToolCall(toolCall);
|
const toolResult = await this.toolExecutor.handleToolCall(toolCall);
|
||||||
return { ...toolResult, id: toolCall.id };
|
return { ...toolResult, id: toolCall.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
).filter((result): result is NonNullable<typeof result> => result !== null);
|
).filter((result): result is NonNullable<typeof result> => result !== null);
|
||||||
|
|
||||||
@@ -449,7 +458,10 @@ export class ChatView extends ItemView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await this.vaultIndexer.searchVault(userMessage, this.settings.vaultSearchLimit);
|
const entries = await this.vaultIndexer.searchVault(
|
||||||
|
userMessage,
|
||||||
|
this.settings.vaultSearchLimit
|
||||||
|
);
|
||||||
const context = entries
|
const context = entries
|
||||||
.map((entry) => `${entry.title}\n${entry.content}`)
|
.map((entry) => `${entry.title}\n${entry.content}`)
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
@@ -459,7 +471,8 @@ export class ChatView extends ItemView {
|
|||||||
: userMessage;
|
: userMessage;
|
||||||
|
|
||||||
// Get the complete messages array for the LLM with all context layers
|
// Get the complete messages array for the LLM with all context layers
|
||||||
const completeMessages = this.conversationStateManager.getCompleteMessages(userMessageWithContext);
|
const completeMessages =
|
||||||
|
this.conversationStateManager.getCompleteMessages(userMessageWithContext);
|
||||||
|
|
||||||
const stream = this.ollamaClient.streamChat(completeMessages, tools);
|
const stream = this.ollamaClient.streamChat(completeMessages, tools);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export default class OllamaPlugin extends Plugin {
|
|||||||
(leaf: WorkspaceLeaf) => new ChatView(leaf, this.settings)
|
(leaf: WorkspaceLeaf) => new ChatView(leaf, this.settings)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add a ribbon icon in the left sidebar
|
||||||
|
this.addRibbonIcon('bot', 'Open Ollama Chat', async () => {
|
||||||
|
await this.activateChatView();
|
||||||
|
});
|
||||||
|
|
||||||
// Add a command to open the chat view
|
// Add a command to open the chat view
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: 'open-ollama-chat',
|
id: 'open-ollama-chat',
|
||||||
|
|||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
:root {
|
||||||
|
--ollama-user-bg: var(--background-modifier-form-field);
|
||||||
|
--ollama-assistant-bg: var(--background-primary-alt);
|
||||||
|
--ollama-border: var(--background-modifier-border);
|
||||||
|
--ollama-radius: var(--radius-m);
|
||||||
|
--ollama-gap: var(--size-4-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ollama-gap);
|
||||||
|
padding: var(--size-4-2);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-4-1);
|
||||||
|
padding: var(--size-4-2);
|
||||||
|
border-radius: var(--ollama-radius);
|
||||||
|
border: 1px solid var(--ollama-border);
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-message-user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background-color: var(--ollama-user-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-message-assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background-color: var(--ollama-assistant-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4-1);
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-message-role::before {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
margin-right: var(--size-4-1);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-message-user .ollama-message-role::before {
|
||||||
|
content: '👤';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-message-assistant .ollama-message-role::before {
|
||||||
|
content: '🤖';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-message-content {
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-new-chat-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: var(--size-4-1) var(--size-4-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-new-chat-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4-1);
|
||||||
|
padding: var(--size-4-1) var(--size-4-2);
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
border-radius: var(--ollama-radius);
|
||||||
|
border: 1px solid var(--ollama-border);
|
||||||
|
background-color: var(--background-modifier-form-field);
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-new-chat-button:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-new-chat-button::before {
|
||||||
|
content: '➕';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-input-container {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-4-1);
|
||||||
|
padding: var(--size-4-2);
|
||||||
|
border-top: 1px solid var(--ollama-border);
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
max-height: 8rem;
|
||||||
|
padding: var(--size-4-1) var(--size-4-2);
|
||||||
|
border-radius: var(--ollama-radius);
|
||||||
|
border: 1px solid var(--ollama-border);
|
||||||
|
background-color: var(--background-modifier-form-field);
|
||||||
|
color: var(--text-normal);
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--interactive-accent-translucent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-send-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--size-4-1) var(--size-4-3);
|
||||||
|
border-radius: var(--ollama-radius);
|
||||||
|
border: none;
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-send-button:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-send-button:active {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
@@ -100,6 +100,12 @@ describe('ChatView', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getIcon', () => {
|
||||||
|
it('should return the bot icon', () => {
|
||||||
|
expect(view.getIcon()).toBe('bot');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('onOpen', () => {
|
describe('onOpen', () => {
|
||||||
it('should call render and setup event listeners', async () => {
|
it('should call render and setup event listeners', async () => {
|
||||||
const renderSpy = jest.spyOn(view, 'render');
|
const renderSpy = jest.spyOn(view, 'render');
|
||||||
|
|||||||
Reference in New Issue
Block a user